mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-13 02:54:26 +08:00
feat(web): 新增控制台布局切换并完善状态与磁盘交互
新增经典、悬浮和侧栏布局,保存用户偏好,并调整工具栏、状态摘要及窄屏交互。 统一控制台弹层外观、焦点行为和中英文文案。 根据虚拟磁盘文件访问能力显示浏览入口、容量信息和不可用状态。 验证:npm run build 通过(vue-tsc 类型检查和 Vite 生产构建)。
This commit is contained in:
@@ -583,6 +583,16 @@ export interface DriveFile {
|
|||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DriveFileAccess = 'available' | 'unsupported' | 'blocked_while_connected' | 'unknown'
|
||||||
|
|
||||||
|
export interface DriveInfo {
|
||||||
|
size: number
|
||||||
|
used: number | null
|
||||||
|
free: number | null
|
||||||
|
initialized: boolean
|
||||||
|
file_access: DriveFileAccess
|
||||||
|
}
|
||||||
|
|
||||||
export type DiskMode = 'single' | 'multi'
|
export type DiskMode = 'single' | 'multi'
|
||||||
export type MountedMediaKind = 'drive' | 'image'
|
export type MountedMediaKind = 'drive' | 'image'
|
||||||
|
|
||||||
@@ -604,12 +614,7 @@ export const msdApi = {
|
|||||||
slot_capacity: number
|
slot_capacity: number
|
||||||
mounted_count: number
|
mounted_count: number
|
||||||
mounted_media: MountedMedia[]
|
mounted_media: MountedMedia[]
|
||||||
drive_info: {
|
drive_info: DriveInfo | null
|
||||||
size: number
|
|
||||||
used: number
|
|
||||||
free: number
|
|
||||||
initialized: boolean
|
|
||||||
} | null
|
|
||||||
usb_reenumerating: boolean
|
usb_reenumerating: boolean
|
||||||
}
|
}
|
||||||
}>('/msd/status', {}, { toastOnError: false }),
|
}>('/msd/status', {}, { toastOnError: false }),
|
||||||
@@ -650,20 +655,10 @@ export const msdApi = {
|
|||||||
request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountDrive' }),
|
request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountDrive' }),
|
||||||
|
|
||||||
driveInfo: () =>
|
driveInfo: () =>
|
||||||
request<{
|
request<DriveInfo>('/msd/drive', {}, { toastOnError: false }),
|
||||||
size: number
|
|
||||||
used: number
|
|
||||||
free: number
|
|
||||||
initialized: boolean
|
|
||||||
}>('/msd/drive', {}, { toastOnError: false }),
|
|
||||||
|
|
||||||
initDrive: (sizeMb?: number) =>
|
initDrive: (sizeMb?: number) =>
|
||||||
request<{
|
request<DriveInfo>(
|
||||||
size: number
|
|
||||||
used: number
|
|
||||||
free: number
|
|
||||||
initialized: boolean
|
|
||||||
}>(
|
|
||||||
'/msd/drive/init',
|
'/msd/drive/init',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Bot,
|
Bot,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
Keyboard,
|
||||||
|
Scaling,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import PasteModal from '@/components/PasteModal.vue'
|
import PasteModal from '@/components/PasteModal.vue'
|
||||||
import AtxPopover from '@/components/AtxPopover.vue'
|
import AtxPopover from '@/components/AtxPopover.vue'
|
||||||
@@ -47,6 +51,7 @@ import HidConfigPopover from '@/components/HidConfigPopover.vue'
|
|||||||
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
|
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
|
||||||
import MsdDialog from '@/components/MsdDialog.vue'
|
import MsdDialog from '@/components/MsdDialog.vue'
|
||||||
import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
|
import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
|
||||||
|
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -60,6 +65,7 @@ const showMsd = computed(() => {
|
|||||||
return !!systemStore.msd?.available && !isCh9329Backend.value
|
return !!systemStore.msd?.available && !isCh9329Backend.value
|
||||||
})
|
})
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
layout?: ConsoleLayout
|
||||||
mouseMode?: 'absolute' | 'relative'
|
mouseMode?: 'absolute' | 'relative'
|
||||||
videoMode?: VideoMode
|
videoMode?: VideoMode
|
||||||
ttydRunning?: boolean
|
ttydRunning?: boolean
|
||||||
@@ -71,6 +77,18 @@ const props = defineProps<{
|
|||||||
scaleMode?: VideoScaleMode
|
scaleMode?: VideoScaleMode
|
||||||
sourceSizeAvailable?: boolean
|
sourceSizeAvailable?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
const isSidebarLayout = computed(() => props.layout === 'sidebar')
|
||||||
|
const isFloatingLayout = computed(() => props.layout === 'floating')
|
||||||
|
const floatingCollapsed = ref(false)
|
||||||
|
const expandButtonRef = ref<InstanceType<typeof Button> | null>(null)
|
||||||
|
const collapseButtonRef = ref<InstanceType<typeof Button> | null>(null)
|
||||||
|
|
||||||
|
async function setFloatingCollapsed(collapsed: boolean) {
|
||||||
|
floatingCollapsed.value = collapsed
|
||||||
|
await nextTick()
|
||||||
|
const target = collapsed ? expandButtonRef.value : collapseButtonRef.value
|
||||||
|
target?.$el?.focus()
|
||||||
|
}
|
||||||
const showAtx = computed(() => props.showPower !== false)
|
const showAtx = computed(() => props.showPower !== false)
|
||||||
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
|
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
|
||||||
const showPasteText = computed(() => props.showPasteText !== false)
|
const showPasteText = computed(() => props.showPasteText !== false)
|
||||||
@@ -133,13 +151,18 @@ const openMobilePaste = () => openFromOverflow(() => {
|
|||||||
const barRef = ref<HTMLElement | null>(null)
|
const barRef = ref<HTMLElement | null>(null)
|
||||||
const measureRef = ref<HTMLElement | null>(null)
|
const measureRef = ref<HTMLElement | null>(null)
|
||||||
const barWidth = ref(0)
|
const barWidth = ref(0)
|
||||||
|
const barHeight = ref(0)
|
||||||
|
const coreWidth = ref(0)
|
||||||
|
const coreHeight = ref(0)
|
||||||
|
const fixedHeight = ref(0)
|
||||||
|
const actionHeight = ref(36)
|
||||||
|
const minimalDisplayControls = computed(() => isFloatingLayout.value && barWidth.value < 640)
|
||||||
const alwaysRightWidth = ref(152)
|
const alwaysRightWidth = ref(152)
|
||||||
let layoutResizeObserver: ResizeObserver | null = null
|
let layoutResizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
type CollapsibleItem =
|
type CollapsibleItem =
|
||||||
| 'video' | 'audio' | 'hid'
|
|
||||||
| 'msd' | 'atx' | 'paste'
|
| 'msd' | 'atx' | 'paste'
|
||||||
| 'stats' | 'terminal' | 'settings'
|
| 'stats' | 'terminal' | 'settings' | 'ai'
|
||||||
|
|
||||||
interface ItemSpec {
|
interface ItemSpec {
|
||||||
id: CollapsibleItem
|
id: CollapsibleItem
|
||||||
@@ -147,15 +170,13 @@ interface ItemSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ITEM_SPECS: ItemSpec[] = [
|
const ITEM_SPECS: ItemSpec[] = [
|
||||||
{ id: 'video', side: 'left' },
|
|
||||||
{ id: 'audio', side: 'left' },
|
|
||||||
{ id: 'hid', side: 'left' },
|
|
||||||
{ id: 'msd', side: 'left' },
|
{ id: 'msd', side: 'left' },
|
||||||
{ id: 'atx', side: 'left' },
|
{ id: 'atx', side: 'left' },
|
||||||
{ id: 'paste', side: 'left' },
|
{ id: 'paste', side: 'left' },
|
||||||
{ id: 'stats', side: 'right' },
|
{ id: 'stats', side: 'right' },
|
||||||
{ id: 'terminal', side: 'right' },
|
{ id: 'terminal', side: 'right' },
|
||||||
{ id: 'settings', side: 'right' },
|
{ id: 'settings', side: 'right' },
|
||||||
|
{ id: 'ai', side: 'right' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
||||||
@@ -167,7 +188,13 @@ const measureLayout = async () => {
|
|||||||
const measureContainer = measureRef.value
|
const measureContainer = measureRef.value
|
||||||
if (!bar || !measureContainer) return
|
if (!bar || !measureContainer) return
|
||||||
|
|
||||||
barWidth.value = bar.clientWidth
|
const style = window.getComputedStyle(bar)
|
||||||
|
barWidth.value = bar.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)
|
||||||
|
barHeight.value = bar.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
||||||
|
const core = Array.from(bar.querySelectorAll<HTMLElement>('[data-core-action]'))
|
||||||
|
coreWidth.value = core.reduce((sum, element) => sum + element.offsetWidth, 0)
|
||||||
|
coreHeight.value = core.reduce((sum, element) => sum + element.offsetHeight, 0)
|
||||||
|
actionHeight.value = core[0]?.offsetHeight || 36
|
||||||
|
|
||||||
const newWidths = new Map<CollapsibleItem, { icon: number; label: number }>()
|
const newWidths = new Map<CollapsibleItem, { icon: number; label: number }>()
|
||||||
for (const spec of ITEM_SPECS) {
|
for (const spec of ITEM_SPECS) {
|
||||||
@@ -190,7 +217,12 @@ const measureLayout = async () => {
|
|||||||
+ Number.parseFloat(style.marginLeft || '0')
|
+ Number.parseFloat(style.marginLeft || '0')
|
||||||
+ Number.parseFloat(style.marginRight || '0')
|
+ Number.parseFloat(style.marginRight || '0')
|
||||||
}, 0)
|
}, 0)
|
||||||
if (width > 0) alwaysRightWidth.value = Math.ceil(width)
|
alwaysRightWidth.value = Math.ceil(width)
|
||||||
|
fixedHeight.value = elements.reduce((sum, element) => {
|
||||||
|
const style = window.getComputedStyle(element)
|
||||||
|
return sum + element.getBoundingClientRect().height
|
||||||
|
+ parseFloat(style.marginTop || '0') + parseFloat(style.marginBottom || '0')
|
||||||
|
}, 0)
|
||||||
|
|
||||||
measurementReady.value = true
|
measurementReady.value = true
|
||||||
}
|
}
|
||||||
@@ -202,7 +234,7 @@ const observeLayout = async () => {
|
|||||||
void measureLayout()
|
void measureLayout()
|
||||||
})
|
})
|
||||||
if (barRef.value) layoutResizeObserver.observe(barRef.value)
|
if (barRef.value) layoutResizeObserver.observe(barRef.value)
|
||||||
barRef.value?.querySelectorAll('[data-fixed-action]').forEach((element) => {
|
barRef.value?.querySelectorAll('[data-fixed-action], [data-core-action]').forEach((element) => {
|
||||||
layoutResizeObserver?.observe(element)
|
layoutResizeObserver?.observe(element)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -220,7 +252,7 @@ watch(locale, () => {
|
|||||||
void measureLayout()
|
void measureLayout()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.showComputerUse, () => {
|
watch([() => props.layout, () => props.showComputerUse, floatingCollapsed, minimalDisplayControls], () => {
|
||||||
void observeLayout()
|
void observeLayout()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -241,52 +273,55 @@ watch(showPasteText, (visible) => {
|
|||||||
const OVERFLOW_BUTTON_BUDGET_PX = 36
|
const OVERFLOW_BUTTON_BUDGET_PX = 36
|
||||||
|
|
||||||
const collapsibleItems = computed(() => {
|
const collapsibleItems = computed(() => {
|
||||||
const items = ITEM_SPECS.slice(3).filter(item => {
|
const items = ITEM_SPECS.filter(item => {
|
||||||
|
if (isFloatingLayout.value && item.side === 'right') return false
|
||||||
if (item.id === 'msd' && !showMsd.value) return false
|
if (item.id === 'msd' && !showMsd.value) return false
|
||||||
if (item.id === 'atx' && !showAtx.value) return false
|
if (item.id === 'atx' && !showAtx.value) return false
|
||||||
if (item.id === 'paste' && !showPasteText.value) return false
|
if (item.id === 'paste' && !showPasteText.value) return false
|
||||||
if (item.id === 'stats' && !showStats.value) return false
|
if (item.id === 'stats' && !showStats.value) return false
|
||||||
if (item.id === 'terminal' && props.showTerminal === false) return false
|
if (item.id === 'terminal' && props.showTerminal === false) return false
|
||||||
|
if (item.id === 'ai' && props.showComputerUse === false) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const visibleSet = computed(() => {
|
const visibleSet = computed(() => {
|
||||||
if (!measurementReady.value) {
|
|
||||||
return new Map<CollapsibleItem, 'icon' | 'label'>()
|
|
||||||
}
|
|
||||||
|
|
||||||
const available = barWidth.value - alwaysRightWidth.value - OVERFLOW_BUTTON_BUDGET_PX
|
|
||||||
|
|
||||||
let used = 0
|
|
||||||
if (barRef.value) {
|
|
||||||
const leftContainer = barRef.value.querySelector('.left-buttons') as HTMLElement
|
|
||||||
if (leftContainer) {
|
|
||||||
const children = Array.from(leftContainer.children).slice(0, 3) as HTMLElement[]
|
|
||||||
used = children.reduce((sum, el) => sum + el.offsetWidth, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (used === 0) used = 330
|
|
||||||
|
|
||||||
const result = new Map<CollapsibleItem, 'icon' | 'label'>()
|
const result = new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||||
|
if (!measurementReady.value) return result
|
||||||
|
|
||||||
|
if (isSidebarLayout.value) {
|
||||||
|
// Reserve More and a gap between the two groups before assigning vertical slots.
|
||||||
|
let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16
|
||||||
|
const priority: CollapsibleItem[] = ['paste', 'settings', 'msd', 'atx', 'stats', 'terminal', 'ai']
|
||||||
|
for (const id of priority) {
|
||||||
|
if (!collapsibleItems.value.some(item => item.id === id)) continue
|
||||||
|
if (available < actionHeight.value) break
|
||||||
|
result.set(id, 'icon')
|
||||||
|
available -= actionHeight.value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = barWidth.value - alwaysRightWidth.value - Math.max(OVERFLOW_BUTTON_BUDGET_PX, actionHeight.value) - (isFloatingLayout.value ? 48 : 12)
|
||||||
|
let used = coreWidth.value
|
||||||
|
// Keep actions reachable before spending the remaining space on labels.
|
||||||
for (const item of collapsibleItems.value) {
|
for (const item of collapsibleItems.value) {
|
||||||
const widths = measuredWidths.value.get(item.id)
|
const widths = measuredWidths.value.get(item.id)
|
||||||
if (!widths) continue
|
if (!widths || used + widths.icon > available) continue
|
||||||
|
|
||||||
if (used + widths.icon <= available) {
|
|
||||||
if (used + widths.label <= available) {
|
|
||||||
result.set(item.id, 'label')
|
|
||||||
used += widths.label
|
|
||||||
} else {
|
|
||||||
result.set(item.id, 'icon')
|
result.set(item.id, 'icon')
|
||||||
used += widths.icon
|
used += widths.icon
|
||||||
}
|
}
|
||||||
|
if (isFloatingLayout.value && barWidth.value < 640) return result
|
||||||
|
for (const item of collapsibleItems.value) {
|
||||||
|
const widths = measuredWidths.value.get(item.id)
|
||||||
|
if (!widths || !result.has(item.id)) continue
|
||||||
|
const extra = widths.label - widths.icon
|
||||||
|
if (used + extra <= available) {
|
||||||
|
result.set(item.id, 'label')
|
||||||
|
used += extra
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -304,36 +339,85 @@ const hasRightOverflow = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full border-b bg-background">
|
<div
|
||||||
<div ref="barRef" class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
|
:class="[
|
||||||
|
'console-action-bar bg-background',
|
||||||
|
props.layout === 'floating' && 'console-action-bar--floating',
|
||||||
|
isFloatingLayout && floatingCollapsed && 'console-action-bar--collapsed',
|
||||||
|
props.layout === 'sidebar' && 'console-action-bar--sidebar',
|
||||||
|
(!props.layout || props.layout === 'current') && 'w-full border-b',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-if="isFloatingLayout && floatingCollapsed"
|
||||||
|
ref="expandButtonRef"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="console-action-bar__expand gap-1.5 rounded-xl text-xs"
|
||||||
|
:aria-label="t('actionbar.expandToolbar')"
|
||||||
|
:aria-expanded="false"
|
||||||
|
@click="setFloatingCollapsed(false)"
|
||||||
|
>
|
||||||
|
<ChevronDown class="size-4" />{{ t('actionbar.expandToolbar') }}
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
v-show="!isFloatingLayout || !floatingCollapsed"
|
||||||
|
ref="barRef"
|
||||||
|
class="console-action-bar__inner flex items-center"
|
||||||
|
:class="isSidebarLayout
|
||||||
|
? 'h-full flex-col px-1 py-2 sm:px-1.5'
|
||||||
|
: 'px-2 py-1 sm:px-4 sm:py-1.5'"
|
||||||
|
>
|
||||||
<!-- Left side buttons -->
|
<!-- Left side buttons -->
|
||||||
<ButtonGroup class="left-buttons flex-1 min-w-0 overflow-hidden">
|
<ButtonGroup
|
||||||
|
class="left-buttons min-w-0"
|
||||||
|
:class="isSidebarLayout
|
||||||
|
? 'flex-none flex-col overflow-visible'
|
||||||
|
: 'flex-1 overflow-hidden'"
|
||||||
|
:orientation="isSidebarLayout ? 'vertical' : 'horizontal'"
|
||||||
|
>
|
||||||
<!-- Video Config - Always visible -->
|
<!-- Video Config - Always visible -->
|
||||||
|
<div data-core-action class="flex shrink-0">
|
||||||
<VideoConfigPopover
|
<VideoConfigPopover
|
||||||
v-model:open="videoPopoverOpen"
|
v-model:open="videoPopoverOpen"
|
||||||
:video-mode="props.videoMode || 'mjpeg'"
|
:video-mode="props.videoMode || 'mjpeg'"
|
||||||
|
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||||
@update:video-mode="emit('update:videoMode', $event)"
|
@update:video-mode="emit('update:videoMode', $event)"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Audio Config - Always visible -->
|
<!-- Audio Config - Always visible -->
|
||||||
|
<div data-core-action class="flex shrink-0">
|
||||||
<AudioConfigPopover
|
<AudioConfigPopover
|
||||||
v-model:open="audioPopoverOpen"
|
v-model:open="audioPopoverOpen"
|
||||||
:microphone-enabled="showMic"
|
:microphone-enabled="showMic"
|
||||||
|
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- HID Config - Always visible -->
|
<!-- HID Config - Always visible -->
|
||||||
|
<div data-core-action class="flex shrink-0">
|
||||||
<HidConfigPopover
|
<HidConfigPopover
|
||||||
v-model:open="hidPopoverOpen"
|
v-model:open="hidPopoverOpen"
|
||||||
:mouse-mode="mouseMode"
|
:mouse-mode="mouseMode"
|
||||||
|
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||||
@update:mouse-mode="emit('toggleMouseMode')"
|
@update:mouse-mode="emit('toggleMouseMode')"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Virtual Media (MSD) - Adaptive -->
|
<!-- Virtual Media (MSD) - Adaptive -->
|
||||||
<div v-if="showMsd && isVisible('msd')">
|
<div v-if="showMsd && isVisible('msd')">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 gap-1.5 text-xs"
|
||||||
|
:aria-label="t('actionbar.virtualMedia')"
|
||||||
|
:title="t('actionbar.virtualMedia')"
|
||||||
|
@click="msdDialogOpen = true"
|
||||||
|
>
|
||||||
<HardDrive class="size-4" />
|
<HardDrive class="size-4" />
|
||||||
<span v-if="visibleSet.get('msd') === 'label'">{{ t('actionbar.virtualMedia') }}</span>
|
<span v-if="visibleSet.get('msd') === 'label'">{{ t('actionbar.virtualMedia') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -349,12 +433,22 @@ const hasRightOverflow = computed(() => {
|
|||||||
<div v-if="showAtx && isVisible('atx')">
|
<div v-if="showAtx && isVisible('atx')">
|
||||||
<Popover v-model:open="atxOpen">
|
<Popover v-model:open="atxOpen">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 gap-1.5 text-xs"
|
||||||
|
:aria-label="t('actionbar.power')"
|
||||||
|
:title="t('actionbar.power')"
|
||||||
|
>
|
||||||
<Power class="size-4" />
|
<Power class="size-4" />
|
||||||
<span v-if="visibleSet.get('atx') === 'label'">{{ t('actionbar.power') }}</span>
|
<span v-if="visibleSet.get('atx') === 'label'">{{ t('actionbar.power') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[min(280px,90vw)] p-0" align="start">
|
<PopoverContent
|
||||||
|
class="w-[min(280px,90vw)] p-0"
|
||||||
|
align="start"
|
||||||
|
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||||
|
>
|
||||||
<AtxPopover
|
<AtxPopover
|
||||||
@close="atxOpen = false"
|
@close="atxOpen = false"
|
||||||
@power-short="emit('powerShort')"
|
@power-short="emit('powerShort')"
|
||||||
@@ -370,12 +464,24 @@ const hasRightOverflow = computed(() => {
|
|||||||
<div v-if="showPasteText && isVisible('paste')">
|
<div v-if="showPasteText && isVisible('paste')">
|
||||||
<Popover v-model:open="pasteOpen">
|
<Popover v-model:open="pasteOpen">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 gap-1.5 text-xs"
|
||||||
|
:aria-label="t('actionbar.paste')"
|
||||||
|
:title="t('actionbar.paste')"
|
||||||
|
>
|
||||||
<ClipboardPaste class="size-4" />
|
<ClipboardPaste class="size-4" />
|
||||||
<span v-if="visibleSet.get('paste') === 'label'">{{ t('actionbar.paste') }}</span>
|
<span v-if="visibleSet.get('paste') === 'label'">{{ t('actionbar.paste') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[min(400px,90vw)] p-0" align="start">
|
<PopoverContent
|
||||||
|
:class="isSidebarLayout
|
||||||
|
? 'w-[min(400px,calc(100vw-4.5rem))] p-0'
|
||||||
|
: 'w-[min(400px,90vw)] p-0'"
|
||||||
|
align="start"
|
||||||
|
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||||
|
>
|
||||||
<PasteModal v-if="pasteOpen" @close="pasteOpen = false" />
|
<PasteModal v-if="pasteOpen" @close="pasteOpen = false" />
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -384,8 +490,16 @@ const hasRightOverflow = computed(() => {
|
|||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|
||||||
<!-- Right side buttons -->
|
<!-- Right side buttons -->
|
||||||
<ButtonGroup class="shrink-0 ml-1 sm:ml-2">
|
<ButtonGroup
|
||||||
|
class="shrink-0"
|
||||||
|
:class="isSidebarLayout
|
||||||
|
? 'mt-auto flex-none flex-col'
|
||||||
|
: 'ml-1 sm:ml-2'"
|
||||||
|
:orientation="isSidebarLayout ? 'vertical' : 'horizontal'"
|
||||||
|
>
|
||||||
<VideoDisplayControls
|
<VideoDisplayControls
|
||||||
|
:minimal="minimalDisplayControls"
|
||||||
|
:text-only-scale="isSidebarLayout"
|
||||||
:scale-mode="props.scaleMode"
|
:scale-mode="props.scaleMode"
|
||||||
:source-size-available="props.sourceSizeAvailable"
|
:source-size-available="props.sourceSizeAvailable"
|
||||||
@toggle-fullscreen="emit('toggleFullscreen')"
|
@toggle-fullscreen="emit('toggleFullscreen')"
|
||||||
@@ -398,7 +512,7 @@ const hasRightOverflow = computed(() => {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.stats')" @click="emit('toggleStats')">
|
||||||
<BarChart3 class="size-4" />
|
<BarChart3 class="size-4" />
|
||||||
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
|
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -420,6 +534,7 @@ const hasRightOverflow = computed(() => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 gap-1.5 text-xs"
|
class="h-8 gap-1.5 text-xs"
|
||||||
:disabled="!props.ttydRunning"
|
:disabled="!props.ttydRunning"
|
||||||
|
:aria-label="t('actionbar.webTerminal')"
|
||||||
@click="emit('openTerminal')"
|
@click="emit('openTerminal')"
|
||||||
>
|
>
|
||||||
<Terminal class="size-4" />
|
<Terminal class="size-4" />
|
||||||
@@ -434,18 +549,18 @@ const hasRightOverflow = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Computer Use - Optional -->
|
<!-- Computer Use - Optional -->
|
||||||
<TooltipProvider v-if="props.showComputerUse !== false">
|
<TooltipProvider v-if="isVisible('ai')">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
data-fixed-action
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
class="h-8 gap-1.5 text-xs"
|
||||||
|
:aria-label="t('computerUse.title')"
|
||||||
@click="emit('openComputerUse')"
|
@click="emit('openComputerUse')"
|
||||||
>
|
>
|
||||||
<Bot class="size-3.5 sm:size-4" />
|
<Bot class="size-3.5 sm:size-4" />
|
||||||
<span class="hidden xl:inline">AI</span>
|
<span v-if="visibleSet.get('ai') === 'label'">AI</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -459,7 +574,7 @@ const hasRightOverflow = computed(() => {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.settings')" @click="router.push('/settings')">
|
||||||
<Settings class="size-4" />
|
<Settings class="size-4" />
|
||||||
<span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span>
|
<span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -472,13 +587,25 @@ const hasRightOverflow = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overflow Menu - Only show if there are overflowed items -->
|
<!-- Overflow Menu - Only show if there are overflowed items -->
|
||||||
<DropdownMenu v-if="hasOverflow" v-model:open="overflowMenuOpen">
|
<DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen">
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="size-8 p-0">
|
<Button variant="ghost" size="sm" class="size-8 p-0" :aria-label="t('actionbar.more')" :title="t('actionbar.more')">
|
||||||
<MoreHorizontal class="size-3.5 sm:size-4" />
|
<MoreHorizontal class="size-3.5 sm:size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" class="w-48">
|
<DropdownMenuContent align="end" :side="isSidebarLayout ? 'right' : 'bottom'" class="w-56 max-h-[70dvh] overflow-y-auto">
|
||||||
|
<template v-if="minimalDisplayControls">
|
||||||
|
<DropdownMenuItem @click="openFromOverflow(() => emit('toggleVirtualKeyboard'))">
|
||||||
|
<Keyboard class="size-4 mr-2" />{{ t('actionbar.keyboard') }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
:disabled="!props.sourceSizeAvailable"
|
||||||
|
@click="emit('update:scaleMode', props.scaleMode === 'actual' ? 'fit' : 'actual')"
|
||||||
|
>
|
||||||
|
<Scaling class="size-4 mr-2" />{{ t(props.scaleMode === 'actual' ? 'actionbar.fitSizeAria' : 'actionbar.actualSizeAria') }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</template>
|
||||||
<!-- MSD -->
|
<!-- MSD -->
|
||||||
<DropdownMenuItem v-if="showMsd && !isVisible('msd')" @click="openFromOverflow(() => msdDialogOpen = true)">
|
<DropdownMenuItem v-if="showMsd && !isVisible('msd')" @click="openFromOverflow(() => msdDialogOpen = true)">
|
||||||
<HardDrive class="size-4 mr-2" />
|
<HardDrive class="size-4 mr-2" />
|
||||||
@@ -500,14 +627,14 @@ const hasRightOverflow = computed(() => {
|
|||||||
<DropdownMenuSeparator v-if="hasLeftOverflow && hasRightOverflow" />
|
<DropdownMenuSeparator v-if="hasLeftOverflow && hasRightOverflow" />
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<DropdownMenuItem v-if="showStats && !isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
<DropdownMenuItem v-if="!isFloatingLayout && showStats && !isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||||
<BarChart3 class="size-4 mr-2" />
|
<BarChart3 class="size-4 mr-2" />
|
||||||
{{ t('actionbar.stats') }}
|
{{ t('actionbar.stats') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<!-- Web Terminal -->
|
<!-- Web Terminal -->
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
v-if="props.showTerminal !== false && !isVisible('terminal')"
|
v-if="!isFloatingLayout && props.showTerminal !== false && !isVisible('terminal')"
|
||||||
:disabled="!props.ttydRunning"
|
:disabled="!props.ttydRunning"
|
||||||
@click="openFromOverflow(() => emit('openTerminal'))"
|
@click="openFromOverflow(() => emit('openTerminal'))"
|
||||||
>
|
>
|
||||||
@@ -515,13 +642,30 @@ const hasRightOverflow = computed(() => {
|
|||||||
{{ t('actionbar.webTerminal') }}
|
{{ t('actionbar.webTerminal') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem v-if="!isFloatingLayout && props.showComputerUse !== false && !isVisible('ai')" @click="openFromOverflow(() => emit('openComputerUse'))">
|
||||||
|
<Bot class="size-4 mr-2" />{{ t('computerUse.title') }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<DropdownMenuItem v-if="!isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
|
<DropdownMenuItem v-if="!isFloatingLayout && !isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
|
||||||
<Settings class="size-4 mr-2" />
|
<Settings class="size-4 mr-2" />
|
||||||
{{ t('actionbar.settings') }}
|
{{ t('actionbar.settings') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
v-if="isFloatingLayout"
|
||||||
|
ref="collapseButtonRef"
|
||||||
|
data-fixed-action
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
:aria-label="t('actionbar.collapseToolbar')"
|
||||||
|
:title="t('actionbar.collapseToolbar')"
|
||||||
|
:aria-expanded="true"
|
||||||
|
@click="setFloatingCollapsed(true)"
|
||||||
|
>
|
||||||
|
<ChevronUp class="size-4" />
|
||||||
|
</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -588,13 +732,131 @@ const hasRightOverflow = computed(() => {
|
|||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<Button data-measure="settings-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" /></Button>
|
<Button data-measure="settings-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" /></Button>
|
||||||
<Button data-measure="settings-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" />{{ t('actionbar.settings') }}</Button>
|
<Button data-measure="settings-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" />{{ t('actionbar.settings') }}</Button>
|
||||||
<!-- Always-visible items (for measuring their actual width) -->
|
<Button data-measure="ai-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" /></Button>
|
||||||
<Button data-measure="video-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
<Button data-measure="ai-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" />AI</Button>
|
||||||
<Button data-measure="video-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
|
||||||
<Button data-measure="audio-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
|
||||||
<Button data-measure="audio-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
|
||||||
<Button data-measure="hid-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
|
||||||
<Button data-measure="hid-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.console-action-bar--floating {
|
||||||
|
position: relative;
|
||||||
|
z-index: 40;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 64rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: color-mix(in srgb, var(--background) 96%, transparent);
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--floating .console-action-bar__inner {
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--sidebar {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 40;
|
||||||
|
top: 2.5rem;
|
||||||
|
bottom: 1.75rem;
|
||||||
|
left: 0;
|
||||||
|
width: 3.5rem;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--background) 94%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--sidebar :deep([data-slot='button-group']) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--sidebar :deep(button) {
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--sidebar :deep(button > span) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--sidebar :deep([data-fixed-action][aria-hidden='true']) {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1px;
|
||||||
|
margin: 0.35rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--sidebar .console-action-bar__inner {
|
||||||
|
overflow-y: auto;
|
||||||
|
gap: 1rem;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--floating .console-action-bar__inner {
|
||||||
|
padding: 4px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--floating :deep([data-slot='button-group']) {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||||
|
min-width: 36px;
|
||||||
|
height: 38px;
|
||||||
|
padding-inline: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--floating .console-action-bar__inner :deep(button[aria-expanded='true']) {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.console-action-bar--floating .console-action-bar__inner {
|
||||||
|
padding-inline: 4px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.console-action-bar--floating :deep([data-slot='button-group']) {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||||
|
width: 36px;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-action-bar--floating.console-action-bar--collapsed {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.console-action-bar__expand {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.console-action-bar--sidebar :deep(button) {
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||||
|
min-width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.console-action-bar--floating .console-action-bar__inner {
|
||||||
|
padding-inline: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.console-action-bar--sidebar {
|
||||||
|
top: 3.5rem;
|
||||||
|
width: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onUnmounted, ref, watch } from 'vue'
|
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { Loader2, RefreshCw, Volume2 } from 'lucide-vue-next'
|
import { Loader2, RefreshCw, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
|
|
||||||
import { audioApi, configApi } from '@/api'
|
import { audioApi, configApi } from '@/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -29,6 +30,7 @@ interface AudioDevice {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
microphoneEnabled?: boolean
|
microphoneEnabled?: boolean
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -40,6 +42,7 @@ const configStore = useConfigStore()
|
|||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
const unifiedAudio = getUnifiedAudio()
|
const unifiedAudio = getUnifiedAudio()
|
||||||
const microphone = getMicrophone()
|
const microphone = getMicrophone()
|
||||||
|
const playbackMuted = computed(() => unifiedAudio.muted.value || unifiedAudio.volume.value === 0)
|
||||||
|
|
||||||
const localVolume = ref([unifiedAudio.volume.value * 100])
|
const localVolume = ref([unifiedAudio.volume.value * 100])
|
||||||
const devices = ref<AudioDevice[]>([])
|
const devices = ref<AudioDevice[]>([])
|
||||||
@@ -150,13 +153,21 @@ onUnmounted(() => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
||||||
|
:aria-label="playbackMuted ? `${t('actionbar.audioConfig')} · ${t('actionbar.muted')}` : t('actionbar.audioConfig')"
|
||||||
|
:title="playbackMuted ? `${t('actionbar.audioConfig')} · ${t('actionbar.muted')}` : t('actionbar.audioConfig')"
|
||||||
>
|
>
|
||||||
<Volume2 class="size-3.5 sm:size-4" />
|
<VolumeX v-if="playbackMuted" class="size-3.5 sm:size-4" />
|
||||||
|
<Volume2 v-else class="size-3.5 sm:size-4" />
|
||||||
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
|
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
<PopoverContent
|
||||||
|
@open-auto-focus="focusConsolePanel"
|
||||||
|
class="console-config-panel w-[min(320px,92vw)] p-3"
|
||||||
|
align="start"
|
||||||
|
:side="props.side ?? 'bottom'"
|
||||||
|
>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +43,7 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const consoleAppearance = useConsoleAppearance()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:open', value: boolean): void
|
(e: 'update:open', value: boolean): void
|
||||||
@@ -215,6 +217,8 @@ onMounted(loadConfig)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
|
data-slot="computer-use-panel"
|
||||||
|
:data-console-layout="consoleAppearance"
|
||||||
v-show="open"
|
v-show="open"
|
||||||
class="absolute inset-y-0 right-0 z-30 h-full min-h-0 w-full border-l bg-background shadow-xl sm:w-[420px] md:relative md:z-auto xl:w-[460px]"
|
class="absolute inset-y-0 right-0 z-30 h-full min-h-0 w-full border-l bg-background shadow-xl sm:w-[420px] md:relative md:z-auto xl:w-[460px]"
|
||||||
>
|
>
|
||||||
|
|||||||
56
web/src/components/ConsoleHeaderActions.vue
Normal file
56
web/src/components/ConsoleHeaderActions.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { BarChart3, Bot, Settings, Terminal, MoreHorizontal } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
showStats: boolean
|
||||||
|
showTerminal: boolean
|
||||||
|
terminalRunning: boolean
|
||||||
|
showComputerUse: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'openStats'): void
|
||||||
|
(e: 'openTerminal'): void
|
||||||
|
(e: 'openComputerUse'): void
|
||||||
|
}>()
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
function openFromMenu(action: () => void) {
|
||||||
|
menuOpen.value = false
|
||||||
|
window.setTimeout(action, 50)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="console-header-actions flex shrink-0 items-center gap-1">
|
||||||
|
<div class="hidden items-center gap-1 md:flex">
|
||||||
|
<Button v-if="showStats" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.stats')" :title="t('actionbar.stats')" @click="emit('openStats')">
|
||||||
|
<BarChart3 class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.stats') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button v-if="showTerminal" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.webTerminal')" :title="t('actionbar.webTerminal')" :disabled="!terminalRunning" @click="emit('openTerminal')">
|
||||||
|
<Terminal class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.webTerminal') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button v-if="showComputerUse" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('computerUse.title')" :title="t('computerUse.title')" @click="emit('openComputerUse')">
|
||||||
|
<Bot class="size-4" /><span class="hidden lg:inline">AI</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu v-if="showStats || showTerminal || showComputerUse" v-model:open="menuOpen">
|
||||||
|
<DropdownMenuTrigger as-child class="md:hidden">
|
||||||
|
<Button variant="ghost" size="icon-sm" :aria-label="t('actionbar.more')"><MoreHorizontal class="size-4" /></Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-48">
|
||||||
|
<DropdownMenuItem v-if="showStats" @select="openFromMenu(() => emit('openStats'))"><BarChart3 class="size-4" />{{ t('actionbar.stats') }}</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem v-if="showTerminal" :disabled="!terminalRunning" @select="openFromMenu(() => emit('openTerminal'))"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem v-if="showComputerUse" @select="openFromMenu(() => emit('openComputerUse'))"><Bot class="size-4" />{{ t('computerUse.title') }}</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button variant="ghost" size="sm" class="gap-1.5 rounded-lg px-2 text-xs" :aria-label="t('actionbar.settings')" :title="t('actionbar.settings')" @click="router.push('/settings')">
|
||||||
|
<Settings class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.settings') }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
87
web/src/components/ConsoleStatusSummary.vue
Normal file
87
web/src/components/ConsoleStatusSummary.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ChevronDown, AlertCircle, Info } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import type { StatusDetail, ConnectionStatus } from '@/components/StatusCard.vue'
|
||||||
|
|
||||||
|
export interface ConsoleStatusItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: ConnectionStatus
|
||||||
|
quickInfo: string
|
||||||
|
details: StatusDetail[]
|
||||||
|
errorMessage?: string
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: ConsoleStatusItem[]
|
||||||
|
showVideoInfo?: boolean
|
||||||
|
}>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const issues = computed(() => props.items.filter(item =>
|
||||||
|
['error', 'no_signal', 'busy'].includes(item.status) || (item.required && item.status !== 'connected'),
|
||||||
|
))
|
||||||
|
const summary = computed(() => issues.value.length
|
||||||
|
? issues.value.map(item => `${item.title}: ${t(`status.${item.status}`)}`).join(' · ')
|
||||||
|
: t('status.connected'))
|
||||||
|
const videoInfo = computed(() => props.items.find(item => item.id === 'video')?.quickInfo)
|
||||||
|
const hasError = computed(() => issues.value.some(item => item.status === 'error'))
|
||||||
|
|
||||||
|
function dotClass(status: ConsoleStatusItem['status']) {
|
||||||
|
return {
|
||||||
|
connected: 'bg-status-active',
|
||||||
|
connecting: 'bg-warning animate-pulse',
|
||||||
|
disconnected: 'bg-muted-foreground',
|
||||||
|
error: 'bg-destructive',
|
||||||
|
no_signal: 'bg-warning',
|
||||||
|
busy: 'bg-warning',
|
||||||
|
}[status]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="min-w-0 shrink gap-1.5 px-2 text-xs"
|
||||||
|
:class="hasError ? 'text-destructive' : issues.length ? 'text-warning' : ''"
|
||||||
|
:aria-label="`${t('statusCard.connectionDetails')}: ${summary}`"
|
||||||
|
:title="summary"
|
||||||
|
>
|
||||||
|
<AlertCircle v-if="hasError" class="size-3.5 shrink-0" />
|
||||||
|
<Info v-else-if="issues.length" class="size-3.5 shrink-0" />
|
||||||
|
<span v-else class="size-2 shrink-0 rounded-full bg-status-active" />
|
||||||
|
<span class="truncate" role="status">{{ summary }}</span>
|
||||||
|
<span v-if="showVideoInfo && !issues.length" class="hidden text-muted-foreground md:inline">{{ videoInfo }}</span>
|
||||||
|
<ChevronDown class="size-3 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" class="w-[min(360px,calc(100vw-1rem))] max-h-[70dvh] overflow-y-auto">
|
||||||
|
<h2 class="mb-3 text-sm font-semibold">{{ t('statusCard.connectionDetails') }}</h2>
|
||||||
|
<div class="divide-y">
|
||||||
|
<section v-for="item in items" :key="item.id" class="space-y-2 py-3 first:pt-0 last:pb-0">
|
||||||
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span class="font-medium">{{ item.title }}</span>
|
||||||
|
<span class="flex items-center gap-1.5 text-xs">
|
||||||
|
<span class="size-1.5 rounded-full" :class="dotClass(item.status)" />
|
||||||
|
{{ t(`status.${item.status}`) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="item.quickInfo" class="text-xs text-muted-foreground">{{ item.quickInfo }}</p>
|
||||||
|
<p v-if="item.errorMessage" class="break-words text-xs" :class="item.status === 'error' ? 'text-destructive' : 'text-muted-foreground'">{{ item.errorMessage }}</p>
|
||||||
|
<dl class="space-y-1 text-xs">
|
||||||
|
<div v-for="(detail, index) in item.details" :key="index" class="flex justify-between gap-4">
|
||||||
|
<dt class="shrink-0 text-muted-foreground">{{ detail.label }}</dt>
|
||||||
|
<dd class="min-w-0 break-words text-right" :class="detail.status === 'error' ? 'text-destructive' : detail.status === 'warning' ? 'text-warning' : ''">{{ detail.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
@@ -29,6 +30,7 @@ import type { HidConfigUpdate } from '@/types/generated'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
mouseMode?: 'absolute' | 'relative'
|
mouseMode?: 'absolute' | 'relative'
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -231,13 +233,24 @@ watch(() => props.open, (isOpen) => {
|
|||||||
<template>
|
<template>
|
||||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||||
|
:aria-label="buttonText"
|
||||||
|
:title="buttonText"
|
||||||
|
>
|
||||||
<MousePointer v-if="mouseMode === 'absolute'" class="size-3.5 sm:size-4" />
|
<MousePointer v-if="mouseMode === 'absolute'" class="size-3.5 sm:size-4" />
|
||||||
<Move v-else class="size-3.5 sm:size-4" />
|
<Move v-else class="size-3.5 sm:size-4" />
|
||||||
<span class="hidden sm:inline">{{ buttonText }}</span>
|
<span class="hidden sm:inline">{{ buttonText }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
<PopoverContent
|
||||||
|
@open-auto-focus="focusConsolePanel"
|
||||||
|
class="console-config-panel w-[min(320px,92vw)] p-3"
|
||||||
|
align="start"
|
||||||
|
:side="props.side ?? 'bottom'"
|
||||||
|
>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const props = defineProps<{
|
|||||||
mousePosition?: { x: number; y: number }
|
mousePosition?: { x: number; y: number }
|
||||||
debugMode?: boolean
|
debugMode?: boolean
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
captured?: boolean
|
||||||
|
minimal?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -45,22 +47,23 @@ const keysDisplay = computed(() => {
|
|||||||
<div class="w-full border-t bg-background">
|
<div class="w-full border-t bg-background">
|
||||||
<!-- Compact mode (explicit prop or auto on small screens via sm:hidden) -->
|
<!-- Compact mode (explicit prop or auto on small screens via sm:hidden) -->
|
||||||
<div :class="compact ? '' : 'sm:hidden'">
|
<div :class="compact ? '' : 'sm:hidden'">
|
||||||
<div class="flex items-center justify-between text-xs px-2 py-0.5">
|
<div class="flex items-center justify-between gap-3 text-xs px-2 h-7">
|
||||||
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
|
<div v-if="keyboardLedEnabled && !minimal" class="flex items-center gap-1">
|
||||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
<Badge variant="outline" class="h-5 gap-1 px-1 text-xs text-foreground">
|
||||||
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-warning'" />C
|
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Caps
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
<Badge variant="outline" class="h-5 gap-1 px-1 text-xs text-foreground">
|
||||||
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-warning'" />N
|
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Num
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
<Badge variant="outline" class="h-5 gap-1 px-1 text-xs text-foreground">
|
||||||
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-warning'" />S
|
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Scroll
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-[10px] text-muted-foreground/60">
|
<div v-else-if="!minimal" class="text-xs text-muted-foreground">
|
||||||
{{ t('infobar.keyboardLedUnavailable') }}
|
{{ t('infobar.keyboardLedUnavailable') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[200px]">
|
<span v-if="captured && minimal" class="min-w-0 truncate text-xs text-muted-foreground" :title="t('infobar.pointerCaptured')">{{ t('infobar.pointerCaptured') }}</span>
|
||||||
|
<div v-if="keysDisplay" class="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||||
{{ keysDisplay }}
|
{{ keysDisplay }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,15 +90,15 @@ const keysDisplay = computed(() => {
|
|||||||
<Separator orientation="vertical" class="h-5" />
|
<Separator orientation="vertical" class="h-5" />
|
||||||
<template v-if="keyboardLedEnabled">
|
<template v-if="keyboardLedEnabled">
|
||||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||||
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-warning'" />
|
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />
|
||||||
{{ t('infobar.caps') }}
|
{{ t('infobar.caps') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||||
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-warning'" />
|
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />
|
||||||
{{ t('infobar.num') }}
|
{{ t('infobar.num') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||||
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-warning'" />
|
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />
|
||||||
{{ t('infobar.scroll') }}
|
{{ t('infobar.scroll') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useSystemStore } from '@/stores/system'
|
import { useSystemStore } from '@/stores/system'
|
||||||
import { msdApi, type MsdImage, type DriveFile, type MountedMedia, type DiskMode } from '@/api'
|
import { msdApi, type MsdImage, type DriveFile, type DriveInfo, type MountedMedia, type DiskMode } from '@/api'
|
||||||
import { ApiError, localizeMsdErrorCode } from '@/api/request'
|
import { ApiError, localizeMsdErrorCode } from '@/api/request'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
import {
|
import {
|
||||||
@@ -92,13 +92,12 @@ const showMountOptionsDialog = ref(false)
|
|||||||
const driveFiles = ref<DriveFile[]>([])
|
const driveFiles = ref<DriveFile[]>([])
|
||||||
const currentPath = ref('/')
|
const currentPath = ref('/')
|
||||||
const loadingDrive = ref(false)
|
const loadingDrive = ref(false)
|
||||||
const driveInfo = ref<{ size: number; used: number; free: number; initialized: boolean } | null>(null)
|
const driveInfo = ref<DriveInfo | null>(null)
|
||||||
const driveInitialized = ref(false)
|
const driveInitialized = ref(false)
|
||||||
const uploadingFile = ref(false)
|
const uploadingFile = ref(false)
|
||||||
const fileUploadProgress = ref(0)
|
const fileUploadProgress = ref(0)
|
||||||
const driveError = ref<string | null>(null) // filesystem error (e.g. unsupported format)
|
const driveError = ref<string | null>(null) // filesystem error (e.g. unsupported format)
|
||||||
const driveErrorCode = ref<string | null>(null)
|
const driveErrorCode = ref<string | null>(null)
|
||||||
const driveFilesystemUnsupported = computed(() => driveErrorCode.value === 'MSD_DRIVE_FILESYSTEM_UNSUPPORTED')
|
|
||||||
|
|
||||||
const showDeleteDialog = ref(false)
|
const showDeleteDialog = ref(false)
|
||||||
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
|
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
|
||||||
@@ -175,6 +174,14 @@ const mediaSlotsFull = computed(() => mountedCount.value >= slotCapacity.value)
|
|||||||
const driveMedia = computed(() => mountedMedia.value.find(media => media.kind === 'drive') ?? null)
|
const driveMedia = computed(() => mountedMedia.value.find(media => media.kind === 'drive') ?? null)
|
||||||
// Drive is currently mounted on the target machine via USB — file ops are blocked
|
// Drive is currently mounted on the target machine via USB — file ops are blocked
|
||||||
const driveConnectedToTarget = computed(() => !!driveMedia.value)
|
const driveConnectedToTarget = computed(() => !!driveMedia.value)
|
||||||
|
const driveFileAccess = computed(() => {
|
||||||
|
if (driveConnectedToTarget.value) return 'blocked_while_connected'
|
||||||
|
return driveInfo.value?.file_access ?? 'unknown'
|
||||||
|
})
|
||||||
|
const driveFilesAvailable = computed(() => driveFileAccess.value === 'available')
|
||||||
|
const driveFilesystemUnsupported = computed(() =>
|
||||||
|
driveFileAccess.value === 'unsupported' || driveErrorCode.value === 'MSD_DRIVE_FILESYSTEM_UNSUPPORTED',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -253,7 +260,7 @@ async function loadData() {
|
|||||||
await refreshMsdState()
|
await refreshMsdState()
|
||||||
await loadImages()
|
await loadImages()
|
||||||
await loadDriveInfo()
|
await loadDriveInfo()
|
||||||
if (driveInitialized.value) {
|
if (driveFilesAvailable.value) {
|
||||||
await loadDriveFiles()
|
await loadDriveFiles()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,7 +376,6 @@ async function unmountMedia(media: MountedMedia) {
|
|||||||
try {
|
try {
|
||||||
if (media.kind === 'drive') {
|
if (media.kind === 'drive') {
|
||||||
await msdApi.unmountDrive()
|
await msdApi.unmountDrive()
|
||||||
await refreshDriveBrowser()
|
|
||||||
} else {
|
} else {
|
||||||
await msdApi.unmountImage(media.id)
|
await msdApi.unmountImage(media.id)
|
||||||
}
|
}
|
||||||
@@ -444,6 +450,7 @@ async function loadDriveInfo() {
|
|||||||
try {
|
try {
|
||||||
driveInfo.value = await msdApi.driveInfo()
|
driveInfo.value = await msdApi.driveInfo()
|
||||||
driveInitialized.value = true
|
driveInitialized.value = true
|
||||||
|
driveFiles.value = driveFilesAvailable.value ? driveFiles.value : []
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
if (e.code === 'MSD_DRIVE_NOT_INITIALIZED' || e.status === 404) {
|
if (e.code === 'MSD_DRIVE_NOT_INITIALIZED' || e.status === 404) {
|
||||||
@@ -486,7 +493,7 @@ async function createDrive() {
|
|||||||
const sizeMb = finalDriveSize.value
|
const sizeMb = finalDriveSize.value
|
||||||
await msdApi.initDrive(sizeMb)
|
await msdApi.initDrive(sizeMb)
|
||||||
await loadDriveInfo()
|
await loadDriveInfo()
|
||||||
await loadDriveFiles()
|
if (driveFilesAvailable.value) await loadDriveFiles()
|
||||||
await refreshDiskSpace()
|
await refreshDiskSpace()
|
||||||
showDriveInitDialog.value = false
|
showDriveInitDialog.value = false
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -516,7 +523,7 @@ async function deleteDrive() {
|
|||||||
async function loadDriveFiles() {
|
async function loadDriveFiles() {
|
||||||
// Do not read image file while it is mounted on the target machine:
|
// Do not read image file while it is mounted on the target machine:
|
||||||
// concurrent access causes filesystem corruption (Windows error 0x80070570)
|
// concurrent access causes filesystem corruption (Windows error 0x80070570)
|
||||||
if (driveConnectedToTarget.value) {
|
if (driveConnectedToTarget.value || !driveFilesAvailable.value) {
|
||||||
driveFiles.value = []
|
driveFiles.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -538,7 +545,7 @@ async function loadDriveFiles() {
|
|||||||
|
|
||||||
async function refreshDriveBrowser() {
|
async function refreshDriveBrowser() {
|
||||||
await loadDriveInfo()
|
await loadDriveInfo()
|
||||||
if (driveInitialized.value) {
|
if (driveFilesAvailable.value) {
|
||||||
await loadDriveFiles()
|
await loadDriveFiles()
|
||||||
} else {
|
} else {
|
||||||
driveFiles.value = []
|
driveFiles.value = []
|
||||||
@@ -965,20 +972,27 @@ onUnmounted(() => {
|
|||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: driveError
|
: driveError
|
||||||
? 'border-destructive/40 bg-destructive/5'
|
? 'border-destructive/40 bg-destructive/5'
|
||||||
|
: driveFilesystemUnsupported
|
||||||
|
? 'border-warning/40 bg-warning/5'
|
||||||
: 'bg-muted/50'"
|
: 'bg-muted/50'"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<HardDrive class="size-4 text-muted-foreground" />
|
<HardDrive class="size-4 text-muted-foreground" />
|
||||||
<span class="text-sm font-medium">{{ t('msd.drive') }}</span>
|
<span class="text-sm font-medium">{{ t('msd.drive') }}</span>
|
||||||
<!-- Show size badge only when info is available -->
|
|
||||||
<Badge v-if="driveInfo" variant="outline" class="text-xs">
|
<Badge v-if="driveInfo" variant="outline" class="text-xs">
|
||||||
{{ Math.round((driveInfo?.size || 0) / 1024 / 1024) }} MB
|
{{ Math.round((driveInfo?.size || 0) / 1024 / 1024) }} MB
|
||||||
</Badge>
|
</Badge>
|
||||||
<!-- Show unreadable badge when format is wrong -->
|
<Badge
|
||||||
|
v-if="driveFilesystemUnsupported"
|
||||||
|
variant="outline"
|
||||||
|
class="border-warning/50 text-xs text-warning"
|
||||||
|
>
|
||||||
|
{{ t('msd.driveUnreadable') }}
|
||||||
|
</Badge>
|
||||||
<template v-else-if="driveError">
|
<template v-else-if="driveError">
|
||||||
<Badge variant="outline" class="text-xs border-destructive/50 text-destructive">
|
<Badge variant="outline" class="text-xs border-destructive/50 text-destructive">
|
||||||
{{ driveFilesystemUnsupported ? t('msd.driveUnreadable') : t('common.error') }}
|
{{ t('common.error') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
@@ -993,19 +1007,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<!-- When drive format is unrecognized, only offer re-initialization -->
|
<template v-if="driveConnectedToTarget">
|
||||||
<template v-if="driveFilesystemUnsupported && !msdConnected">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="h-8 text-xs"
|
|
||||||
:disabled="operationInProgress"
|
|
||||||
@click="initializeDrive"
|
|
||||||
>
|
|
||||||
{{ t('msd.reinitializeDrive') }}
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="driveConnectedToTarget">
|
|
||||||
<Badge variant="default" class="h-8 px-2 text-xs">
|
<Badge variant="default" class="h-8 px-2 text-xs">
|
||||||
<span class="relative flex size-1.5 mr-1.5">
|
<span class="relative flex size-1.5 mr-1.5">
|
||||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary-foreground opacity-75"></span>
|
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary-foreground opacity-75"></span>
|
||||||
@@ -1030,13 +1032,23 @@ onUnmounted(() => {
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 text-xs"
|
class="h-8 text-xs"
|
||||||
:disabled="operationInProgress || mediaSlotsFull || !!driveError"
|
:disabled="operationInProgress || mediaSlotsFull || !driveInfo || !!driveError"
|
||||||
@click="connectDrive"
|
@click="connectDrive"
|
||||||
>
|
>
|
||||||
<Link v-if="!connecting" class="size-3.5 mr-1" />
|
<Link v-if="!connecting" class="size-3.5 mr-1" />
|
||||||
<span v-if="connecting">{{ t('common.connecting') }}...</span>
|
<span v-if="connecting">{{ t('common.connecting') }}...</span>
|
||||||
<span v-else>{{ t('msd.connect') }}</span>
|
<span v-else>{{ t('msd.connect') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="driveFilesystemUnsupported"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 text-xs"
|
||||||
|
:disabled="operationInProgress"
|
||||||
|
@click="initializeDrive"
|
||||||
|
>
|
||||||
|
{{ t('msd.reinitializeDrive') }}
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1049,19 +1061,29 @@ onUnmounted(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Storage usage bar — hidden when format is unrecognized -->
|
<div
|
||||||
<div v-if="driveInfo" class="space-y-1.5">
|
v-if="driveFilesAvailable && driveInfo?.used !== null && driveInfo?.free !== null"
|
||||||
|
class="space-y-1.5"
|
||||||
|
>
|
||||||
<Progress
|
<Progress
|
||||||
:model-value="driveInfo.size > 0 ? (driveInfo.used / driveInfo.size) * 100 : 0"
|
:model-value="driveInfo && driveInfo.size > 0 ? ((driveInfo.used ?? 0) / driveInfo.size) * 100 : 0"
|
||||||
class="h-2"
|
class="h-2"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>{{ formatBytes(driveInfo?.used || 0) }} {{ t('msd.usedSpace') }}</span>
|
<span>{{ formatBytes(driveInfo?.used ?? 0) }} {{ t('msd.usedSpace') }}</span>
|
||||||
<span>{{ formatBytes(driveInfo?.free || 0) }} {{ t('msd.freeSpace') }}</span>
|
<span>{{ formatBytes(driveInfo?.free ?? 0) }} {{ t('msd.freeSpace') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="driveFilesystemUnsupported"
|
||||||
|
class="flex shrink-0 items-start gap-2 rounded-md border border-warning/40 bg-warning/5 p-3"
|
||||||
|
>
|
||||||
|
<Info class="mt-0.5 size-4 shrink-0 text-warning" />
|
||||||
|
<p class="text-sm text-muted-foreground">{{ t('msd.driveFilesystemUnsupportedHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="driveError"
|
v-if="driveError"
|
||||||
class="flex shrink-0 items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-3"
|
class="flex shrink-0 items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-3"
|
||||||
@@ -1075,7 +1097,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
|
|
||||||
<!-- File Browser -->
|
<!-- File Browser -->
|
||||||
<div class="flex-1 min-h-0 flex flex-col space-y-2">
|
<div v-if="driveFilesAvailable" class="flex-1 min-h-0 flex flex-col space-y-2">
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="shrink-0 flex items-center justify-between gap-2">
|
<div class="shrink-0 flex items-center justify-between gap-2">
|
||||||
@@ -1163,21 +1185,13 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- File List -->
|
<!-- File List -->
|
||||||
<Skeleton v-if="loadingDrive" class="h-24 w-full" />
|
<Skeleton v-if="loadingDrive" class="h-24 w-full" />
|
||||||
<Empty v-else-if="driveFiles.length === 0 && !driveConnectedToTarget && !driveError" class="shrink-0 py-6">
|
<Empty v-else-if="driveFiles.length === 0 && !driveError" class="shrink-0 py-6">
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon"><Folder /></EmptyMedia>
|
<EmptyMedia variant="icon"><Folder /></EmptyMedia>
|
||||||
<EmptyDescription>{{ t('msd.emptyFolder') }}</EmptyDescription>
|
<EmptyDescription>{{ t('msd.emptyFolder') }}</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
|
|
||||||
<!-- Connected placeholder: file list hidden while drive mounted on target -->
|
|
||||||
<div
|
|
||||||
v-else-if="driveConnectedToTarget"
|
|
||||||
class="shrink-0 text-center py-6 text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
{{ t('msd.driveConnectedFilesHidden') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
|
<div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div
|
<div
|
||||||
@@ -1232,6 +1246,12 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="driveConnectedToTarget"
|
||||||
|
class="shrink-0 py-6 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('msd.driveConnectedFilesHidden') }}
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -408,17 +408,17 @@ onUnmounted(() => {
|
|||||||
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
|
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
side="right"
|
||||||
class="w-[90vw] max-w-[440px] border-l bg-background p-0"
|
class="w-[90vw] max-w-[440px] gap-0 overflow-hidden border-l bg-background p-0"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<SheetHeader class="border-b px-6 py-3">
|
<SheetHeader class="shrink-0 border-b px-6 py-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<SheetTitle class="text-base">{{ t('stats.title') }}</SheetTitle>
|
<SheetTitle class="text-base">{{ t('stats.title') }}</SheetTitle>
|
||||||
<Badge variant="secondary">WebRTC</Badge>
|
<Badge variant="secondary">WebRTC</Badge>
|
||||||
</div>
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<ScrollArea class="h-[calc(100dvh-60px)]">
|
<ScrollArea class="min-h-0 flex-1">
|
||||||
<div class="px-6 py-4 space-y-6">
|
<div class="px-6 py-4 space-y-6">
|
||||||
<!-- Video Information -->
|
<!-- Video Information -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive } from 'lucide-vue-next'
|
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive, MonitorOff, Clock } from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error' | 'no_signal' | 'busy'
|
||||||
|
|
||||||
export interface StatusDetail {
|
export interface StatusDetail {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
@@ -28,7 +30,7 @@ export interface StatusDetail {
|
|||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
title: string
|
title: string
|
||||||
type: 'device' | 'video' | 'hid' | 'audio' | 'msd'
|
type: 'device' | 'video' | 'hid' | 'audio' | 'msd'
|
||||||
status: 'connected' | 'connecting' | 'disconnected' | 'error'
|
status: ConnectionStatus
|
||||||
quickInfo?: string // Quick info displayed on trigger (e.g., "1920x1080 30fps")
|
quickInfo?: string // Quick info displayed on trigger (e.g., "1920x1080 30fps")
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
@@ -54,6 +56,9 @@ const statusColor = computed(() => {
|
|||||||
return 'bg-status-active'
|
return 'bg-status-active'
|
||||||
case 'connecting':
|
case 'connecting':
|
||||||
return 'bg-warning animate-pulse'
|
return 'bg-warning animate-pulse'
|
||||||
|
case 'no_signal':
|
||||||
|
case 'busy':
|
||||||
|
return 'bg-warning'
|
||||||
case 'disconnected':
|
case 'disconnected':
|
||||||
return 'bg-muted-foreground'
|
return 'bg-muted-foreground'
|
||||||
case 'error':
|
case 'error':
|
||||||
@@ -86,6 +91,10 @@ const statusIcon = computed(() => {
|
|||||||
return CheckCircle
|
return CheckCircle
|
||||||
case 'connecting':
|
case 'connecting':
|
||||||
return Loader2
|
return Loader2
|
||||||
|
case 'no_signal':
|
||||||
|
return MonitorOff
|
||||||
|
case 'busy':
|
||||||
|
return Clock
|
||||||
case 'error':
|
case 'error':
|
||||||
return AlertCircle
|
return AlertCircle
|
||||||
default:
|
default:
|
||||||
@@ -93,6 +102,8 @@ const statusIcon = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isNotice = computed(() => ['no_signal', 'busy'].includes(props.status))
|
||||||
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
switch (props.status) {
|
switch (props.status) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
@@ -103,6 +114,9 @@ const statusText = computed(() => {
|
|||||||
return t('status.disconnected')
|
return t('status.disconnected')
|
||||||
case 'error':
|
case 'error':
|
||||||
return t('status.error')
|
return t('status.error')
|
||||||
|
case 'no_signal':
|
||||||
|
case 'busy':
|
||||||
|
return t(`status.${props.status}`)
|
||||||
default:
|
default:
|
||||||
return props.status
|
return props.status
|
||||||
}
|
}
|
||||||
@@ -118,6 +132,9 @@ const statusBadgeText = computed(() => {
|
|||||||
return t('statusCard.offline')
|
return t('statusCard.offline')
|
||||||
case 'error':
|
case 'error':
|
||||||
return t('status.error')
|
return t('status.error')
|
||||||
|
case 'no_signal':
|
||||||
|
case 'busy':
|
||||||
|
return t(`status.${props.status}`)
|
||||||
default:
|
default:
|
||||||
return props.status
|
return props.status
|
||||||
}
|
}
|
||||||
@@ -142,7 +159,7 @@ const statusBadgeText = computed(() => {
|
|||||||
<!-- Compact: single row with dot + abbreviated title -->
|
<!-- Compact: single row with dot + abbreviated title -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span :class="cn('size-1.5 rounded-full shrink-0', statusColor)" />
|
<span :class="cn('size-1.5 rounded-full shrink-0', statusColor)" />
|
||||||
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
|
<span class="text-xs text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -182,11 +199,12 @@ const statusBadgeText = computed(() => {
|
|||||||
status === 'connected' ? 'text-success' :
|
status === 'connected' ? 'text-success' :
|
||||||
status === 'connecting' ? 'text-warning animate-spin' :
|
status === 'connecting' ? 'text-warning animate-spin' :
|
||||||
status === 'error' ? 'text-destructive' :
|
status === 'error' ? 'text-destructive' :
|
||||||
|
isNotice ? 'text-warning' :
|
||||||
'text-muted-foreground'
|
'text-muted-foreground'
|
||||||
)"
|
)"
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="status === 'connected' ? 'success' : status === 'connecting' ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
:variant="status === 'connected' ? 'success' : (status === 'connecting' || isNotice) ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||||
class="text-[10px] px-1.5 py-0"
|
class="text-[10px] px-1.5 py-0"
|
||||||
>
|
>
|
||||||
{{ statusBadgeText }}
|
{{ statusBadgeText }}
|
||||||
@@ -230,7 +248,7 @@ const statusBadgeText = computed(() => {
|
|||||||
<!-- Compact: single row with dot + abbreviated title -->
|
<!-- Compact: single row with dot + abbreviated title -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span :class="cn('size-1.5 rounded-full shrink-0', statusColor)" />
|
<span :class="cn('size-1.5 rounded-full shrink-0', statusColor)" />
|
||||||
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
|
<span class="text-xs text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -270,11 +288,12 @@ const statusBadgeText = computed(() => {
|
|||||||
status === 'connected' ? 'text-success' :
|
status === 'connected' ? 'text-success' :
|
||||||
status === 'connecting' ? 'text-warning animate-spin' :
|
status === 'connecting' ? 'text-warning animate-spin' :
|
||||||
status === 'error' ? 'text-destructive' :
|
status === 'error' ? 'text-destructive' :
|
||||||
|
isNotice ? 'text-warning' :
|
||||||
'text-muted-foreground'
|
'text-muted-foreground'
|
||||||
)"
|
)"
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="status === 'connected' ? 'success' : status === 'connecting' ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
:variant="status === 'connected' ? 'success' : (status === 'connecting' || isNotice) ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||||
class="text-[10px] px-1.5 py-0"
|
class="text-[10px] px-1.5 py-0"
|
||||||
>
|
>
|
||||||
{{ statusBadgeText }}
|
{{ statusBadgeText }}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
@@ -39,6 +40,7 @@ export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
videoMode: VideoMode
|
videoMode: VideoMode
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -538,12 +540,23 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||||
|
:aria-label="buttonText"
|
||||||
|
:title="buttonText"
|
||||||
|
>
|
||||||
<Monitor class="size-3.5 sm:size-4" />
|
<Monitor class="size-3.5 sm:size-4" />
|
||||||
<span class="hidden sm:inline">{{ buttonText }}</span>
|
<span class="hidden sm:inline">{{ buttonText }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
<PopoverContent
|
||||||
|
@open-auto-focus="focusConsolePanel"
|
||||||
|
class="console-config-panel w-[min(320px,92vw)] p-3"
|
||||||
|
align="start"
|
||||||
|
:side="props.side ?? 'bottom'"
|
||||||
|
>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
minimal?: boolean
|
||||||
|
textOnlyScale?: boolean
|
||||||
scaleMode?: VideoScaleMode
|
scaleMode?: VideoScaleMode
|
||||||
sourceSizeAvailable?: boolean
|
sourceSizeAvailable?: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -51,7 +53,7 @@ function toggleScaleMode() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider v-if="!minimal">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<span data-fixed-action class="inline-flex">
|
<span data-fixed-action class="inline-flex">
|
||||||
@@ -64,7 +66,7 @@ function toggleScaleMode() {
|
|||||||
:aria-pressed="props.scaleMode === 'actual'"
|
:aria-pressed="props.scaleMode === 'actual'"
|
||||||
@click="toggleScaleMode"
|
@click="toggleScaleMode"
|
||||||
>
|
>
|
||||||
<Scaling class="size-3.5" />
|
<Scaling v-if="!textOnlyScale" class="size-3.5" />
|
||||||
1:1
|
1:1
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
@@ -75,7 +77,7 @@ function toggleScaleMode() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider v-if="!minimal">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
@@ -97,6 +99,7 @@ function toggleScaleMode() {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
v-if="!minimal"
|
||||||
data-fixed-action
|
data-fixed-action
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="mx-2 h-5 w-px shrink-0 self-center bg-border"
|
class="mx-2 h-5 w-px shrink-0 self-center bg-border"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import Keyboard from 'simple-keyboard'
|
import Keyboard from 'simple-keyboard'
|
||||||
@@ -38,6 +39,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const consoleAppearance = useConsoleAppearance()
|
||||||
|
|
||||||
const isAttached = ref(props.attached ?? true)
|
const isAttached = ref(props.attached ?? true)
|
||||||
const selectedOs = ref<KeyboardOsType>('windows')
|
const selectedOs = ref<KeyboardOsType>('windows')
|
||||||
@@ -594,6 +596,7 @@ onUnmounted(() => {
|
|||||||
v-if="visible"
|
v-if="visible"
|
||||||
:id="keyboardId"
|
:id="keyboardId"
|
||||||
ref="keyboardRef"
|
ref="keyboardRef"
|
||||||
|
:data-console-layout="consoleAppearance"
|
||||||
class="vkb"
|
class="vkb"
|
||||||
:class="{
|
:class="{
|
||||||
'vkb--attached': isAttached,
|
'vkb--attached': isAttached,
|
||||||
@@ -909,7 +912,7 @@ html.dark .hg-theme-default .hg-button.down-key,
|
|||||||
min-width: 1200px;
|
min-width: 1200px;
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
width: auto;
|
width: auto;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--console-surface-radius, var(--radius-lg));
|
||||||
box-shadow: 0 25px 50px -12px color-mix(in oklch, var(--foreground) 25%, transparent);
|
box-shadow: 0 25px 50px -12px color-mix(in oklch, var(--foreground) 25%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,7 +939,7 @@ html.dark .hg-theme-default .hg-button.down-key,
|
|||||||
|
|
||||||
.vkb--floating .vkb-header {
|
.vkb--floating .vkb-header {
|
||||||
cursor: move;
|
cursor: move;
|
||||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
border-radius: var(--console-surface-radius, var(--radius-lg)) var(--console-surface-radius, var(--radius-lg)) 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vkb-header-left {
|
.vkb-header-left {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||||
|
const consoleAppearance = useConsoleAppearance()
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue"
|
||||||
import { X } from "lucide-vue-next"
|
import { X } from "lucide-vue-next"
|
||||||
@@ -31,6 +33,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogContent
|
<DialogContent
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
|
:data-console-layout="consoleAppearance"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||||
|
const consoleAppearance = useConsoleAppearance()
|
||||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
@@ -21,7 +23,7 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class", "sideOffset")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
@@ -30,6 +32,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
|
:data-console-layout="consoleAppearance"
|
||||||
|
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||||
|
const consoleAppearance = useConsoleAppearance()
|
||||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
@@ -19,6 +21,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<template>
|
<template>
|
||||||
<DropdownMenuSubContent
|
<DropdownMenuSubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
:data-console-layout="consoleAppearance"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] max-w-(--reka-dropdown-menu-content-available-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] max-w-(--reka-dropdown-menu-content-available-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||||
|
const consoleAppearance = useConsoleAppearance()
|
||||||
import type { HoverCardContentProps } from "reka-ui"
|
import type { HoverCardContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
@@ -20,7 +22,7 @@ const props = withDefaults(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class", "sideOffset")
|
||||||
|
|
||||||
const forwardedProps = useForwardProps(delegatedProps)
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
</script>
|
</script>
|
||||||
@@ -29,6 +31,8 @@ const forwardedProps = useForwardProps(delegatedProps)
|
|||||||
<HoverCardPortal>
|
<HoverCardPortal>
|
||||||
<HoverCardContent
|
<HoverCardContent
|
||||||
data-slot="hover-card-content"
|
data-slot="hover-card-content"
|
||||||
|
:data-console-layout="consoleAppearance"
|
||||||
|
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
|
||||||
v-bind="{ ...$attrs, ...forwardedProps }"
|
v-bind="{ ...$attrs, ...forwardedProps }"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||||
|
const consoleAppearance = useConsoleAppearance()
|
||||||
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
|
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
@@ -22,7 +24,7 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
const emits = defineEmits<PopoverContentEmits>()
|
const emits = defineEmits<PopoverContentEmits>()
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class", "sideOffset")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
@@ -31,6 +33,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<PopoverPortal>
|
<PopoverPortal>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
data-slot="popover-content"
|
data-slot="popover-content"
|
||||||
|
:data-console-layout="consoleAppearance"
|
||||||
|
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||||
|
const consoleAppearance = useConsoleAppearance()
|
||||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
@@ -32,6 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<SelectPortal>
|
<SelectPortal>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
|
:data-console-layout="consoleAppearance"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||||
|
const consoleAppearance = useConsoleAppearance()
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue"
|
||||||
import { X } from "lucide-vue-next"
|
import { X } from "lucide-vue-next"
|
||||||
@@ -36,6 +38,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
<DialogContent
|
<DialogContent
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
|
:data-console-layout="consoleAppearance"
|
||||||
|
:data-edge="side"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
side === 'right'
|
side === 'right'
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||||
|
const consoleAppearance = useConsoleAppearance()
|
||||||
import type { TooltipContentEmits, TooltipContentProps } from "reka-ui"
|
import type { TooltipContentEmits, TooltipContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
@@ -23,6 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
|
:data-console-layout="consoleAppearance"
|
||||||
v-bind="{ ...forwarded, ...$attrs }"
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
:class="cn('bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance', props.class)"
|
:class="cn('bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance', props.class)"
|
||||||
>
|
>
|
||||||
|
|||||||
22
web/src/composables/useConsoleAppearance.ts
Normal file
22
web/src/composables/useConsoleAppearance.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { inject, provide, type InjectionKey, type Ref } from 'vue'
|
||||||
|
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||||
|
|
||||||
|
// Injection follows component ownership through portals, without styling settings/login.
|
||||||
|
const consoleAppearanceKey: InjectionKey<Readonly<Ref<ConsoleLayout>>> = Symbol('consoleAppearance')
|
||||||
|
|
||||||
|
export function provideConsoleAppearance(layout: Readonly<Ref<ConsoleLayout>>) {
|
||||||
|
provide(consoleAppearanceKey, layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConsoleAppearance() {
|
||||||
|
return inject(consoleAppearanceKey, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus the panel itself so the first help tooltip does not obscure its controls on open.
|
||||||
|
// Keyboard users can then Tab into the panel's controls in their normal order.
|
||||||
|
export function focusConsolePanel(event: Event) {
|
||||||
|
if (event.target instanceof HTMLElement) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.target.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
32
web/src/composables/useConsoleLayout.ts
Normal file
32
web/src/composables/useConsoleLayout.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export type ConsoleLayout = 'current' | 'floating' | 'sidebar'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'consoleLayout'
|
||||||
|
const VALID_LAYOUTS: ConsoleLayout[] = ['current', 'floating', 'sidebar']
|
||||||
|
|
||||||
|
function readStoredLayout(): ConsoleLayout {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return VALID_LAYOUTS.includes(stored as ConsoleLayout)
|
||||||
|
? stored as ConsoleLayout
|
||||||
|
: 'current'
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleLayout = ref<ConsoleLayout>(readStoredLayout())
|
||||||
|
|
||||||
|
watch(consoleLayout, layout => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, layout)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useConsoleLayout() {
|
||||||
|
function setConsoleLayout(layout: ConsoleLayout) {
|
||||||
|
if (VALID_LAYOUTS.includes(layout)) {
|
||||||
|
consoleLayout.value = layout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
consoleLayout,
|
||||||
|
setConsoleLayout,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,12 +96,19 @@ export default {
|
|||||||
backToPassword: 'Back to password',
|
backToPassword: 'Back to password',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
|
no_signal: 'No signal',
|
||||||
|
busy: 'Device busy',
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
connecting: 'Connecting',
|
connecting: 'Connecting',
|
||||||
disconnected: 'Disconnected',
|
disconnected: 'Disconnected',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
},
|
},
|
||||||
actionbar: {
|
actionbar: {
|
||||||
|
muted: 'Muted',
|
||||||
|
more: 'More actions',
|
||||||
|
collapseToolbar: 'Collapse toolbar',
|
||||||
|
expandToolbar: 'Expand toolbar',
|
||||||
|
|
||||||
paste: 'Paste Text',
|
paste: 'Paste Text',
|
||||||
micStart: 'Start Transfer',
|
micStart: 'Start Transfer',
|
||||||
micStop: 'Stop Transfer',
|
micStop: 'Stop Transfer',
|
||||||
@@ -190,6 +197,8 @@ export default {
|
|||||||
selectAudioDevice: 'Select device...',
|
selectAudioDevice: 'Select device...',
|
||||||
},
|
},
|
||||||
infobar: {
|
infobar: {
|
||||||
|
pointerCaptured: 'Mouse captured · Esc to release',
|
||||||
|
|
||||||
keys: 'Keys',
|
keys: 'Keys',
|
||||||
pointer: 'Pointer',
|
pointer: 'Pointer',
|
||||||
caps: 'Caps',
|
caps: 'Caps',
|
||||||
@@ -439,6 +448,7 @@ export default {
|
|||||||
uploadFailed: 'File upload failed',
|
uploadFailed: 'File upload failed',
|
||||||
driveUnreadable: 'Format unsupported',
|
driveUnreadable: 'Format unsupported',
|
||||||
driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.',
|
driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.',
|
||||||
|
driveFilesystemUnsupportedHint: 'Web file browsing does not support this filesystem, but you can still connect the drive to the target machine.',
|
||||||
reinitializeDrive: 'Re-initialize',
|
reinitializeDrive: 'Re-initialize',
|
||||||
diskMode: 'Drive Mode',
|
diskMode: 'Drive Mode',
|
||||||
singleDiskMode: 'Single',
|
singleDiskMode: 'Single',
|
||||||
@@ -484,7 +494,7 @@ export default {
|
|||||||
downloadIncomplete: 'The remote image download was incomplete.',
|
downloadIncomplete: 'The remote image download was incomplete.',
|
||||||
driveNotInitialized: 'The virtual drive is not initialized.',
|
driveNotInitialized: 'The virtual drive is not initialized.',
|
||||||
driveConnected: 'The virtual drive is connected to the controlled computer. Disconnect it before editing files.',
|
driveConnected: 'The virtual drive is connected to the controlled computer. Disconnect it before editing files.',
|
||||||
driveFilesystemUnsupported: 'The virtual drive filesystem is unsupported. Reinitialize it to continue.',
|
driveFilesystemUnsupported: 'Web file management does not support this format, but the drive can still be mounted on the controlled computer.',
|
||||||
driveSizeInvalid: 'The virtual drive size is invalid.',
|
driveSizeInvalid: 'The virtual drive size is invalid.',
|
||||||
storageSpaceUnavailable: 'Available virtual media storage space could not be determined.',
|
storageSpaceUnavailable: 'Available virtual media storage space could not be determined.',
|
||||||
storageFull: 'Virtual media storage does not have enough free space.',
|
storageFull: 'Virtual media storage does not have enough free space.',
|
||||||
@@ -551,6 +561,18 @@ export default {
|
|||||||
networkAddresses: 'Network Addresses',
|
networkAddresses: 'Network Addresses',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
theme: 'Theme',
|
theme: 'Theme',
|
||||||
|
consoleLayout: 'Console layout',
|
||||||
|
consoleLayoutDesc: 'Choose how console controls are arranged. The preference is saved in this browser.',
|
||||||
|
consoleLayoutHints: {
|
||||||
|
current: 'Full status and labeled tools for configuration and troubleshooting.',
|
||||||
|
sidebar: 'Compact status and fixed icons for frequent actions.',
|
||||||
|
floating: 'Essential floating controls that collapse to keep the video clear.',
|
||||||
|
},
|
||||||
|
consoleLayoutOptions: {
|
||||||
|
current: 'Top bar',
|
||||||
|
floating: 'Floating bar',
|
||||||
|
sidebar: 'Sidebar',
|
||||||
|
},
|
||||||
lightMode: 'Light',
|
lightMode: 'Light',
|
||||||
darkMode: 'Dark',
|
darkMode: 'Dark',
|
||||||
systemMode: 'System',
|
systemMode: 'System',
|
||||||
@@ -948,6 +970,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
statusCard: {
|
statusCard: {
|
||||||
|
connectionDetails: 'Connection details',
|
||||||
|
|
||||||
device: 'Device',
|
device: 'Device',
|
||||||
video: 'Video',
|
video: 'Video',
|
||||||
hid: 'HID',
|
hid: 'HID',
|
||||||
|
|||||||
@@ -96,12 +96,19 @@ export default {
|
|||||||
backToPassword: '返回密码登录',
|
backToPassword: '返回密码登录',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
|
no_signal: '无信号',
|
||||||
|
busy: '设备占用中',
|
||||||
connected: '已连接',
|
connected: '已连接',
|
||||||
connecting: '连接中',
|
connecting: '连接中',
|
||||||
disconnected: '已断开',
|
disconnected: '已断开',
|
||||||
error: '错误',
|
error: '错误',
|
||||||
},
|
},
|
||||||
actionbar: {
|
actionbar: {
|
||||||
|
muted: '已静音',
|
||||||
|
more: '更多操作',
|
||||||
|
collapseToolbar: '收起工具栏',
|
||||||
|
expandToolbar: '展开工具栏',
|
||||||
|
|
||||||
paste: '粘贴文本',
|
paste: '粘贴文本',
|
||||||
micStart: '开始传声',
|
micStart: '开始传声',
|
||||||
micStop: '停止传声',
|
micStop: '停止传声',
|
||||||
@@ -190,6 +197,8 @@ export default {
|
|||||||
selectAudioDevice: '选择设备...',
|
selectAudioDevice: '选择设备...',
|
||||||
},
|
},
|
||||||
infobar: {
|
infobar: {
|
||||||
|
pointerCaptured: '鼠标已捕获 · Esc 释放',
|
||||||
|
|
||||||
keys: '按键',
|
keys: '按键',
|
||||||
pointer: '指针',
|
pointer: '指针',
|
||||||
caps: 'Caps',
|
caps: 'Caps',
|
||||||
@@ -438,6 +447,7 @@ export default {
|
|||||||
uploadFailed: '文件上传失败',
|
uploadFailed: '文件上传失败',
|
||||||
driveUnreadable: '格式不支持',
|
driveUnreadable: '格式不支持',
|
||||||
driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。',
|
driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。',
|
||||||
|
driveFilesystemUnsupportedHint: '网页暂不支持浏览此文件系统,但仍可连接到被控机使用。',
|
||||||
reinitializeDrive: '重新初始化',
|
reinitializeDrive: '重新初始化',
|
||||||
diskMode: '驱动器模式',
|
diskMode: '驱动器模式',
|
||||||
singleDiskMode: '单驱动器',
|
singleDiskMode: '单驱动器',
|
||||||
@@ -483,7 +493,7 @@ export default {
|
|||||||
downloadIncomplete: '远程镜像下载不完整。',
|
downloadIncomplete: '远程镜像下载不完整。',
|
||||||
driveNotInitialized: '虚拟盘尚未初始化。',
|
driveNotInitialized: '虚拟盘尚未初始化。',
|
||||||
driveConnected: '虚拟盘已连接到被控机,请先断开连接再操作文件。',
|
driveConnected: '虚拟盘已连接到被控机,请先断开连接再操作文件。',
|
||||||
driveFilesystemUnsupported: '虚拟盘文件系统不受支持,请重新初始化后再操作。',
|
driveFilesystemUnsupported: '网页文件管理不支持此格式,但仍可挂载到被控机使用。',
|
||||||
driveSizeInvalid: '虚拟盘大小无效。',
|
driveSizeInvalid: '虚拟盘大小无效。',
|
||||||
storageSpaceUnavailable: '无法获取虚拟媒体存储空间信息。',
|
storageSpaceUnavailable: '无法获取虚拟媒体存储空间信息。',
|
||||||
storageFull: '虚拟媒体存储空间不足。',
|
storageFull: '虚拟媒体存储空间不足。',
|
||||||
@@ -550,6 +560,18 @@ export default {
|
|||||||
networkAddresses: '网络地址',
|
networkAddresses: '网络地址',
|
||||||
language: '语言',
|
language: '语言',
|
||||||
theme: '主题',
|
theme: '主题',
|
||||||
|
consoleLayout: '控制台布局',
|
||||||
|
consoleLayoutDesc: '选择控制台操作区的排列方式,设置将保存在当前浏览器。',
|
||||||
|
consoleLayoutHints: {
|
||||||
|
current: '完整状态与文字工具栏,适合配置和排障。',
|
||||||
|
sidebar: '精简状态与固定图标入口,适合频繁操作。',
|
||||||
|
floating: '常用操作悬浮显示,可收起以专注画面。',
|
||||||
|
},
|
||||||
|
consoleLayoutOptions: {
|
||||||
|
current: '顶栏',
|
||||||
|
floating: '浮动栏',
|
||||||
|
sidebar: '侧边栏',
|
||||||
|
},
|
||||||
lightMode: '浅色模式',
|
lightMode: '浅色模式',
|
||||||
darkMode: '深色模式',
|
darkMode: '深色模式',
|
||||||
systemMode: '跟随系统',
|
systemMode: '跟随系统',
|
||||||
@@ -947,6 +969,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
statusCard: {
|
statusCard: {
|
||||||
|
connectionDetails: '连接详情',
|
||||||
|
|
||||||
device: '设备',
|
device: '设备',
|
||||||
video: '视频',
|
video: '视频',
|
||||||
hid: 'HID',
|
hid: 'HID',
|
||||||
|
|||||||
@@ -185,3 +185,79 @@ body {
|
|||||||
.settings-dense [data-slot="card-footer"].border-t {
|
.settings-dense [data-slot="card-footer"].border-t {
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Console surfaces follow their owning layout, including content rendered in portals. */
|
||||||
|
[data-console-layout="current"] {
|
||||||
|
--console-surface-radius: 8px;
|
||||||
|
--console-control-radius: 5px;
|
||||||
|
--console-menu-row: 32px;
|
||||||
|
--console-surface-shadow: 0 4px 14px rgb(0 0 0 / 12%);
|
||||||
|
}
|
||||||
|
[data-console-layout="floating"] {
|
||||||
|
--console-surface-radius: 16px;
|
||||||
|
--console-control-radius: 10px;
|
||||||
|
--console-menu-row: 38px;
|
||||||
|
--console-surface-shadow: 0 12px 36px rgb(0 0 0 / 20%);
|
||||||
|
}
|
||||||
|
[data-console-layout="sidebar"] {
|
||||||
|
--console-surface-radius: 10px;
|
||||||
|
--console-control-radius: 7px;
|
||||||
|
--console-menu-row: 36px;
|
||||||
|
--console-surface-shadow: 4px 6px 20px rgb(0 0 0 / 14%);
|
||||||
|
}
|
||||||
|
[data-console-layout]:is(
|
||||||
|
[data-slot="popover-content"], [data-slot="hover-card-content"],
|
||||||
|
[data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"],
|
||||||
|
[data-slot="select-content"], [data-slot="dialog-content"]
|
||||||
|
) {
|
||||||
|
border-radius: var(--console-surface-radius);
|
||||||
|
box-shadow: var(--console-surface-shadow);
|
||||||
|
}
|
||||||
|
[data-console-layout][data-slot="popover-content"] {
|
||||||
|
max-height: min(var(--reka-popover-content-available-height, 80dvh), 80dvh);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
[data-console-layout] :is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"], [data-slot="button"]) {
|
||||||
|
border-radius: var(--console-control-radius);
|
||||||
|
}
|
||||||
|
[data-console-layout] :is([data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-sub-trigger"], [data-slot="select-item"]) {
|
||||||
|
min-height: var(--console-menu-row);
|
||||||
|
border-radius: var(--console-control-radius);
|
||||||
|
}
|
||||||
|
[data-console-layout="floating"]:is([data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"]) {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.console-config-panel[data-console-layout="floating"] {
|
||||||
|
width: min(360px, calc(100vw - 24px));
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.console-config-panel[data-console-layout="sidebar"] {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
[data-console-layout="floating"][data-slot="tooltip-content"] {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
[data-console-layout="floating"][data-slot="sheet-content"] {
|
||||||
|
margin: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
[data-console-layout="floating"][data-slot="sheet-content"]:is([data-edge="left"], [data-edge="right"]) {
|
||||||
|
height: calc(100% - 24px);
|
||||||
|
max-width: min(24rem, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
[data-console-layout="sidebar"][data-slot="sheet-content"][data-edge="right"] {
|
||||||
|
border-radius: 12px 0 0 12px;
|
||||||
|
}
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
[data-console-layout] :is([data-slot="dropdown-menu-item"], [data-slot="select-item"]) {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-console-layout][data-slot="computer-use-panel"] {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--console-surface-radius);
|
||||||
|
box-shadow: var(--console-surface-shadow);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useVideoScaling } from '@/composables/useVideoScaling'
|
|||||||
import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket'
|
import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket'
|
||||||
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
import { useConsoleLayout } from '@/composables/useConsoleLayout'
|
||||||
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
||||||
import { getMicrophone } from '@/composables/useMicrophone'
|
import { getMicrophone } from '@/composables/useMicrophone'
|
||||||
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } from '@/api'
|
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } from '@/api'
|
||||||
@@ -30,8 +31,11 @@ import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/
|
|||||||
import type { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket'
|
import type { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket'
|
||||||
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
|
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
|
||||||
|
|
||||||
import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue'
|
import StatusCard, { type StatusDetail, type ConnectionStatus } from '@/components/StatusCard.vue'
|
||||||
import ActionBar from '@/components/ActionBar.vue'
|
import ActionBar from '@/components/ActionBar.vue'
|
||||||
|
import ConsoleHeaderActions from '@/components/ConsoleHeaderActions.vue'
|
||||||
|
import { provideConsoleAppearance } from '@/composables/useConsoleAppearance'
|
||||||
|
import ConsoleStatusSummary, { type ConsoleStatusItem } from '@/components/ConsoleStatusSummary.vue'
|
||||||
import InfoBar from '@/components/InfoBar.vue'
|
import InfoBar from '@/components/InfoBar.vue'
|
||||||
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
|
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
|
||||||
import StatsSheet from '@/components/StatsSheet.vue'
|
import StatsSheet from '@/components/StatsSheet.vue'
|
||||||
@@ -236,14 +240,19 @@ const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
|
|||||||
const showTerminalDialog = ref(false)
|
const showTerminalDialog = ref(false)
|
||||||
const featureVisibility = useFeatureVisibility()
|
const featureVisibility = useFeatureVisibility()
|
||||||
const { isDark, toggleTheme } = useTheme()
|
const { isDark, toggleTheme } = useTheme()
|
||||||
|
const { consoleLayout } = useConsoleLayout()
|
||||||
|
provideConsoleAppearance(consoleLayout)
|
||||||
const terminalAvailable = computed(() => ttydStatus.value?.available !== false)
|
const terminalAvailable = computed(() => ttydStatus.value?.available !== false)
|
||||||
const showPower = computed(() => featureVisibility.value.power)
|
const showPower = computed(() => featureVisibility.value.power)
|
||||||
const showTerminal = computed(() => terminalAvailable.value && featureVisibility.value.webTerminal)
|
const showTerminal = computed(() => terminalAvailable.value && featureVisibility.value.webTerminal)
|
||||||
const showComputerUse = computed(() => featureVisibility.value.computerUse)
|
const showComputerUse = computed(() => featureVisibility.value.computerUse)
|
||||||
const showPasteText = computed(() => featureVisibility.value.pasteText)
|
const showPasteText = computed(() => featureVisibility.value.pasteText)
|
||||||
|
|
||||||
const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
const videoStatus = computed<ConnectionStatus>(() => {
|
||||||
if (wsNetworkError.value) return 'connecting'
|
if (wsNetworkError.value) return 'connecting'
|
||||||
|
if (streamSignalState.value === 'no_signal') return 'no_signal'
|
||||||
|
if (streamSignalState.value === 'device_busy') return 'busy'
|
||||||
|
if (streamSignalState.value === 'device_lost') return 'error'
|
||||||
|
|
||||||
if (videoError.value) return 'error'
|
if (videoError.value) return 'error'
|
||||||
if (videoLoading.value) return 'connecting'
|
if (videoLoading.value) return 'connecting'
|
||||||
@@ -256,6 +265,24 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
|
|||||||
return 'disconnected'
|
return 'disconnected'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Optional idle devices are neutral; video and HID must stay visible when unavailable.
|
||||||
|
const consoleStatusItems = computed<ConsoleStatusItem[]>(() => {
|
||||||
|
const items: ConsoleStatusItem[] = [
|
||||||
|
{ id: 'video', title: t('statusCard.video'), status: videoStatus.value,
|
||||||
|
quickInfo: videoQuickInfo.value, details: videoDetails.value, required: true,
|
||||||
|
errorMessage: showSignalOverlay.value ? signalOverlayInfo.value.title : videoErrorMessage.value },
|
||||||
|
{ id: 'audio', title: t('statusCard.audio'), status: audioStatus.value,
|
||||||
|
quickInfo: audioQuickInfo.value, details: audioDetails.value, errorMessage: audioErrorMessage.value },
|
||||||
|
{ id: 'hid', title: t('statusCard.hid'), status: hidStatus.value,
|
||||||
|
quickInfo: hidQuickInfo.value, details: hidDetails.value, errorMessage: hidErrorMessage.value, required: true },
|
||||||
|
]
|
||||||
|
if (showMsdStatusCard.value) items.push({
|
||||||
|
id: 'msd', title: t('statusCard.msd'), status: msdStatus.value,
|
||||||
|
quickInfo: msdQuickInfo.value, details: msdDetails.value, errorMessage: msdErrorMessage.value,
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
function openStatsSheet() {
|
function openStatsSheet() {
|
||||||
if (showConnectionStats.value) {
|
if (showConnectionStats.value) {
|
||||||
statsSheetOpen.value = true
|
statsSheetOpen.value = true
|
||||||
@@ -288,6 +315,7 @@ const isMjpegPaused = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const videoQuickInfo = computed(() => {
|
const videoQuickInfo = computed(() => {
|
||||||
|
if (videoStatus.value === 'no_signal' || videoStatus.value === 'busy') return t(`status.${videoStatus.value}`)
|
||||||
const stream = systemStore.stream
|
const stream = systemStore.stream
|
||||||
if (!stream?.resolution) return ''
|
if (!stream?.resolution) return ''
|
||||||
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
|
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
|
||||||
@@ -306,8 +334,9 @@ const videoDetails = computed<StatusDetail[]>(() => {
|
|||||||
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}`
|
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}`
|
||||||
|
|
||||||
const targetFpsValue = formatFpsValue(stream.targetFps ?? 0)
|
const targetFpsValue = formatFpsValue(stream.targetFps ?? 0)
|
||||||
const actualFpsValue = paused ? t('statusCard.paused') : formatFpsValue(receivedFps)
|
const signalUnavailable = streamSignalState.value !== 'ok'
|
||||||
const actualStatus: StatusDetail['status'] = paused
|
const actualFpsValue = signalUnavailable ? videoQuickInfo.value : paused ? t('statusCard.paused') : formatFpsValue(receivedFps)
|
||||||
|
const actualStatus: StatusDetail['status'] = signalUnavailable ? 'warning' : paused
|
||||||
? undefined
|
? undefined
|
||||||
: receivedFps > 5 ? 'ok'
|
: receivedFps > 5 ? 'ok'
|
||||||
: receivedFps > 0 ? 'warning'
|
: receivedFps > 0 ? 'warning'
|
||||||
@@ -3157,17 +3186,22 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-screen h-dvh flex flex-col bg-background">
|
<div class="relative h-screen h-dvh flex flex-col bg-background">
|
||||||
<header class="shrink-0 border-b bg-background">
|
<header class="shrink-0 border-b bg-background" :class="consoleLayout === 'floating' && 'console-header--floating'">
|
||||||
<div class="px-2 sm:px-4">
|
<div class="px-2 sm:px-4">
|
||||||
<div class="h-10 sm:h-14 flex items-center justify-between">
|
<div class="console-header__row h-10 sm:h-14 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2 sm:gap-6">
|
<div class="flex min-w-0 flex-1 items-center gap-2 sm:gap-6">
|
||||||
<div class="flex items-center gap-1.5 sm:gap-2">
|
<div class="flex shrink-0 items-center gap-1.5 whitespace-nowrap sm:gap-2">
|
||||||
<BrandMark size="md" class="hidden sm:block" />
|
<BrandMark size="md" class="hidden sm:block" />
|
||||||
<BrandMark size="sm" class="sm:hidden" />
|
<BrandMark size="sm" class="sm:hidden" />
|
||||||
<span class="font-bold text-sm sm:text-lg">One-KVM</span>
|
<span class="font-bold text-sm sm:text-lg">One-KVM</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex md:hidden items-center gap-1">
|
<ConsoleStatusSummary
|
||||||
|
v-if="consoleLayout !== 'current'"
|
||||||
|
:items="consoleStatusItems"
|
||||||
|
:show-video-info="consoleLayout === 'sidebar'"
|
||||||
|
/>
|
||||||
|
<div v-else class="flex md:hidden items-center gap-1">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
:title="t('statusCard.video')"
|
:title="t('statusCard.video')"
|
||||||
type="video"
|
type="video"
|
||||||
@@ -3189,8 +3223,19 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 sm:gap-2">
|
<div v-if="consoleLayout === 'floating'" id="console-header-toolbar" class="console-header__toolbar" />
|
||||||
<div class="hidden md:flex items-center gap-2">
|
<div class="console-header__account flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
|
<ConsoleHeaderActions
|
||||||
|
v-if="consoleLayout === 'floating'"
|
||||||
|
:show-stats="showConnectionStats"
|
||||||
|
:show-terminal="showTerminal"
|
||||||
|
:terminal-running="!!ttydStatus?.running"
|
||||||
|
:show-computer-use="showComputerUse"
|
||||||
|
@open-stats="openStatsSheet"
|
||||||
|
@open-terminal="openTerminal"
|
||||||
|
@open-computer-use="openComputerUse"
|
||||||
|
/>
|
||||||
|
<div v-if="consoleLayout === 'current'" class="hidden md:flex items-center gap-2">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
:title="t('statusCard.video')"
|
:title="t('statusCard.video')"
|
||||||
type="video"
|
type="video"
|
||||||
@@ -3270,7 +3315,9 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<Teleport defer :to="consoleLayout === 'floating' ? '#console-header-toolbar' : 'body'" :disabled="consoleLayout !== 'floating'">
|
||||||
<ActionBar
|
<ActionBar
|
||||||
|
:layout="consoleLayout"
|
||||||
:mouse-mode="mouseMode"
|
:mouse-mode="mouseMode"
|
||||||
:video-mode="videoMode"
|
:video-mode="videoMode"
|
||||||
:ttyd-running="ttydStatus?.running"
|
:ttyd-running="ttydStatus?.running"
|
||||||
@@ -3294,7 +3341,11 @@ onUnmounted(() => {
|
|||||||
@open-terminal="openTerminal"
|
@open-terminal="openTerminal"
|
||||||
@open-computer-use="openComputerUse"
|
@open-computer-use="openComputerUse"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 overflow-hidden relative">
|
</Teleport>
|
||||||
|
<div
|
||||||
|
class="flex-1 overflow-hidden relative transition-[padding] duration-300"
|
||||||
|
:class="consoleLayout === 'sidebar' && 'pl-14 sm:pl-16'"
|
||||||
|
>
|
||||||
<div class="absolute inset-0 dot-grid-bg" />
|
<div class="absolute inset-0 dot-grid-bg" />
|
||||||
<div class="relative flex h-full w-full min-w-0 items-stretch gap-3 p-1 sm:p-4">
|
<div class="relative flex h-full w-full min-w-0 items-stretch gap-3 p-1 sm:p-4">
|
||||||
<div
|
<div
|
||||||
@@ -3534,6 +3585,13 @@ onUnmounted(() => {
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
<div id="keyboard-anchor"></div>
|
<div id="keyboard-anchor"></div>
|
||||||
<InfoBar
|
<InfoBar
|
||||||
|
v-if="consoleLayout !== 'floating' || pressedKeys.length > 0 || isPointerLocked"
|
||||||
|
:compact="consoleLayout !== 'current'"
|
||||||
|
:captured="isPointerLocked"
|
||||||
|
:minimal="consoleLayout === 'floating'"
|
||||||
|
:class="consoleLayout === 'floating'
|
||||||
|
? 'pointer-events-none absolute bottom-2 left-1/2 z-30 max-w-[calc(100%-1rem)] -translate-x-1/2 rounded-lg border shadow-sm !w-auto'
|
||||||
|
: 'shrink-0'"
|
||||||
:pressed-keys="pressedKeys"
|
:pressed-keys="pressedKeys"
|
||||||
:caps-lock="keyboardLed.capsLock"
|
:caps-lock="keyboardLed.capsLock"
|
||||||
:num-lock="keyboardLed.numLock"
|
:num-lock="keyboardLed.numLock"
|
||||||
@@ -3601,6 +3659,27 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Expanded controls may overlap header actions, but stay outside the video stage. */
|
||||||
|
.console-header--floating .console-header__row {
|
||||||
|
position: relative;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
.console-header__toolbar {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 40;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 64rem;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.console-header__toolbar :deep(.console-action-bar) {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { toConfigFps } from '@/lib/fps'
|
|||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
import { useConsoleLayout, type ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||||
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
|
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
|
||||||
import { getVideoFormatState } from '@/lib/video-format-support'
|
import { getVideoFormatState } from '@/lib/video-format-support'
|
||||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||||
@@ -143,6 +144,9 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
Wrench,
|
Wrench,
|
||||||
|
PanelTop,
|
||||||
|
PanelLeft,
|
||||||
|
GalleryHorizontalEnd,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t, te } = useI18n()
|
const { t, te } = useI18n()
|
||||||
@@ -153,6 +157,15 @@ const configStore = useConfigStore()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const featureVisibility = useFeatureVisibility()
|
const featureVisibility = useFeatureVisibility()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
const { consoleLayout, setConsoleLayout } = useConsoleLayout()
|
||||||
|
const consoleLayoutOptions: Array<{
|
||||||
|
value: ConsoleLayout
|
||||||
|
icon: typeof PanelTop
|
||||||
|
}> = [
|
||||||
|
{ value: 'current', icon: PanelTop },
|
||||||
|
{ value: 'floating', icon: GalleryHorizontalEnd },
|
||||||
|
{ value: 'sidebar', icon: PanelLeft },
|
||||||
|
]
|
||||||
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
|
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
|
||||||
|
|
||||||
const isWindows = computed(() => systemStore.platform?.mode === 'windows')
|
const isWindows = computed(() => systemStore.platform?.mode === 'windows')
|
||||||
@@ -2797,6 +2810,55 @@ watch(isWindows, () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ t('settings.consoleLayout') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('settings.consoleLayoutDesc') }}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<button
|
||||||
|
v-for="option in consoleLayoutOptions"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="group rounded-lg border p-3 text-left transition-colors hover:bg-accent"
|
||||||
|
:class="consoleLayout === option.value ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'border-border'"
|
||||||
|
:aria-pressed="consoleLayout === option.value"
|
||||||
|
@click="setConsoleLayout(option.value)"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex h-20 overflow-hidden rounded-md border bg-muted/40">
|
||||||
|
<div
|
||||||
|
v-if="option.value === 'sidebar'"
|
||||||
|
class="flex w-4 flex-col items-center gap-1 border-r bg-background p-1"
|
||||||
|
>
|
||||||
|
<span v-for="i in 4" :key="i" class="size-1.5 rounded-sm bg-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1 bg-zinc-950">
|
||||||
|
<div
|
||||||
|
v-if="option.value === 'current'"
|
||||||
|
class="absolute inset-x-0 top-0 flex h-3 items-center gap-1 border-b bg-background px-1"
|
||||||
|
>
|
||||||
|
<span v-for="i in 5" :key="i" class="h-1 w-2 rounded-full bg-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="option.value === 'floating'"
|
||||||
|
class="absolute inset-x-2 top-2 flex h-3 items-center gap-1 rounded border bg-background/90 px-1 shadow"
|
||||||
|
>
|
||||||
|
<span v-for="i in 5" :key="i" class="h-1 w-2 rounded-full bg-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="option.icon" class="size-4 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">{{ t(`settings.consoleLayoutOptions.${option.value}`) }}</span>
|
||||||
|
<Check v-if="consoleLayout === option.value" class="ml-auto size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">{{ t(`settings.consoleLayoutHints.${option.value}`) }}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{{ t('settings.language') }}</CardTitle>
|
<CardTitle>{{ t('settings.language') }}</CardTitle>
|
||||||
|
|||||||
Reference in New Issue
Block a user