mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 03:32:00 +08:00
refactor: 删除部分多余的代码和注释
This commit is contained in:
@@ -8,7 +8,6 @@ const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
// Check for dark mode preference
|
||||
function initTheme() {
|
||||
const stored = localStorage.getItem('theme')
|
||||
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
@@ -16,11 +15,9 @@ function initTheme() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on mount
|
||||
onMounted(async () => {
|
||||
initTheme()
|
||||
|
||||
// Check setup status
|
||||
try {
|
||||
await authStore.checkSetupStatus()
|
||||
if (authStore.needsSetup) {
|
||||
@@ -28,22 +25,17 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Continue anyway
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
try {
|
||||
await authStore.checkAuth()
|
||||
if (authStore.isAuthenticated) {
|
||||
// Fetch system info
|
||||
await systemStore.fetchSystemInfo()
|
||||
}
|
||||
} catch {
|
||||
// Not authenticated
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for dark mode changes
|
||||
watch(
|
||||
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
(dark) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
MsdConfigUpdate,
|
||||
AtxConfig,
|
||||
AtxConfigUpdate,
|
||||
AtxDevices,
|
||||
AudioConfig,
|
||||
AudioConfigUpdate,
|
||||
ExtensionsStatus,
|
||||
@@ -36,7 +37,6 @@ import type {
|
||||
|
||||
import { request } from './request'
|
||||
|
||||
// ===== 全局配置 API =====
|
||||
export const configApi = {
|
||||
/**
|
||||
* 获取完整配置
|
||||
@@ -44,7 +44,6 @@ export const configApi = {
|
||||
getAll: () => request<AppConfig>('/config'),
|
||||
}
|
||||
|
||||
// ===== Auth 配置 API =====
|
||||
export const authConfigApi = {
|
||||
/**
|
||||
* 获取认证配置
|
||||
@@ -62,7 +61,6 @@ export const authConfigApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// ===== Video 配置 API =====
|
||||
export const videoConfigApi = {
|
||||
/**
|
||||
* 获取视频配置
|
||||
@@ -80,7 +78,6 @@ export const videoConfigApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// ===== Stream 配置 API =====
|
||||
export const streamConfigApi = {
|
||||
/**
|
||||
* 获取流配置
|
||||
@@ -98,7 +95,6 @@ export const streamConfigApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// ===== HID 配置 API =====
|
||||
export const hidConfigApi = {
|
||||
/**
|
||||
* 获取 HID 配置
|
||||
@@ -116,7 +112,6 @@ export const hidConfigApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// ===== MSD 配置 API =====
|
||||
export const msdConfigApi = {
|
||||
/**
|
||||
* 获取 MSD 配置
|
||||
@@ -134,9 +129,6 @@ export const msdConfigApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// ===== ATX 配置 API =====
|
||||
import type { AtxDevices } from '@/types/generated'
|
||||
|
||||
export interface WolHistoryEntry {
|
||||
mac_address: string
|
||||
updated_at: number
|
||||
@@ -185,7 +177,6 @@ export const atxConfigApi = {
|
||||
request<WolHistoryResponse>(`/atx/wol/history?limit=${Math.max(1, Math.min(50, limit))}`),
|
||||
}
|
||||
|
||||
// ===== Audio 配置 API =====
|
||||
export const audioConfigApi = {
|
||||
/**
|
||||
* 获取音频配置
|
||||
@@ -203,7 +194,6 @@ export const audioConfigApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// ===== Extensions API =====
|
||||
export const extensionsApi = {
|
||||
/**
|
||||
* 获取所有扩展状态
|
||||
@@ -265,8 +255,6 @@ export const extensionsApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// ===== RustDesk 配置 API =====
|
||||
|
||||
/** RustDesk 配置响应 */
|
||||
export interface RustDeskConfigResponse {
|
||||
enabled: boolean
|
||||
@@ -342,8 +330,6 @@ export const rustdeskConfigApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// ===== RTSP 配置 API =====
|
||||
|
||||
export type RtspCodec = 'h264' | 'h265'
|
||||
|
||||
export interface RtspConfigResponse {
|
||||
@@ -385,9 +371,6 @@ export const rtspConfigApi = {
|
||||
getStatus: () => request<RtspStatusResponse>('/config/rtsp/status'),
|
||||
}
|
||||
|
||||
// ===== Web 服务器配置 API =====
|
||||
// `/config/web` 使用 `WebConfigResponse` / `WebConfigUpdate`(由 typeshare 自 Rust 生成)。
|
||||
|
||||
/** REST `/config/web` 响应(`WebConfigResponse` 别名,兼容旧命名) */
|
||||
export type WebConfig = WebConfigResponse
|
||||
|
||||
@@ -409,8 +392,6 @@ export const webConfigApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// ===== 系统控制 API =====
|
||||
|
||||
export const systemApi = {
|
||||
/**
|
||||
* 重启系统
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// API client for One-KVM backend
|
||||
|
||||
import { request, ApiError } from './request'
|
||||
import type { CanonicalKey } from '@/types/generated'
|
||||
import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ success: boolean; message?: string }>(
|
||||
@@ -36,7 +34,6 @@ export const authApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// System API
|
||||
export interface NetworkAddress {
|
||||
interface: string
|
||||
ip: string
|
||||
@@ -149,7 +146,6 @@ export const updateApi = {
|
||||
request<UpdateStatusResponse>('/update/status'),
|
||||
}
|
||||
|
||||
// Stream API
|
||||
export interface VideoCodecInfo {
|
||||
id: string
|
||||
name: string
|
||||
@@ -271,7 +267,6 @@ export const streamApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// WebRTC API
|
||||
export interface IceCandidate {
|
||||
candidate: string
|
||||
sdpMid?: string
|
||||
@@ -317,15 +312,9 @@ export const webrtcApi = {
|
||||
request<{ ice_servers: IceServerConfig[]; mdns_mode: string }>('/webrtc/ice-servers'),
|
||||
}
|
||||
|
||||
// HID API
|
||||
// Import HID WebSocket composable
|
||||
import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket'
|
||||
|
||||
// Create shared HID WebSocket instance
|
||||
const hidWs = useHidWebSocket()
|
||||
let hidWsInitialized = false
|
||||
|
||||
// Initialize HID WebSocket connection
|
||||
async function ensureHidConnection() {
|
||||
if (!hidWsInitialized) {
|
||||
hidWsInitialized = true
|
||||
@@ -333,7 +322,6 @@ async function ensureHidConnection() {
|
||||
}
|
||||
}
|
||||
|
||||
// Map button string to number
|
||||
function mapButton(button?: 'left' | 'right' | 'middle'): number | undefined {
|
||||
if (!button) return undefined
|
||||
const buttonMap = { left: 0, middle: 1, right: 2 }
|
||||
@@ -403,7 +391,6 @@ export const hidApi = {
|
||||
scroll?: number | null
|
||||
}) => {
|
||||
await ensureHidConnection()
|
||||
// Ensure all values are properly typed (convert null to undefined)
|
||||
const event: HidMouseEvent = {
|
||||
type: data.type === 'move_abs' ? 'moveabs' : data.type,
|
||||
x: data.x ?? undefined,
|
||||
@@ -424,13 +411,11 @@ export const hidApi = {
|
||||
return { success: true }
|
||||
},
|
||||
|
||||
// WebSocket connection management
|
||||
connectWebSocket: () => hidWs.connect(),
|
||||
disconnectWebSocket: () => hidWs.disconnect(),
|
||||
isWebSocketConnected: () => hidWs.connected.value,
|
||||
}
|
||||
|
||||
// ATX API
|
||||
export const atxApi = {
|
||||
status: () =>
|
||||
request<{
|
||||
@@ -448,7 +433,6 @@ export const atxApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// MSD API
|
||||
export interface MsdImage {
|
||||
id: string
|
||||
name: string
|
||||
@@ -485,7 +469,6 @@ export const msdApi = {
|
||||
}
|
||||
}>('/msd/status'),
|
||||
|
||||
// Image management
|
||||
listImages: () => request<MsdImage[]>('/msd/images'),
|
||||
|
||||
uploadImage: async (file: File, onProgress?: (progress: number) => void) => {
|
||||
@@ -528,7 +511,6 @@ export const msdApi = {
|
||||
disconnect: () =>
|
||||
request<{ success: boolean }>('/msd/disconnect', { method: 'POST' }),
|
||||
|
||||
// Virtual drive
|
||||
driveInfo: () =>
|
||||
request<{
|
||||
size: number
|
||||
@@ -590,7 +572,6 @@ export const msdApi = {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Download from URL
|
||||
downloadFromUrl: (url: string, filename?: string) =>
|
||||
request<{
|
||||
download_id: string
|
||||
@@ -632,7 +613,6 @@ function sortSerialDevices(serialDevices: SerialDeviceOption[]): SerialDeviceOpt
|
||||
})
|
||||
}
|
||||
|
||||
// Config API
|
||||
/** @deprecated 使用域特定 API(videoConfigApi, hidConfigApi 等)替代 */
|
||||
export const configApi = {
|
||||
get: () => request<Record<string, unknown>>('/config'),
|
||||
@@ -683,7 +663,6 @@ export const configApi = {
|
||||
},
|
||||
}
|
||||
|
||||
// 导出新的域分离配置 API
|
||||
export {
|
||||
authConfigApi,
|
||||
videoConfigApi,
|
||||
@@ -707,7 +686,6 @@ export {
|
||||
type WebConfigUpdate,
|
||||
} from './config'
|
||||
|
||||
// 导出生成的类型
|
||||
export type {
|
||||
AppConfig,
|
||||
AuthConfig,
|
||||
@@ -730,7 +708,6 @@ export type {
|
||||
BitratePreset,
|
||||
} from '@/types/generated'
|
||||
|
||||
// Audio API
|
||||
export const audioApi = {
|
||||
status: () =>
|
||||
request<{
|
||||
@@ -765,7 +742,6 @@ export const audioApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// USB API
|
||||
export interface UsbDeviceInfo {
|
||||
bus_num: number
|
||||
dev_num: number
|
||||
|
||||
@@ -51,7 +51,6 @@ const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
// Overflow menu state
|
||||
const overflowMenuOpen = ref(false)
|
||||
|
||||
const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase())
|
||||
@@ -79,7 +78,6 @@ const emit = defineEmits<{
|
||||
(e: 'openTerminal'): void
|
||||
}>()
|
||||
|
||||
// Desktop toolbar popover/dialog state
|
||||
const pasteOpen = ref(false)
|
||||
const atxOpen = ref(false)
|
||||
const videoPopoverOpen = ref(false)
|
||||
@@ -88,7 +86,6 @@ const audioPopoverOpen = ref(false)
|
||||
const msdDialogOpen = ref(false)
|
||||
const extensionOpen = ref(false)
|
||||
|
||||
// Mobile Sheet state
|
||||
const mobileAtxOpen = ref(false)
|
||||
const mobilePasteOpen = ref(false)
|
||||
const mobileAtxOpenTime = ref(0)
|
||||
@@ -117,7 +114,6 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
mobilePasteOpenTime.value = Date.now()
|
||||
})
|
||||
|
||||
// ── Adaptive overflow: measure real width, show as many items as fit ──
|
||||
|
||||
const barRef = ref<HTMLElement | null>(null)
|
||||
const measureRef = ref<HTMLElement | null>(null)
|
||||
@@ -146,11 +142,9 @@ const ITEM_SPECS: ItemSpec[] = [
|
||||
{ id: 'settings', side: 'right' },
|
||||
]
|
||||
|
||||
// Measured widths from DOM (icon-only and with-label)
|
||||
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
||||
const measurementReady = ref(false)
|
||||
|
||||
// Measure button widths from hidden measurement container
|
||||
const measureButtonWidths = async () => {
|
||||
await nextTick()
|
||||
if (!measureRef.value) return
|
||||
@@ -162,7 +156,6 @@ const measureButtonWidths = async () => {
|
||||
const labelEl = measureRef.value.querySelector(`[data-measure="${spec.id}-label"]`) as HTMLElement
|
||||
|
||||
if (iconEl && labelEl) {
|
||||
// Add small buffer (8px) for gaps and rounding errors
|
||||
newWidths.set(spec.id, {
|
||||
icon: Math.ceil(iconEl.offsetWidth) + 8,
|
||||
label: Math.ceil(labelEl.offsetWidth) + 8,
|
||||
@@ -191,17 +184,13 @@ onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
// Re-measure when locale changes (different text widths)
|
||||
watch(locale, () => {
|
||||
measurementReady.value = false
|
||||
measureButtonWidths()
|
||||
})
|
||||
|
||||
// Fixed-width budget for always-visible items (right side):
|
||||
// keyboard + fullscreen + potential overflow button + gaps
|
||||
const RIGHT_FIXED_PX = 120
|
||||
|
||||
// First 3 items (video/audio/hid) are always visible
|
||||
const collapsibleItems = computed(() => {
|
||||
const items = ITEM_SPECS.slice(3).filter(item => {
|
||||
if (item.id === 'msd' && !showMsd.value) return false
|
||||
@@ -210,27 +199,22 @@ const collapsibleItems = computed(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
// Determine which collapsible items are visible (icon-only or with label)
|
||||
const visibleSet = computed(() => {
|
||||
if (!measurementReady.value) {
|
||||
// Fallback to hardcoded estimates during initial render
|
||||
return new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||
}
|
||||
|
||||
const available = barWidth.value - RIGHT_FIXED_PX
|
||||
|
||||
// Measure actual width of always-visible items (video/audio/hid)
|
||||
let used = 0
|
||||
if (barRef.value) {
|
||||
const leftContainer = barRef.value.querySelector('.left-buttons') as HTMLElement
|
||||
if (leftContainer) {
|
||||
// Get width of first 3 children (video/audio/hid)
|
||||
const children = Array.from(leftContainer.children).slice(0, 3) as HTMLElement[]
|
||||
used = children.reduce((sum, el) => sum + el.offsetWidth, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// If measurement failed, use estimate
|
||||
if (used === 0) used = 330
|
||||
|
||||
const result = new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||
|
||||
@@ -33,14 +33,12 @@ const { t } = useI18n()
|
||||
|
||||
const activeTab = ref('atx')
|
||||
|
||||
// ATX state
|
||||
const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
|
||||
let powerStateTimer: number | null = null
|
||||
// Decouple action data from dialog visibility to prevent race conditions
|
||||
const pendingAction = ref<'short' | 'long' | 'reset' | null>(null)
|
||||
const confirmDialogOpen = ref(false)
|
||||
|
||||
// WOL state
|
||||
const wolMacAddress = ref('')
|
||||
const wolHistory = ref<string[]>([])
|
||||
const wolSending = ref(false)
|
||||
@@ -95,10 +93,8 @@ const confirmDescription = computed(() => {
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
// MAC address validation
|
||||
const isValidMac = computed(() => {
|
||||
const mac = wolMacAddress.value.trim()
|
||||
// Support formats: AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF or AABBCCDDEEFF
|
||||
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$|^([0-9A-Fa-f]{12})$/
|
||||
return macRegex.test(mac)
|
||||
})
|
||||
@@ -107,7 +103,6 @@ function sendWol() {
|
||||
if (!isValidMac.value) return
|
||||
wolSending.value = true
|
||||
|
||||
// Normalize MAC address
|
||||
let mac = wolMacAddress.value.trim().toUpperCase()
|
||||
if (mac.length === 12) {
|
||||
mac = mac.match(/.{2}/g)!.join(':')
|
||||
@@ -117,7 +112,6 @@ function sendWol() {
|
||||
|
||||
emit('wol', mac)
|
||||
|
||||
// Optimistic update, then sync from server after request likely completes
|
||||
wolHistory.value = [mac, ...wolHistory.value.filter(item => item !== mac)].slice(0, 5)
|
||||
setTimeout(() => {
|
||||
loadWolHistory().catch(() => {})
|
||||
|
||||
@@ -42,10 +42,8 @@ const configStore = useConfigStore()
|
||||
const systemStore = useSystemStore()
|
||||
const unifiedAudio = getUnifiedAudio()
|
||||
|
||||
// === Playback Control (immediate effect) ===
|
||||
const localVolume = ref([unifiedAudio.volume.value * 100])
|
||||
|
||||
// Volume change - immediate effect, also triggers connection if needed
|
||||
async function handleVolumeChange(value: number[] | undefined) {
|
||||
if (!value || value.length === 0 || value[0] === undefined) return
|
||||
|
||||
@@ -53,7 +51,6 @@ async function handleVolumeChange(value: number[] | undefined) {
|
||||
unifiedAudio.setVolume(newVolume)
|
||||
localVolume.value = value
|
||||
|
||||
// If backend is streaming but audio not connected, connect now (user gesture)
|
||||
if (newVolume > 0 && systemStore.audio?.streaming && !unifiedAudio.connected.value) {
|
||||
console.log('[Audio] User adjusted volume, connecting unified audio')
|
||||
try {
|
||||
@@ -64,17 +61,14 @@ async function handleVolumeChange(value: number[] | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
// === Device Settings (requires apply) ===
|
||||
const devices = ref<AudioDevice[]>([])
|
||||
const loadingDevices = ref(false)
|
||||
const applying = ref(false)
|
||||
|
||||
// Config values
|
||||
const audioEnabled = ref(false)
|
||||
const selectedDevice = ref('')
|
||||
const selectedQuality = ref<'voice' | 'balanced' | 'high'>('balanced')
|
||||
|
||||
// Load device list
|
||||
async function loadDevices() {
|
||||
loadingDevices.value = true
|
||||
try {
|
||||
@@ -87,7 +81,6 @@ async function loadDevices() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize from current config
|
||||
function initializeFromCurrent() {
|
||||
const audio = configStore.audio
|
||||
if (audio) {
|
||||
@@ -96,46 +89,36 @@ function initializeFromCurrent() {
|
||||
selectedQuality.value = (audio.quality as 'voice' | 'balanced' | 'high') || 'balanced'
|
||||
}
|
||||
|
||||
// Sync playback control state
|
||||
localVolume.value = [unifiedAudio.volume.value * 100]
|
||||
}
|
||||
|
||||
// Apply device configuration
|
||||
async function applyConfig() {
|
||||
applying.value = true
|
||||
|
||||
try {
|
||||
// Update config
|
||||
await configStore.updateAudio({
|
||||
enabled: audioEnabled.value,
|
||||
device: selectedDevice.value,
|
||||
quality: selectedQuality.value,
|
||||
})
|
||||
|
||||
// If enabled and device is selected, try to start audio stream
|
||||
if (audioEnabled.value && selectedDevice.value) {
|
||||
try {
|
||||
// Restore default volume BEFORE starting audio
|
||||
// This ensures handleAudioStateChanged sees the correct volume
|
||||
if (localVolume.value[0] === 0) {
|
||||
localVolume.value = [100]
|
||||
unifiedAudio.setVolume(1)
|
||||
}
|
||||
|
||||
await audioApi.start()
|
||||
// ConsoleView will react when system.device_info reflects streaming=true.
|
||||
} catch (startError) {
|
||||
// Audio start failed - config was saved but streaming not started
|
||||
console.info('[AudioConfig] Audio start failed:', startError)
|
||||
}
|
||||
} else if (!audioEnabled.value) {
|
||||
// Reset volume to 0 when disabling audio
|
||||
localVolume.value = [0]
|
||||
unifiedAudio.setVolume(0)
|
||||
try {
|
||||
await audioApi.stop()
|
||||
} catch {
|
||||
// Ignore stop errors
|
||||
}
|
||||
unifiedAudio.disconnect()
|
||||
}
|
||||
@@ -148,7 +131,6 @@ async function applyConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Watch popover open state
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (!isOpen) return
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ function loadMouseMoveSendIntervalFromStorage(): number {
|
||||
)
|
||||
}
|
||||
|
||||
// Mouse Settings (real-time)
|
||||
const mouseThrottle = ref<number>(
|
||||
loadMouseMoveSendIntervalFromStorage()
|
||||
)
|
||||
@@ -62,9 +61,7 @@ const showCursor = ref<boolean>(
|
||||
localStorage.getItem('hidShowCursor') !== 'false' // default true
|
||||
)
|
||||
|
||||
// Watch showCursor changes and sync to localStorage + notify ConsoleView
|
||||
watch(showCursor, (newValue, oldValue) => {
|
||||
// Only sync if value actually changed (avoid triggering on initialization)
|
||||
if (newValue !== oldValue) {
|
||||
localStorage.setItem('hidShowCursor', newValue ? 'true' : 'false')
|
||||
window.dispatchEvent(new CustomEvent('hidCursorVisibilityChanged', {
|
||||
@@ -78,7 +75,6 @@ const hidBackend = ref<HidBackend>(HidBackend.None)
|
||||
const devicePath = ref<string>('')
|
||||
const baudrate = ref<number>(9600)
|
||||
|
||||
// UI state
|
||||
const applying = ref(false)
|
||||
const loadingDevices = ref(false)
|
||||
|
||||
@@ -86,7 +82,6 @@ const loadingDevices = ref(false)
|
||||
const serialDevices = ref<Array<{ path: string; name: string }>>([])
|
||||
const udcDevices = ref<Array<{ name: string }>>([])
|
||||
|
||||
// Button display text - simplified to just show label
|
||||
const buttonText = computed(() => t('actionbar.hidConfig'))
|
||||
|
||||
// Available device paths based on backend type
|
||||
@@ -117,9 +112,7 @@ async function loadDevices() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize from current config
|
||||
function initializeFromCurrent() {
|
||||
// Re-sync real-time settings from localStorage
|
||||
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
|
||||
|
||||
const storedCursor = localStorage.getItem('hidShowCursor') !== 'false'
|
||||
@@ -140,7 +133,6 @@ function initializeFromCurrent() {
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle mouse mode (real-time)
|
||||
function toggleMouseMode() {
|
||||
const newMode = props.mouseMode === 'absolute' ? 'relative' : 'absolute'
|
||||
emit('update:mouseMode', newMode)
|
||||
@@ -154,14 +146,11 @@ function toggleMouseMode() {
|
||||
})
|
||||
}
|
||||
|
||||
// Update mouse throttle (real-time)
|
||||
function handleThrottleChange(value: number[] | undefined) {
|
||||
if (!value || value.length === 0 || value[0] === undefined) return
|
||||
const throttleValue = clampMouseMoveSendIntervalMs(value[0])
|
||||
mouseThrottle.value = throttleValue
|
||||
// Save to localStorage
|
||||
localStorage.setItem('hidMouseThrottle', String(throttleValue))
|
||||
// Notify ConsoleView (storage event doesn't fire in same tab)
|
||||
window.dispatchEvent(new CustomEvent('hidMouseSendIntervalChanged', {
|
||||
detail: { intervalMs: throttleValue },
|
||||
}))
|
||||
@@ -191,7 +180,6 @@ function handleDevicePathChange(path: unknown) {
|
||||
devicePath.value = path
|
||||
}
|
||||
|
||||
// Handle baudrate change
|
||||
function handleBaudrateChange(rate: unknown) {
|
||||
if (typeof rate !== 'string') return
|
||||
baudrate.value = Number(rate)
|
||||
@@ -219,13 +207,11 @@ async function applyHidConfig() {
|
||||
// HID state will be updated via WebSocket device_info event
|
||||
} catch (e) {
|
||||
console.info('[HidConfig] Failed to apply config:', e)
|
||||
// Error toast already shown by API layer
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch open state
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (!isOpen) return
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ const props = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Key name mapping for friendly display
|
||||
const keyNameMap: Record<string, string> = {
|
||||
MetaLeft: 'Win', MetaRight: 'Win',
|
||||
ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
|
||||
|
||||
@@ -60,29 +60,23 @@ const { t } = useI18n()
|
||||
const systemStore = useSystemStore()
|
||||
const { on, off } = useWebSocket()
|
||||
|
||||
// Tab state
|
||||
const activeTab = ref('images')
|
||||
|
||||
// Image state
|
||||
const images = ref<MsdImage[]>([])
|
||||
const loadingImages = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const uploading = ref(false)
|
||||
|
||||
// Mount options (using ToggleGroup)
|
||||
const mountMode = ref<'cdrom' | 'flash'>('flash')
|
||||
const accessMode = ref<'readonly' | 'readwrite'>('readonly')
|
||||
|
||||
// Computed properties for API compatibility
|
||||
const cdromMode = computed(() => mountMode.value === 'cdrom')
|
||||
const readOnly = computed(() => accessMode.value === 'readonly')
|
||||
|
||||
// Operation state flags
|
||||
const connecting = ref(false)
|
||||
const disconnecting = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Drive state
|
||||
const driveFiles = ref<DriveFile[]>([])
|
||||
const currentPath = ref('/')
|
||||
const loadingDrive = ref(false)
|
||||
@@ -91,13 +85,11 @@ const driveInitialized = ref(false)
|
||||
const uploadingFile = ref(false)
|
||||
const fileUploadProgress = ref(0)
|
||||
|
||||
// Inner dialog state
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
|
||||
const showNewFolderDialog = ref(false)
|
||||
const newFolderName = ref('')
|
||||
|
||||
// Drive init dialog state
|
||||
const showDriveInitDialog = ref(false)
|
||||
const showDeleteDriveDialog = ref(false)
|
||||
const selectedDriveSize = ref(256) // Default 256MB
|
||||
@@ -105,7 +97,6 @@ const customDriveSize = ref<number | undefined>(undefined)
|
||||
const initializingDrive = ref(false)
|
||||
const deletingDrive = ref(false)
|
||||
|
||||
// URL download state
|
||||
const showUrlDialog = ref(false)
|
||||
const downloadUrl = ref('')
|
||||
const downloadFilename = ref('')
|
||||
@@ -119,14 +110,11 @@ const downloadProgress = ref<{
|
||||
status: string
|
||||
} | null>(null)
|
||||
|
||||
// Constants
|
||||
const TWO_POINT_TWO_GB = 2.2 * 1024 * 1024 * 1024
|
||||
|
||||
// Computed
|
||||
const msdConnected = computed(() => systemStore.msd?.connected ?? false)
|
||||
const msdMode = computed(() => systemStore.msd?.mode ?? 'none')
|
||||
|
||||
// Get currently connected image name
|
||||
const connectedImageName = computed(() => {
|
||||
if (!msdConnected.value) return null
|
||||
if (msdMode.value === 'drive') return t('msd.drive')
|
||||
@@ -136,7 +124,6 @@ const connectedImageName = computed(() => {
|
||||
return image?.name ?? null
|
||||
})
|
||||
|
||||
// Check if any operation is in progress
|
||||
const operationInProgress = computed(() => {
|
||||
return connecting.value ||
|
||||
disconnecting.value ||
|
||||
@@ -147,7 +134,6 @@ const operationInProgress = computed(() => {
|
||||
deletingDrive.value
|
||||
})
|
||||
|
||||
// Check if image is large (>2.2GB)
|
||||
function isLargeFile(image: MsdImage): boolean {
|
||||
return image.size > TWO_POINT_TWO_GB
|
||||
}
|
||||
@@ -163,7 +149,6 @@ const breadcrumbs = computed(() => {
|
||||
return crumbs
|
||||
})
|
||||
|
||||
// Load data when dialog opens
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
await loadData()
|
||||
@@ -179,7 +164,6 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
// Image functions
|
||||
async function loadImages() {
|
||||
loadingImages.value = true
|
||||
try {
|
||||
@@ -294,7 +278,6 @@ async function executeDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
// Drive functions
|
||||
async function loadDriveInfo() {
|
||||
try {
|
||||
driveInfo.value = await msdApi.driveInfo()
|
||||
@@ -304,7 +287,6 @@ async function loadDriveInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// Drive size options - computed for i18n support
|
||||
const driveSizeOptions = computed(() => [
|
||||
{ value: 64, label: '64 MB' },
|
||||
{ value: 128, label: '128 MB' },
|
||||
@@ -316,17 +298,14 @@ const driveSizeOptions = computed(() => [
|
||||
{ value: 8192, label: '8 GB' },
|
||||
])
|
||||
|
||||
// Computed final drive size
|
||||
const finalDriveSize = computed(() => {
|
||||
return customDriveSize.value || selectedDriveSize.value
|
||||
})
|
||||
|
||||
// Open drive init dialog
|
||||
function initializeDrive() {
|
||||
showDriveInitDialog.value = true
|
||||
}
|
||||
|
||||
// Create drive with selected size
|
||||
async function createDrive() {
|
||||
initializingDrive.value = true
|
||||
try {
|
||||
@@ -342,7 +321,6 @@ async function createDrive() {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete virtual drive
|
||||
async function deleteDrive() {
|
||||
deletingDrive.value = true
|
||||
try {
|
||||
@@ -422,7 +400,6 @@ async function createFolder() {
|
||||
}
|
||||
}
|
||||
|
||||
// URL download functions
|
||||
async function startUrlDownload() {
|
||||
if (!downloadUrl.value.trim()) return
|
||||
|
||||
|
||||
@@ -53,10 +53,8 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
// Tab state
|
||||
const activeTab = ref('images')
|
||||
|
||||
// Image state
|
||||
const images = ref<MsdImage[]>([])
|
||||
const loadingImages = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
@@ -64,7 +62,6 @@ const uploading = ref(false)
|
||||
const cdromMode = ref(true)
|
||||
const readOnly = ref(true)
|
||||
|
||||
// Drive state
|
||||
const driveFiles = ref<DriveFile[]>([])
|
||||
const currentPath = ref('/')
|
||||
const loadingDrive = ref(false)
|
||||
@@ -73,13 +70,11 @@ const driveInitialized = ref(false)
|
||||
const uploadingFile = ref(false)
|
||||
const fileUploadProgress = ref(0)
|
||||
|
||||
// Dialog state
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
|
||||
const showNewFolderDialog = ref(false)
|
||||
const newFolderName = ref('')
|
||||
|
||||
// Computed
|
||||
const msdConnected = computed(() => systemStore.msd?.connected ?? false)
|
||||
const msdMode = computed(() => systemStore.msd?.mode ?? 'none')
|
||||
|
||||
@@ -94,7 +89,6 @@ const breadcrumbs = computed(() => {
|
||||
return crumbs
|
||||
})
|
||||
|
||||
// Load data when sheet opens
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
await loadData()
|
||||
@@ -110,7 +104,6 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
// Image functions
|
||||
async function loadImages() {
|
||||
loadingImages.value = true
|
||||
try {
|
||||
@@ -186,7 +179,6 @@ async function executeDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
// Drive functions
|
||||
async function loadDriveInfo() {
|
||||
try {
|
||||
driveInfo.value = await msdApi.driveInfo()
|
||||
|
||||
@@ -23,11 +23,8 @@ const currentChar = ref(0)
|
||||
const totalChars = ref(0)
|
||||
const abortController = ref<AbortController | null>(null)
|
||||
|
||||
// Typing speed in milliseconds between characters
|
||||
// Configurable delay to prevent target system from missing keystrokes
|
||||
const typingDelay = ref(10)
|
||||
|
||||
// Text analysis for warning display
|
||||
const textAnalysis = computed(() => {
|
||||
if (!text.value) return null
|
||||
return analyzeText(text.value)
|
||||
@@ -38,14 +35,12 @@ const hasUntypableChars = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Auto focus the textarea
|
||||
setTimeout(() => {
|
||||
textareaRef.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Cancel any ongoing paste operation when component is unmounted
|
||||
cancelPaste()
|
||||
})
|
||||
|
||||
@@ -65,7 +60,6 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
|
||||
|
||||
const mapping = charToKey(char)
|
||||
if (!mapping) {
|
||||
// Skip untypable characters
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -73,10 +67,8 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
|
||||
const modifier = shift ? 0x02 : 0
|
||||
|
||||
try {
|
||||
// Send keydown
|
||||
await hidApi.keyboard('down', key, modifier)
|
||||
|
||||
// Small delay between down and up to ensure key is registered
|
||||
await sleep(5)
|
||||
|
||||
if (signal.aborted) {
|
||||
@@ -85,20 +77,16 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
// Send keyup
|
||||
await hidApi.keyboard('up', key, modifier)
|
||||
|
||||
// Additional small delay after keyup to ensure it's processed
|
||||
await sleep(2)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[Paste] Failed to type character:', char, error)
|
||||
// Try to release the key even on error
|
||||
try {
|
||||
await hidApi.keyboard('up', key, modifier)
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -133,20 +121,17 @@ async function handlePaste() {
|
||||
currentChar.value = charIndex
|
||||
progress.value = Math.round((charIndex / totalLength) * 100)
|
||||
|
||||
// Handle CRLF: skip \r if followed by \n
|
||||
if (char === '\r' && charIndex < totalLength && chars[charIndex] === '\n') {
|
||||
continue
|
||||
}
|
||||
|
||||
await typeChar(char, signal)
|
||||
|
||||
// Delay between characters (configurable)
|
||||
if (typingDelay.value > 0 && charIndex < totalLength) {
|
||||
await sleep(typingDelay.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Success - close the modal after a brief delay
|
||||
if (!signal.aborted) {
|
||||
await sleep(200)
|
||||
text.value = ''
|
||||
@@ -155,11 +140,9 @@ async function handlePaste() {
|
||||
} catch (error) {
|
||||
console.error('[Paste] Error during paste operation:', error)
|
||||
} finally {
|
||||
// Reset HID to ensure no keys are stuck
|
||||
try {
|
||||
await hidApi.reset()
|
||||
} catch {
|
||||
// Ignore reset errors
|
||||
}
|
||||
isPasting.value = false
|
||||
progress.value = 0
|
||||
@@ -180,14 +163,12 @@ function cancelPaste() {
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Ctrl/Cmd + Enter to paste
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (!isPasting.value) {
|
||||
handlePaste()
|
||||
}
|
||||
}
|
||||
// Escape to cancel or close
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (isPasting.value) {
|
||||
@@ -196,7 +177,6 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
// Stop propagation to prevent HID interference
|
||||
e.stopPropagation()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -29,19 +29,16 @@ const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
// Chart containers
|
||||
const stabilityChartRef = ref<HTMLDivElement | null>(null)
|
||||
const delayChartRef = ref<HTMLDivElement | null>(null)
|
||||
const packetLossChartRef = ref<HTMLDivElement | null>(null)
|
||||
const fpsChartRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// Chart instances
|
||||
let stabilityChart: uPlot | null = null
|
||||
let delayChart: uPlot | null = null
|
||||
let packetLossChart: uPlot | null = null
|
||||
let fpsChart: uPlot | null = null
|
||||
|
||||
// Data history (last 120 seconds)
|
||||
const MAX_POINTS = 120
|
||||
const timestamps = ref<number[]>([])
|
||||
const jitterHistory = ref<number[]>([])
|
||||
@@ -50,7 +47,6 @@ const packetLossHistory = ref<number[]>([])
|
||||
const fpsHistory = ref<number[]>([])
|
||||
const bitrateHistory = ref<number[]>([])
|
||||
|
||||
// For delta calculations
|
||||
let lastBytesReceived = 0
|
||||
let lastPacketsLost = 0
|
||||
let lastTimestamp = 0
|
||||
@@ -58,13 +54,11 @@ let lastTimestamp = 0
|
||||
// Is WebRTC mode
|
||||
const isWebRTC = computed(() => props.videoMode !== 'mjpeg')
|
||||
|
||||
// Format time for axis
|
||||
function formatTime(ts: number): string {
|
||||
const date = new Date(ts * 1000)
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// Chart theme colors
|
||||
const chartColors = {
|
||||
line: '#3b82f6',
|
||||
fill: 'rgba(59, 130, 246, 0.1)',
|
||||
@@ -73,7 +67,6 @@ const chartColors = {
|
||||
text: '#94a3b8',
|
||||
}
|
||||
|
||||
// Chart options factory
|
||||
function createChartOptions(
|
||||
container: HTMLElement,
|
||||
_yLabel: string,
|
||||
@@ -129,7 +122,6 @@ function createChartOptions(
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltip state for each chart
|
||||
const activeTooltip = ref<{
|
||||
chartId: string
|
||||
time: string
|
||||
@@ -186,12 +178,10 @@ function createTooltipPlugin(chartId: string, unit: string): uPlot.Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize charts
|
||||
function initCharts() {
|
||||
if (!props.open) return
|
||||
|
||||
nextTick(() => {
|
||||
// Initialize timestamps if empty
|
||||
if (timestamps.value.length === 0) {
|
||||
const now = Date.now() / 1000
|
||||
for (let i = MAX_POINTS - 1; i >= 0; i--) {
|
||||
@@ -204,7 +194,6 @@ function initCharts() {
|
||||
bitrateHistory.value = new Array(MAX_POINTS).fill(0)
|
||||
}
|
||||
|
||||
// Network Stability (Jitter) Chart
|
||||
if (stabilityChartRef.value && !stabilityChart) {
|
||||
const opts = createChartOptions(stabilityChartRef.value, 'ms', (v) => `${v.toFixed(0)} ms`)
|
||||
opts.plugins = [createTooltipPlugin('stability', 'ms')]
|
||||
@@ -215,7 +204,6 @@ function initCharts() {
|
||||
)
|
||||
}
|
||||
|
||||
// Playback Delay Chart
|
||||
if (delayChartRef.value && !delayChart) {
|
||||
const opts = createChartOptions(delayChartRef.value, 'ms', (v) => `${v.toFixed(0)} ms`)
|
||||
opts.plugins = [createTooltipPlugin('delay', 'ms')]
|
||||
@@ -237,7 +225,6 @@ function initCharts() {
|
||||
)
|
||||
}
|
||||
|
||||
// FPS Chart
|
||||
if (fpsChartRef.value && !fpsChart) {
|
||||
const opts = createChartOptions(fpsChartRef.value, 'fps', (v) => `${v.toFixed(0)} fps`)
|
||||
opts.plugins = [createTooltipPlugin('fps', 'fps')]
|
||||
@@ -250,7 +237,6 @@ function initCharts() {
|
||||
})
|
||||
}
|
||||
|
||||
// Destroy charts
|
||||
function destroyCharts() {
|
||||
stabilityChart?.destroy()
|
||||
stabilityChart = null
|
||||
@@ -262,22 +248,18 @@ function destroyCharts() {
|
||||
fpsChart = null
|
||||
}
|
||||
|
||||
// Add data point
|
||||
function addDataPoint() {
|
||||
const now = Date.now() / 1000
|
||||
|
||||
// Shift timestamps
|
||||
timestamps.value.push(now)
|
||||
if (timestamps.value.length > MAX_POINTS) {
|
||||
timestamps.value.shift()
|
||||
}
|
||||
|
||||
if (isWebRTC.value && props.webrtcStats) {
|
||||
// Jitter in ms
|
||||
const jitter = (props.webrtcStats.jitter || 0) * 1000
|
||||
jitterHistory.value.push(jitter)
|
||||
|
||||
// RTT (round trip time) as delay in ms
|
||||
const rtt = (props.webrtcStats.roundTripTime || 0) * 1000
|
||||
delayHistory.value.push(rtt)
|
||||
|
||||
@@ -287,10 +269,8 @@ function addDataPoint() {
|
||||
lastPacketsLost = currentLost
|
||||
packetLossHistory.value.push(lostDelta)
|
||||
|
||||
// FPS
|
||||
fpsHistory.value.push(props.webrtcStats.framesPerSecond || 0)
|
||||
|
||||
// Calculate bitrate
|
||||
const currentBytes = props.webrtcStats.bytesReceived || 0
|
||||
const currentTime = Date.now()
|
||||
if (lastTimestamp > 0 && currentBytes > lastBytesReceived) {
|
||||
@@ -312,18 +292,15 @@ function addDataPoint() {
|
||||
bitrateHistory.value.push(0)
|
||||
}
|
||||
|
||||
// Trim arrays
|
||||
if (jitterHistory.value.length > MAX_POINTS) jitterHistory.value.shift()
|
||||
if (delayHistory.value.length > MAX_POINTS) delayHistory.value.shift()
|
||||
if (packetLossHistory.value.length > MAX_POINTS) packetLossHistory.value.shift()
|
||||
if (fpsHistory.value.length > MAX_POINTS) fpsHistory.value.shift()
|
||||
if (bitrateHistory.value.length > MAX_POINTS) bitrateHistory.value.shift()
|
||||
|
||||
// Update charts
|
||||
updateCharts()
|
||||
}
|
||||
|
||||
// Update charts with new data
|
||||
function updateCharts() {
|
||||
stabilityChart?.setData([timestamps.value, jitterHistory.value])
|
||||
delayChart?.setData([timestamps.value, delayHistory.value])
|
||||
@@ -331,7 +308,6 @@ function updateCharts() {
|
||||
fpsChart?.setData([timestamps.value, fpsHistory.value])
|
||||
}
|
||||
|
||||
// Data collection interval
|
||||
let dataInterval: number | null = null
|
||||
|
||||
function startDataCollection() {
|
||||
@@ -346,7 +322,6 @@ function stopDataCollection() {
|
||||
}
|
||||
}
|
||||
|
||||
// Format candidate type for display
|
||||
function formatCandidateType(type: string): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
host: 'Host (Local)',
|
||||
@@ -393,10 +368,8 @@ const currentStats = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Watch open state
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
// Reset data
|
||||
timestamps.value = []
|
||||
jitterHistory.value = []
|
||||
delayHistory.value = []
|
||||
@@ -417,7 +390,6 @@ watch(() => props.open, (isOpen) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Resize handler
|
||||
function handleResize() {
|
||||
if (!props.open) return
|
||||
destroyCharts()
|
||||
|
||||
@@ -92,7 +92,6 @@ const statusIcon = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Localized status text
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'connected':
|
||||
@@ -108,7 +107,6 @@ const statusText = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Localized status badge text (for hover card)
|
||||
const statusBadgeText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'connected':
|
||||
|
||||
@@ -68,7 +68,6 @@ const router = useRouter()
|
||||
const devices = ref<VideoDevice[]>([])
|
||||
const loadingDevices = ref(false)
|
||||
|
||||
// Codec list
|
||||
const codecs = ref<VideoCodecInfo[]>([])
|
||||
const loadingCodecs = ref(false)
|
||||
|
||||
@@ -135,7 +134,6 @@ function detectBrowserCodecSupport() {
|
||||
if (capabilities?.codecs) {
|
||||
for (const codec of capabilities.codecs) {
|
||||
const mimeType = codec.mimeType.toLowerCase()
|
||||
// Map MIME types to our codec IDs
|
||||
if (mimeType.includes('h264') || mimeType.includes('avc')) {
|
||||
supported.add('h264')
|
||||
}
|
||||
@@ -154,7 +152,6 @@ function detectBrowserCodecSupport() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: assume basic codecs are supported
|
||||
supported.add('h264')
|
||||
supported.add('vp8')
|
||||
supported.add('vp9')
|
||||
@@ -191,12 +188,10 @@ const translateBackendName = (backend: string | undefined): string => {
|
||||
return backend
|
||||
}
|
||||
|
||||
// Check if a format has fps >= 30 in any resolution
|
||||
const hasHighFps = (format: { resolutions: { fps: number[] }[] }): boolean => {
|
||||
return format.resolutions.some(res => res.fps.some(fps => fps >= 30))
|
||||
}
|
||||
|
||||
// Check if a format is recommended based on video mode
|
||||
const isFormatRecommended = (formatName: string): boolean => {
|
||||
if (!isVideoFormatSelectable(formatName, props.videoMode, currentEncoderBackend.value)) {
|
||||
return false
|
||||
@@ -214,20 +209,16 @@ const isFormatRecommended = (formatName: string): boolean => {
|
||||
const currentFormat = formats.find(f => f.format.toUpperCase() === upperFormat)
|
||||
if (!currentFormat) return false
|
||||
|
||||
// Check if NV12 exists with fps >= 30
|
||||
const nv12Format = formats.find(f => f.format.toUpperCase() === 'NV12')
|
||||
const nv12HasHighFps = nv12Format && hasHighFps(nv12Format)
|
||||
|
||||
// Check if YUYV exists with fps >= 30
|
||||
const yuyvFormat = formats.find(f => f.format.toUpperCase() === 'YUYV')
|
||||
const yuyvHasHighFps = yuyvFormat && hasHighFps(yuyvFormat)
|
||||
|
||||
// Priority 1: NV12 with high fps
|
||||
if (nv12HasHighFps) {
|
||||
return upperFormat === 'NV12'
|
||||
}
|
||||
|
||||
// Priority 2: YUYV with high fps (only if NV12 doesn't qualify)
|
||||
if (yuyvHasHighFps) {
|
||||
return upperFormat === 'YUYV'
|
||||
}
|
||||
@@ -235,13 +226,11 @@ const isFormatRecommended = (formatName: string): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if a format is not recommended for current video mode
|
||||
// In WebRTC mode, compressed formats (MJPEG/JPEG) are not recommended
|
||||
const isFormatNotRecommended = (formatName: string): boolean => {
|
||||
return getFormatState(formatName) === 'not_recommended'
|
||||
}
|
||||
|
||||
// Selected values (mode comes from props)
|
||||
const selectedDevice = ref<string>('')
|
||||
const selectedFormat = ref<string>('')
|
||||
const selectedResolution = ref<string>('')
|
||||
@@ -249,11 +238,9 @@ const selectedFps = ref<number>(30)
|
||||
const selectedBitratePreset = ref<'Speed' | 'Balanced' | 'Quality'>('Balanced')
|
||||
const isDirty = ref(false)
|
||||
|
||||
// UI state
|
||||
const applying = ref(false)
|
||||
const applyingBitrate = ref(false)
|
||||
|
||||
// Current config from store
|
||||
const currentConfig = computed(() => ({
|
||||
device: configStore.video?.device || '',
|
||||
format: configStore.video?.format || '',
|
||||
@@ -262,7 +249,6 @@ const currentConfig = computed(() => ({
|
||||
fps: configStore.video?.fps || 30,
|
||||
}))
|
||||
|
||||
// Button display text - simplified to just show label
|
||||
const buttonText = computed(() => t('actionbar.videoConfig'))
|
||||
|
||||
// Available codecs for selection (filtered by backend support and enriched with backend info)
|
||||
@@ -305,7 +291,6 @@ const availableCodecs = computed(() => {
|
||||
return backendFiltered.filter(codec => allowed.includes(codec.id))
|
||||
})
|
||||
|
||||
// Cascading filters
|
||||
const availableFormats = computed(() => {
|
||||
const device = devices.value.find(d => d.path === selectedDevice.value)
|
||||
return device?.formats || []
|
||||
@@ -331,13 +316,11 @@ const availableFps = computed(() => {
|
||||
return resolution?.fps || []
|
||||
})
|
||||
|
||||
// Get selected format description for display in trigger
|
||||
const selectedFormatInfo = computed(() => {
|
||||
const format = availableFormatOptions.value.find(f => f.format === selectedFormat.value)
|
||||
return format
|
||||
})
|
||||
|
||||
// Get selected codec info for display in trigger
|
||||
const selectedCodecInfo = computed(() => {
|
||||
const codec = availableCodecs.value.find(c => c.id === props.videoMode)
|
||||
return codec || null
|
||||
@@ -366,7 +349,6 @@ async function loadCodecs() {
|
||||
backends.value = result.backends || []
|
||||
} catch (e) {
|
||||
console.info('[VideoConfig] Failed to load codecs')
|
||||
// Fallback to default codecs
|
||||
codecs.value = [
|
||||
{ id: 'mjpeg', name: 'MJPEG / HTTP', protocol: 'http', hardware: false, backend: 'software', available: true },
|
||||
{ id: 'h264', name: 'H.264 / WebRTC', protocol: 'webrtc', hardware: false, backend: 'software', available: true },
|
||||
@@ -384,12 +366,10 @@ async function loadConstraints() {
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to settings page (video tab)
|
||||
function goToSettings() {
|
||||
router.push('/settings?tab=video')
|
||||
}
|
||||
|
||||
// Initialize selected values from current config
|
||||
function initializeFromCurrent() {
|
||||
const config = currentConfig.value
|
||||
selectedDevice.value = config.device
|
||||
@@ -417,7 +397,6 @@ function syncFromCurrentIfChanged() {
|
||||
isDirty.value = false
|
||||
}
|
||||
|
||||
// Handle video mode change
|
||||
function handleVideoModeChange(mode: unknown) {
|
||||
if (typeof mode !== 'string') return
|
||||
|
||||
@@ -476,7 +455,6 @@ function handleDeviceChange(devicePath: unknown) {
|
||||
selectedDevice.value = devicePath
|
||||
isDirty.value = true
|
||||
|
||||
// Auto-select first format
|
||||
const device = devices.value.find(d => d.path === devicePath)
|
||||
const format = device ? findFirstSelectableFormat(device.formats) : undefined
|
||||
if (!format) {
|
||||
@@ -487,7 +465,6 @@ function handleDeviceChange(devicePath: unknown) {
|
||||
selectFormatWithDefaults(format.format)
|
||||
}
|
||||
|
||||
// Handle format change
|
||||
function handleFormatChange(format: unknown) {
|
||||
if (typeof format !== 'string') return
|
||||
if (isFormatUnsupported(format)) return
|
||||
@@ -496,13 +473,11 @@ function handleFormatChange(format: unknown) {
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
// Handle resolution change
|
||||
function handleResolutionChange(resolution: unknown) {
|
||||
if (typeof resolution !== 'string') return
|
||||
selectedResolution.value = resolution
|
||||
isDirty.value = true
|
||||
|
||||
// Auto-select first FPS for this resolution
|
||||
const resolutionData = availableResolutions.value.find(
|
||||
r => `${r.width}x${r.height}` === resolution
|
||||
)
|
||||
@@ -511,14 +486,12 @@ function handleResolutionChange(resolution: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle FPS change
|
||||
function handleFpsChange(fps: unknown) {
|
||||
if (typeof fps !== 'string' && typeof fps !== 'number') return
|
||||
selectedFps.value = typeof fps === 'string' ? Number(fps) : fps
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
// Apply bitrate preset change
|
||||
async function applyBitratePreset(preset: 'Speed' | 'Balanced' | 'Quality') {
|
||||
if (applyingBitrate.value) return
|
||||
applyingBitrate.value = true
|
||||
@@ -532,7 +505,6 @@ async function applyBitratePreset(preset: 'Speed' | 'Balanced' | 'Quality') {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle bitrate preset selection
|
||||
function handleBitratePresetChange(preset: 'Speed' | 'Balanced' | 'Quality') {
|
||||
selectedBitratePreset.value = preset
|
||||
if (props.videoMode !== 'mjpeg') {
|
||||
@@ -540,7 +512,6 @@ function handleBitratePresetChange(preset: 'Speed' | 'Balanced' | 'Quality') {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply video configuration
|
||||
async function applyVideoConfig() {
|
||||
const [width, height] = selectedResolution.value.split('x').map(Number)
|
||||
|
||||
@@ -559,13 +530,11 @@ async function applyVideoConfig() {
|
||||
// Stream state will be updated via WebSocket system.device_info event
|
||||
} catch (e) {
|
||||
console.info('[VideoConfig] Failed to apply config:', e)
|
||||
// Error toast already shown by API layer
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch open state
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
isDirty.value = false
|
||||
|
||||
@@ -38,20 +38,16 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// State
|
||||
const isAttached = ref(props.attached ?? true)
|
||||
const selectedOs = ref<KeyboardOsType>('windows')
|
||||
|
||||
// Keyboard instances
|
||||
const mainKeyboard = ref<Keyboard | null>(null)
|
||||
const controlKeyboard = ref<Keyboard | null>(null)
|
||||
const arrowsKeyboard = ref<Keyboard | null>(null)
|
||||
|
||||
// Pressed keys tracking
|
||||
const pressedModifiers = ref<number>(0)
|
||||
const keysDown = ref<CanonicalKey[]>([])
|
||||
|
||||
// Shift state for display
|
||||
const isShiftActive = computed(() => {
|
||||
return (pressedModifiers.value & 0x22) !== 0
|
||||
})
|
||||
@@ -64,7 +60,6 @@ const layoutName = computed(() => {
|
||||
return isShiftActive.value ? 'shift' : 'default'
|
||||
})
|
||||
|
||||
// Keys currently pressed (for highlighting)
|
||||
const keyNamesForDownKeys = computed(() => {
|
||||
const activeModifierMask = pressedModifiers.value || 0
|
||||
const modifierNames = Object.entries(modifiers)
|
||||
@@ -79,19 +74,15 @@ const keyNamesForDownKeys = computed(() => {
|
||||
]))
|
||||
})
|
||||
|
||||
// Dragging state (for floating mode)
|
||||
const keyboardRef = ref<HTMLDivElement | null>(null)
|
||||
const isDragging = ref(false)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
const position = ref({ x: 100, y: 100 })
|
||||
|
||||
// Unique ID for this keyboard instance
|
||||
const keyboardId = ref(`kb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||
|
||||
// Get bottom row based on selected OS
|
||||
const getBottomRow = () => osBottomRows[selectedOs.value].join(' ')
|
||||
|
||||
// Keyboard layouts - matching JetKVM style
|
||||
const keyboardLayout = {
|
||||
main: {
|
||||
default: [
|
||||
@@ -143,19 +134,15 @@ function setCompactLayout(active: boolean) {
|
||||
updateKeyboardLayout()
|
||||
}
|
||||
|
||||
// Key display mapping with Unicode symbols (JetKVM style)
|
||||
const keyDisplayMap = computed<Record<string, string>>(() => {
|
||||
// OS-specific Meta key labels
|
||||
const metaLabel = selectedOs.value === 'windows' ? '⊞ Win'
|
||||
: selectedOs.value === 'mac' ? '⌘ Cmd' : 'Meta'
|
||||
|
||||
return {
|
||||
// Macros - compact format
|
||||
CtrlAltDelete: 'Ctrl+Alt+Del',
|
||||
AltMetaEscape: 'Alt+Meta+Esc',
|
||||
CtrlAltBackspace: 'Ctrl+Alt+Bksp',
|
||||
|
||||
// Modifiers - simplified
|
||||
ControlLeft: 'Ctrl',
|
||||
ControlRight: 'Ctrl',
|
||||
ShiftLeft: 'Shift',
|
||||
@@ -166,7 +153,6 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
|
||||
MetaRight: metaLabel,
|
||||
ContextMenu: 'Menu',
|
||||
|
||||
// Special keys
|
||||
Escape: 'Esc',
|
||||
Backspace: '⌫',
|
||||
Tab: 'Tab',
|
||||
@@ -174,7 +160,6 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
|
||||
Enter: 'Enter',
|
||||
Space: ' ',
|
||||
|
||||
// Navigation
|
||||
Insert: 'Ins',
|
||||
Delete: 'Del',
|
||||
Home: 'Home',
|
||||
@@ -182,23 +167,19 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
|
||||
PageUp: 'PgUp',
|
||||
PageDown: 'PgDn',
|
||||
|
||||
// Arrows
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
|
||||
// Control cluster
|
||||
PrintScreen: 'PrtSc',
|
||||
ScrollLock: 'ScrLk',
|
||||
Pause: 'Pause',
|
||||
|
||||
// Function keys
|
||||
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4',
|
||||
F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8',
|
||||
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
|
||||
|
||||
// Letters
|
||||
KeyA: areLettersUppercase.value ? 'A' : 'a',
|
||||
KeyB: areLettersUppercase.value ? 'B' : 'b',
|
||||
KeyC: areLettersUppercase.value ? 'C' : 'c',
|
||||
@@ -226,7 +207,6 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
|
||||
KeyY: areLettersUppercase.value ? 'Y' : 'y',
|
||||
KeyZ: areLettersUppercase.value ? 'Z' : 'z',
|
||||
|
||||
// Letter labels in the shifted layout follow CapsLock xor Shift too
|
||||
'(KeyA)': areLettersUppercase.value ? 'A' : 'a',
|
||||
'(KeyB)': areLettersUppercase.value ? 'B' : 'b',
|
||||
'(KeyC)': areLettersUppercase.value ? 'C' : 'c',
|
||||
@@ -254,15 +234,12 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
|
||||
'(KeyY)': areLettersUppercase.value ? 'Y' : 'y',
|
||||
'(KeyZ)': areLettersUppercase.value ? 'Z' : 'z',
|
||||
|
||||
// Numbers
|
||||
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
|
||||
Digit6: '6', Digit7: '7', Digit8: '8', Digit9: '9', Digit0: '0',
|
||||
|
||||
// Shifted Numbers
|
||||
'(Digit1)': '!', '(Digit2)': '@', '(Digit3)': '#', '(Digit4)': '$', '(Digit5)': '%',
|
||||
'(Digit6)': '^', '(Digit7)': '&', '(Digit8)': '*', '(Digit9)': '(', '(Digit0)': ')',
|
||||
|
||||
// Symbols
|
||||
Minus: '-', '(Minus)': '_',
|
||||
Equal: '=', '(Equal)': '+',
|
||||
BracketLeft: '[', '(BracketLeft)': '{',
|
||||
@@ -277,7 +254,6 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Handle media key press (Consumer Control)
|
||||
async function onMediaKeyPress(key: string) {
|
||||
if (key in consumerKeys) {
|
||||
const usage = consumerKeys[key as ConsumerKeyName]
|
||||
@@ -289,16 +265,12 @@ async function onMediaKeyPress(key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Switch OS layout
|
||||
function switchOsLayout(os: KeyboardOsType) {
|
||||
selectedOs.value = os
|
||||
// Save preference to localStorage
|
||||
localStorage.setItem('vkb-os-layout', os)
|
||||
// Update keyboard layout
|
||||
updateKeyboardLayout()
|
||||
}
|
||||
|
||||
// Update keyboard layout based on selected OS
|
||||
function updateKeyboardLayout() {
|
||||
const bottomRow = getBottomRow()
|
||||
const baseLayout = isCompactLayout.value ? compactMainLayout : keyboardLayout.main
|
||||
@@ -319,9 +291,7 @@ function updateKeyboardLayout() {
|
||||
updateKeyboardButtonTheme()
|
||||
}
|
||||
|
||||
// Key press handler
|
||||
async function onKeyDown(key: string) {
|
||||
// Handle macro keys
|
||||
if (key === 'CtrlAltDelete') {
|
||||
await executeMacro([
|
||||
{ keys: ['Delete'], modifiers: ['ControlLeft', 'AltLeft'] },
|
||||
@@ -343,10 +313,8 @@ async function onKeyDown(key: string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clean key name (remove parentheses for shifted keys)
|
||||
const cleanKey = key.replace(/[()]/g, '')
|
||||
|
||||
// Check if key exists
|
||||
if (!(cleanKey in keys)) {
|
||||
console.warn(`[VirtualKeyboard] Unknown key: ${cleanKey}`)
|
||||
return
|
||||
@@ -354,7 +322,6 @@ async function onKeyDown(key: string) {
|
||||
|
||||
const keyCode = keys[cleanKey as KeyName]
|
||||
|
||||
// Handle latching keys (Caps Lock, etc.)
|
||||
if (latchingKeys.some(latchingKey => latchingKey === keyCode)) {
|
||||
emit('keyDown', keyCode)
|
||||
const currentMask = pressedModifiers.value & 0xff
|
||||
@@ -366,7 +333,6 @@ async function onKeyDown(key: string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle modifier keys (toggle)
|
||||
const mask = modifiers[keyCode] ?? 0
|
||||
if (mask !== 0) {
|
||||
const isCurrentlyDown = (pressedModifiers.value & mask) !== 0
|
||||
@@ -386,7 +352,6 @@ async function onKeyDown(key: string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Regular key: press and release
|
||||
keysDown.value.push(keyCode)
|
||||
emit('keyDown', keyCode)
|
||||
const currentMask = pressedModifiers.value & 0xff
|
||||
@@ -401,7 +366,6 @@ async function onKeyDown(key: string) {
|
||||
}
|
||||
|
||||
async function onKeyUp() {
|
||||
// Not used for now - we handle up in onKeyDown with setTimeout
|
||||
}
|
||||
|
||||
async function sendKeyPress(keyCode: CanonicalKey, press: boolean, modifierMask: number) {
|
||||
@@ -453,7 +417,6 @@ async function executeMacro(steps: MacroStep[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update keyboard button theme for pressed keys
|
||||
function updateKeyboardButtonTheme() {
|
||||
const downKeys = keyNamesForDownKeys.value.join(' ')
|
||||
const buttonTheme = [
|
||||
@@ -472,7 +435,6 @@ function updateKeyboardButtonTheme() {
|
||||
arrowsKeyboard.value?.setOptions({ buttonTheme })
|
||||
}
|
||||
|
||||
// Update layout when shift state changes
|
||||
watch([layoutName, () => props.capsLock], ([name]) => {
|
||||
mainKeyboard.value?.setOptions({
|
||||
layoutName: name,
|
||||
@@ -481,11 +443,9 @@ watch([layoutName, () => props.capsLock], ([name]) => {
|
||||
updateKeyboardButtonTheme()
|
||||
})
|
||||
|
||||
// Initialize keyboards with unique selectors
|
||||
function initKeyboards() {
|
||||
const id = keyboardId.value
|
||||
|
||||
// Check if elements exist - use full selector with #
|
||||
const mainEl = document.querySelector(`#${id}-main`)
|
||||
const controlEl = document.querySelector(`#${id}-control`)
|
||||
const arrowsEl = document.querySelector(`#${id}-arrows`)
|
||||
@@ -496,7 +456,6 @@ function initKeyboards() {
|
||||
return
|
||||
}
|
||||
|
||||
// Main keyboard - pass element directly instead of selector string
|
||||
mainKeyboard.value = new Keyboard(mainEl, {
|
||||
layout: isCompactLayout.value ? compactMainLayout : keyboardLayout.main,
|
||||
layoutName: layoutName.value,
|
||||
@@ -517,7 +476,6 @@ function initKeyboards() {
|
||||
stopMouseUpPropagation: true,
|
||||
})
|
||||
|
||||
// Control keyboard
|
||||
controlKeyboard.value = new Keyboard(controlEl, {
|
||||
layout: keyboardLayout.control,
|
||||
layoutName: 'default',
|
||||
@@ -532,7 +490,6 @@ function initKeyboards() {
|
||||
stopMouseUpPropagation: true,
|
||||
})
|
||||
|
||||
// Arrows keyboard
|
||||
arrowsKeyboard.value = new Keyboard(arrowsEl, {
|
||||
layout: keyboardLayout.arrows,
|
||||
layoutName: 'default',
|
||||
@@ -551,7 +508,6 @@ function initKeyboards() {
|
||||
console.log('[VirtualKeyboard] Keyboards initialized:', id)
|
||||
}
|
||||
|
||||
// Destroy keyboards
|
||||
function destroyKeyboards() {
|
||||
mainKeyboard.value?.destroy()
|
||||
controlKeyboard.value?.destroy()
|
||||
@@ -561,7 +517,6 @@ function destroyKeyboards() {
|
||||
arrowsKeyboard.value = null
|
||||
}
|
||||
|
||||
// Dragging handlers
|
||||
function getClientCoords(e: MouseEvent | TouchEvent): { x: number; y: number } | null {
|
||||
if ('touches' in e) {
|
||||
const touch = e.touches[0]
|
||||
@@ -609,11 +564,9 @@ async function toggleAttached() {
|
||||
isAttached.value = !isAttached.value
|
||||
emit('update:attached', isAttached.value)
|
||||
|
||||
// Wait for Teleport to move the component
|
||||
await nextTick()
|
||||
await nextTick() // Extra tick for Teleport
|
||||
|
||||
// Reinitialize keyboards in new location
|
||||
setTimeout(() => {
|
||||
initKeyboards()
|
||||
}, 100)
|
||||
@@ -623,7 +576,6 @@ function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// Watch visibility to init/destroy keyboards
|
||||
watch(() => props.visible, async (visible) => {
|
||||
console.log('[VirtualKeyboard] Visibility changed:', visible, 'attached:', isAttached.value, 'id:', keyboardId.value)
|
||||
if (visible) {
|
||||
@@ -641,7 +593,6 @@ watch(() => props.attached, (value) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Load saved OS layout preference
|
||||
const savedOs = localStorage.getItem('vkb-os-layout') as KeyboardOsType | null
|
||||
if (savedOs && ['windows', 'mac', 'android'].includes(savedOs)) {
|
||||
selectedOs.value = savedOs
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
// Audio player composable - handles WebSocket connection, Opus decoding, and Web Audio API playback
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
import { OpusDecoder } from 'opus-decoder'
|
||||
import { buildWsUrl } from '@/types/websocket'
|
||||
|
||||
// Binary protocol header format (15 bytes)
|
||||
// [type:1][timestamp:4][duration:2][sequence:4][length:4][data:...]
|
||||
|
||||
export function useAudioPlayer() {
|
||||
// State
|
||||
const connected = ref(false)
|
||||
const playing = ref(false)
|
||||
const volume = ref(0) // Default to 0, user must adjust to enable audio (browser autoplay policy)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Internal variables
|
||||
let ws: WebSocket | null = null
|
||||
let audioContext: AudioContext | null = null
|
||||
let gainNode: GainNode | null = null
|
||||
@@ -23,7 +17,6 @@ export function useAudioPlayer() {
|
||||
let nextPlayTime = 0
|
||||
let isConnecting = false // Prevent concurrent connection attempts
|
||||
|
||||
// Initialize decoder
|
||||
async function initDecoder() {
|
||||
const opusDecoder = new OpusDecoder({
|
||||
channels: 2,
|
||||
@@ -33,7 +26,6 @@ export function useAudioPlayer() {
|
||||
decoder = opusDecoder
|
||||
}
|
||||
|
||||
// Initialize audio context
|
||||
function initAudioContext() {
|
||||
audioContext = new AudioContext({ sampleRate: 48000 })
|
||||
gainNode = audioContext.createGain()
|
||||
@@ -41,14 +33,12 @@ export function useAudioPlayer() {
|
||||
updateVolume()
|
||||
}
|
||||
|
||||
// Connect to WebSocket
|
||||
async function connect() {
|
||||
// Prevent concurrent connection attempts (critical fix for multiple WS connections)
|
||||
if (isConnecting) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already connected
|
||||
if (ws) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
@@ -64,7 +54,6 @@ export function useAudioPlayer() {
|
||||
isConnecting = true
|
||||
|
||||
try {
|
||||
// Initialize
|
||||
if (!decoder) await initDecoder()
|
||||
if (!audioContext) initAudioContext()
|
||||
|
||||
@@ -108,7 +97,6 @@ export function useAudioPlayer() {
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
function disconnect() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
@@ -140,14 +128,12 @@ export function useAudioPlayer() {
|
||||
const samplesPerChannel = decoded.samplesDecoded
|
||||
const channels = decoded.channelData.length
|
||||
|
||||
// Create audio buffer
|
||||
const audioBuffer = audioContext.createBuffer(
|
||||
channels,
|
||||
samplesPerChannel,
|
||||
48000
|
||||
)
|
||||
|
||||
// Fill channel data
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
const channelData = audioBuffer.getChannelData(ch)
|
||||
const sourceData = decoded.channelData[ch]
|
||||
@@ -156,7 +142,6 @@ export function useAudioPlayer() {
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule playback
|
||||
const source = audioContext.createBufferSource()
|
||||
source.buffer = audioBuffer
|
||||
source.connect(gainNode)
|
||||
@@ -164,12 +149,10 @@ export function useAudioPlayer() {
|
||||
const now = audioContext.currentTime
|
||||
const scheduledAhead = nextPlayTime - now
|
||||
|
||||
// Reset if too far behind (audio was paused/lagged)
|
||||
if (nextPlayTime < now) {
|
||||
nextPlayTime = now + 0.02 // Start 20ms ahead
|
||||
}
|
||||
|
||||
// Reset if buffer too large (> 500ms ahead)
|
||||
if (scheduledAhead > 0.5) {
|
||||
nextPlayTime = now + 0.05 // Keep 50ms buffer
|
||||
}
|
||||
@@ -177,33 +160,27 @@ export function useAudioPlayer() {
|
||||
source.start(nextPlayTime)
|
||||
nextPlayTime += audioBuffer.duration
|
||||
} catch {
|
||||
// Ignore decode errors
|
||||
}
|
||||
}
|
||||
|
||||
// Update volume
|
||||
function updateVolume() {
|
||||
if (gainNode) {
|
||||
gainNode.gain.value = volume.value
|
||||
}
|
||||
}
|
||||
|
||||
// Set volume
|
||||
function setVolume(v: number) {
|
||||
volume.value = Math.max(0, Math.min(1, v))
|
||||
updateVolume()
|
||||
}
|
||||
|
||||
// Watch volume changes
|
||||
watch(volume, updateVolume)
|
||||
|
||||
return {
|
||||
// State
|
||||
connected,
|
||||
playing,
|
||||
volume,
|
||||
error,
|
||||
// Methods
|
||||
connect,
|
||||
disconnect,
|
||||
setVolume,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Config popover composable - shared logic for config popover components
|
||||
// Provides common state management and lifecycle hooks
|
||||
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -17,11 +15,9 @@ export interface UseConfigPopoverOptions {
|
||||
export function useConfigPopover(options: UseConfigPopoverOptions) {
|
||||
const { t } = useI18n()
|
||||
|
||||
// Common state
|
||||
const applying = ref(false)
|
||||
const loadingDevices = ref(false)
|
||||
|
||||
// Watch open state to initialize
|
||||
watch(options.open, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
options.initializeFromCurrent?.()
|
||||
@@ -36,7 +32,6 @@ export function useConfigPopover(options: UseConfigPopoverOptions) {
|
||||
}
|
||||
})
|
||||
|
||||
// Apply config wrapper with loading state and toast
|
||||
async function applyConfig(applyFn: () => Promise<void>) {
|
||||
applying.value = true
|
||||
try {
|
||||
@@ -44,7 +39,6 @@ export function useConfigPopover(options: UseConfigPopoverOptions) {
|
||||
toast.success(t('config.applied'))
|
||||
} catch (e) {
|
||||
console.info('[ConfigPopover] Apply failed:', e)
|
||||
// Error toast is usually shown by API layer
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
@@ -62,11 +56,9 @@ export function useConfigPopover(options: UseConfigPopoverOptions) {
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
applying,
|
||||
loadingDevices,
|
||||
|
||||
// Methods
|
||||
applyConfig,
|
||||
refreshDevices,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Console WebSocket events composable - handles all WebSocket event subscriptions
|
||||
// Extracted from ConsoleView.vue for better separation of concerns
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
@@ -33,7 +30,7 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
const systemStore = useSystemStore()
|
||||
const { on, off, connect } = useWebSocket()
|
||||
const noop = () => {}
|
||||
// Stream device monitoring handlers
|
||||
|
||||
function handleStreamDeviceLost(data: { device: string; reason: string }) {
|
||||
if (systemStore.stream) {
|
||||
systemStore.stream.online = false
|
||||
@@ -66,20 +63,11 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
handlers.onStreamRecovered?.(_data)
|
||||
}
|
||||
|
||||
function handleStreamStateChanged(data: { state: string }) {
|
||||
if (data.state === 'error') {
|
||||
// Handled by video stream composable
|
||||
}
|
||||
}
|
||||
|
||||
function handleStreamStateChangedForward(data: { state: string; device?: string | null }) {
|
||||
handleStreamStateChanged(data)
|
||||
handlers.onStreamStateChanged?.(data)
|
||||
}
|
||||
|
||||
// Subscribe to all events
|
||||
function subscribe() {
|
||||
// Stream events
|
||||
on('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
|
||||
on('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
|
||||
on('stream.stats_update', handlers.onStreamStatsUpdate ?? noop)
|
||||
@@ -92,14 +80,11 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
on('stream.reconnecting', handleStreamReconnecting)
|
||||
on('stream.recovered', handleStreamRecovered)
|
||||
|
||||
// System events
|
||||
on('system.device_info', handlers.onDeviceInfo ?? noop)
|
||||
|
||||
// Connect WebSocket
|
||||
connect()
|
||||
}
|
||||
|
||||
// Unsubscribe from all events
|
||||
function unsubscribe() {
|
||||
off('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
|
||||
off('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// WebSocket HID channel for low-latency keyboard/mouse input (binary protocol)
|
||||
// Uses the same binary format as WebRTC DataChannel for consistency
|
||||
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import {
|
||||
type HidKeyboardEvent,
|
||||
@@ -23,26 +20,22 @@ const reconnectAttempts = ref(0)
|
||||
const networkError = ref(false)
|
||||
const networkErrorMessage = ref<string | null>(null)
|
||||
let reconnectTimeout: number | null = null
|
||||
const hidUnavailable = ref(false) // Track if HID is unavailable to prevent unnecessary reconnects
|
||||
const hidUnavailable = ref(false)
|
||||
|
||||
// Connection promise to avoid race conditions
|
||||
let connectionPromise: Promise<boolean> | null = null
|
||||
let connectionResolved = false
|
||||
|
||||
function connect(): Promise<boolean> {
|
||||
// If already connected, return immediately
|
||||
if (wsInstance && wsInstance.readyState === WebSocket.OPEN && connectionResolved) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
// If connection is in progress, return the existing promise
|
||||
if (connectionPromise && !connectionResolved) {
|
||||
return connectionPromise
|
||||
}
|
||||
|
||||
connectionResolved = false
|
||||
connectionPromise = new Promise((resolve) => {
|
||||
// Reset network error flag when attempting new connection
|
||||
networkError.value = false
|
||||
networkErrorMessage.value = null
|
||||
hidUnavailable.value = false
|
||||
@@ -60,7 +53,6 @@ function connect(): Promise<boolean> {
|
||||
}
|
||||
|
||||
wsInstance.onmessage = (e) => {
|
||||
// Handle binary response
|
||||
if (e.data instanceof ArrayBuffer) {
|
||||
const view = new DataView(e.data)
|
||||
if (view.byteLength >= 1) {
|
||||
@@ -126,14 +118,12 @@ function disconnect() {
|
||||
}
|
||||
|
||||
if (wsInstance) {
|
||||
// Close the websocket
|
||||
wsInstance.close()
|
||||
wsInstance = null
|
||||
connected.value = false
|
||||
networkError.value = false
|
||||
}
|
||||
|
||||
// Reset connection state
|
||||
connectionPromise = null
|
||||
connectionResolved = false
|
||||
}
|
||||
@@ -154,7 +144,6 @@ function sendKeyboard(event: HidKeyboardEvent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// Internal function to actually send mouse event
|
||||
function _sendMouseInternal(event: HidMouseEvent): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!wsInstance || wsInstance.readyState !== WebSocket.OPEN) {
|
||||
@@ -175,7 +164,6 @@ function sendMouse(event: HidMouseEvent): Promise<void> {
|
||||
return _sendMouseInternal(event)
|
||||
}
|
||||
|
||||
// Send consumer control event (multimedia keys)
|
||||
function sendConsumer(event: HidConsumerEvent): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!wsInstance || wsInstance.readyState !== WebSocket.OPEN) {
|
||||
@@ -194,8 +182,6 @@ function sendConsumer(event: HidConsumerEvent): Promise<void> {
|
||||
|
||||
export function useHidWebSocket() {
|
||||
onUnmounted(() => {
|
||||
// Don't disconnect on component unmount - WebSocket is shared
|
||||
// Only disconnect when explicitly called or page unloads
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -212,7 +198,6 @@ export function useHidWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
// Global lifecycle - disconnect when page unloads
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
disconnect()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Unified Audio Manager
|
||||
// Manages audio playback across different video modes (MJPEG/WebSocket and H264/WebRTC)
|
||||
// Provides a single interface for volume control and audio source switching
|
||||
|
||||
@@ -17,7 +16,6 @@ export interface UnifiedAudioState {
|
||||
}
|
||||
|
||||
export function useUnifiedAudio() {
|
||||
// === State ===
|
||||
const audioMode = ref<AudioMode>('ws')
|
||||
const volume = ref(0) // 0-1, default muted (browser autoplay policy)
|
||||
const muted = ref(false)
|
||||
@@ -25,11 +23,9 @@ export function useUnifiedAudio() {
|
||||
const playing = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// === Internal References ===
|
||||
const wsPlayer = getAudioPlayer()
|
||||
let webrtcVideoElement: HTMLVideoElement | null = null
|
||||
|
||||
// === Methods ===
|
||||
|
||||
/**
|
||||
* Set the WebRTC video element reference
|
||||
@@ -39,9 +35,7 @@ export function useUnifiedAudio() {
|
||||
// Only update if element is provided (don't clear on null to preserve reference)
|
||||
if (el) {
|
||||
webrtcVideoElement = el
|
||||
// Sync current volume to video element
|
||||
el.volume = volume.value
|
||||
// Mute if volume is 0 or explicitly muted
|
||||
const shouldMute = muted.value || volume.value === 0
|
||||
el.muted = shouldMute
|
||||
}
|
||||
@@ -57,7 +51,6 @@ export function useUnifiedAudio() {
|
||||
const wasConnected = connected.value
|
||||
const wasPlaying = playing.value
|
||||
|
||||
// Disconnect old mode
|
||||
if (audioMode.value === 'ws') {
|
||||
wsPlayer.disconnect()
|
||||
}
|
||||
@@ -65,12 +58,10 @@ export function useUnifiedAudio() {
|
||||
|
||||
audioMode.value = mode
|
||||
|
||||
// If was connected/playing and volume > 0, auto-connect new mode
|
||||
if ((wasConnected || wasPlaying) && volume.value > 0) {
|
||||
await connect()
|
||||
}
|
||||
|
||||
// Update connection state
|
||||
updateConnectionState()
|
||||
}
|
||||
|
||||
@@ -82,7 +73,6 @@ export function useUnifiedAudio() {
|
||||
const newVolume = Math.max(0, Math.min(1, v))
|
||||
volume.value = newVolume
|
||||
|
||||
// Sync to WS player
|
||||
wsPlayer.setVolume(newVolume)
|
||||
|
||||
// Sync to WebRTC video element
|
||||
@@ -99,7 +89,6 @@ export function useUnifiedAudio() {
|
||||
function setMuted(m: boolean) {
|
||||
muted.value = m
|
||||
|
||||
// WS player: control via volume (no separate mute)
|
||||
if (audioMode.value === 'ws') {
|
||||
wsPlayer.setVolume(m ? 0 : volume.value)
|
||||
}
|
||||
@@ -133,7 +122,6 @@ export function useUnifiedAudio() {
|
||||
}
|
||||
} else {
|
||||
// WebRTC audio is automatically connected via video track
|
||||
// Just ensure video element is not muted (if volume > 0)
|
||||
if (webrtcVideoElement) {
|
||||
webrtcVideoElement.muted = muted.value || volume.value === 0
|
||||
connected.value = true
|
||||
@@ -173,7 +161,6 @@ export function useUnifiedAudio() {
|
||||
}
|
||||
}
|
||||
|
||||
// Watch WS player state changes
|
||||
watch(() => wsPlayer.connected.value, (newConnected) => {
|
||||
if (audioMode.value === 'ws') {
|
||||
connected.value = newConnected
|
||||
@@ -193,7 +180,6 @@ export function useUnifiedAudio() {
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
audioMode,
|
||||
volume,
|
||||
muted,
|
||||
@@ -201,7 +187,6 @@ export function useUnifiedAudio() {
|
||||
playing,
|
||||
error,
|
||||
|
||||
// Methods
|
||||
setWebRTCElement,
|
||||
switchMode,
|
||||
setVolume,
|
||||
|
||||
@@ -85,7 +85,6 @@ async function fetchIceServers(): Promise<RTCIceServer[]> {
|
||||
}
|
||||
|
||||
// Fallback: for local connections, use no ICE servers (host candidates only)
|
||||
// For remote connections, use Google STUN as fallback
|
||||
const isLocalConnection = typeof window !== 'undefined' &&
|
||||
(window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1' ||
|
||||
@@ -105,7 +104,6 @@ async function fetchIceServers(): Promise<RTCIceServer[]> {
|
||||
]
|
||||
}
|
||||
|
||||
// Shared instance state
|
||||
let peerConnection: RTCPeerConnection | null = null
|
||||
let dataChannel: RTCDataChannel | null = null
|
||||
let sessionId: string | null = null
|
||||
@@ -146,7 +144,6 @@ const error = ref<string | null>(null)
|
||||
const dataChannelReady = ref(false)
|
||||
const connectStage = ref<WebRTCConnectStage>('idle')
|
||||
|
||||
// Create RTCPeerConnection with configuration
|
||||
function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
|
||||
const config: RTCConfiguration = {
|
||||
iceServers,
|
||||
@@ -155,7 +152,6 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
|
||||
|
||||
const pc = new RTCPeerConnection(config)
|
||||
|
||||
// Handle connection state changes
|
||||
pc.onconnectionstatechange = () => {
|
||||
switch (pc.connectionState) {
|
||||
case 'connecting':
|
||||
@@ -211,7 +207,6 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming tracks
|
||||
pc.ontrack = (event) => {
|
||||
const track = event.track
|
||||
|
||||
@@ -222,7 +217,6 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle data channel from server
|
||||
pc.ondatachannel = (event) => {
|
||||
setupDataChannel(event.channel)
|
||||
}
|
||||
@@ -230,7 +224,6 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
|
||||
return pc
|
||||
}
|
||||
|
||||
// Setup data channel event handlers
|
||||
function setupDataChannel(channel: RTCDataChannel) {
|
||||
dataChannel = channel
|
||||
|
||||
@@ -243,15 +236,12 @@ function setupDataChannel(channel: RTCDataChannel) {
|
||||
}
|
||||
|
||||
channel.onerror = () => {
|
||||
// Data channel errors handled silently
|
||||
}
|
||||
|
||||
channel.onmessage = () => {
|
||||
// Handle incoming messages from server (e.g., LED status)
|
||||
}
|
||||
}
|
||||
|
||||
// Create data channel for HID events
|
||||
function createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
|
||||
const channel = pc.createDataChannel('hid', {
|
||||
ordered: true,
|
||||
@@ -307,7 +297,6 @@ async function handleRemoteIceComplete(data: WebRTCIceCompleteEvent) {
|
||||
try {
|
||||
await peerConnection.addIceCandidate(null)
|
||||
} catch {
|
||||
// End-of-candidates failures are non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +337,6 @@ async function flushPendingRemoteIce() {
|
||||
try {
|
||||
await peerConnection.addIceCandidate(null)
|
||||
} catch {
|
||||
// Ignore end-of-candidates errors
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,14 +351,12 @@ function startStatsCollection() {
|
||||
try {
|
||||
const report = await peerConnection.getStats()
|
||||
|
||||
// Collect candidate info
|
||||
const candidates: Record<string, { type: IceCandidateType; protocol: string }> = {}
|
||||
let selectedPairLocalId = ''
|
||||
let selectedPairRemoteId = ''
|
||||
let foundActivePair = false
|
||||
|
||||
report.forEach((stat) => {
|
||||
// Collect all candidates
|
||||
if (stat.type === 'local-candidate' || stat.type === 'remote-candidate') {
|
||||
candidates[stat.id] = {
|
||||
type: (stat.candidateType as IceCandidateType) || 'unknown',
|
||||
@@ -378,10 +364,7 @@ function startStatsCollection() {
|
||||
}
|
||||
}
|
||||
|
||||
// Find the active candidate pair
|
||||
// Priority: nominated > succeeded (for Chrome/Firefox compatibility)
|
||||
if (stat.type === 'candidate-pair') {
|
||||
// Check if this is the nominated/selected pair
|
||||
const isActive = stat.nominated === true ||
|
||||
(stat.state === 'succeeded' && stat.selected === true) ||
|
||||
(stat.state === 'in-progress' && !foundActivePair)
|
||||
@@ -425,8 +408,6 @@ function startStatsCollection() {
|
||||
stats.value.remoteCandidateType = remoteCandidate.type
|
||||
}
|
||||
|
||||
// Check if using TURN relay
|
||||
// TURN relay is when either local or remote candidate is 'relay' type
|
||||
stats.value.isRelay = stats.value.localCandidateType === 'relay' || stats.value.remoteCandidateType === 'relay'
|
||||
} catch {
|
||||
// Stats collection errors are non-fatal
|
||||
@@ -473,7 +454,6 @@ async function connect(): Promise<boolean> {
|
||||
connectInFlight = (async () => {
|
||||
registerWebSocketHandlers()
|
||||
|
||||
// Prevent concurrent connection attempts
|
||||
if (isConnecting) {
|
||||
return state.value === 'connected'
|
||||
}
|
||||
@@ -484,7 +464,6 @@ async function connect(): Promise<boolean> {
|
||||
|
||||
isConnecting = true
|
||||
|
||||
// Clean up any existing connection first
|
||||
if (peerConnection || sessionId) {
|
||||
await disconnect()
|
||||
}
|
||||
@@ -505,20 +484,16 @@ async function connect(): Promise<boolean> {
|
||||
peerConnection = createPeerConnection(iceServers)
|
||||
connectStage.value = 'creating_data_channel'
|
||||
|
||||
// Create data channel before offer (for HID)
|
||||
createDataChannel(peerConnection)
|
||||
|
||||
// Add transceiver for receiving video
|
||||
peerConnection.addTransceiver('video', { direction: 'recvonly' })
|
||||
peerConnection.addTransceiver('audio', { direction: 'recvonly' })
|
||||
connectStage.value = 'creating_offer'
|
||||
|
||||
// Create offer
|
||||
const offer = await peerConnection.createOffer()
|
||||
await peerConnection.setLocalDescription(offer)
|
||||
connectStage.value = 'waiting_server_answer'
|
||||
|
||||
// Send offer to server and get answer
|
||||
// Do not pass client_id here: each connect creates a fresh session.
|
||||
const response = await webrtcApi.offer(offer.sdp!)
|
||||
sessionId = response.session_id
|
||||
@@ -526,7 +501,6 @@ async function connect(): Promise<boolean> {
|
||||
// Send any ICE candidates that were queued while waiting for sessionId
|
||||
await flushPendingIceCandidates()
|
||||
|
||||
// Set remote description (answer)
|
||||
const answer: RTCSessionDescriptionInit = {
|
||||
type: 'answer',
|
||||
sdp: response.sdp,
|
||||
@@ -611,7 +585,6 @@ async function disconnect() {
|
||||
try {
|
||||
await webrtcApi.close(oldSessionId)
|
||||
} catch {
|
||||
// Ignore close errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,13 +644,11 @@ function sendMouse(event: HidMouseEvent): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Get MediaStream for video element (cached to avoid recreating)
|
||||
function getMediaStream(): MediaStream | null {
|
||||
if (!videoTrack.value && !audioTrack.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Reuse cached stream if tracks match
|
||||
if (cachedMediaStream) {
|
||||
const currentVideoTracks = cachedMediaStream.getVideoTracks()
|
||||
const currentAudioTracks = cachedMediaStream.getAudioTracks()
|
||||
@@ -693,19 +664,15 @@ function getMediaStream(): MediaStream | null {
|
||||
return cachedMediaStream
|
||||
}
|
||||
|
||||
// Tracks changed, update the cached stream
|
||||
// Remove old tracks
|
||||
currentVideoTracks.forEach(t => cachedMediaStream!.removeTrack(t))
|
||||
currentAudioTracks.forEach(t => cachedMediaStream!.removeTrack(t))
|
||||
|
||||
// Add new tracks
|
||||
if (videoTrack.value) cachedMediaStream.addTrack(videoTrack.value)
|
||||
if (audioTrack.value) cachedMediaStream.addTrack(audioTrack.value)
|
||||
|
||||
return cachedMediaStream
|
||||
}
|
||||
|
||||
// Create new cached stream
|
||||
cachedMediaStream = new MediaStream()
|
||||
if (videoTrack.value) {
|
||||
cachedMediaStream.addTrack(videoTrack.value)
|
||||
@@ -716,15 +683,11 @@ function getMediaStream(): MediaStream | null {
|
||||
return cachedMediaStream
|
||||
}
|
||||
|
||||
// Composable export
|
||||
export function useWebRTC() {
|
||||
onUnmounted(() => {
|
||||
// Don't disconnect on unmount - keep connection alive
|
||||
// Only disconnect when explicitly called
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
state: state as Ref<WebRTCState>,
|
||||
videoTrack,
|
||||
audioTrack,
|
||||
@@ -734,14 +697,12 @@ export function useWebRTC() {
|
||||
connectStage,
|
||||
sessionId: computed(() => sessionId),
|
||||
|
||||
// Methods
|
||||
connect,
|
||||
disconnect,
|
||||
sendKeyboard,
|
||||
sendMouse,
|
||||
getMediaStream,
|
||||
|
||||
// Computed
|
||||
isConnected: computed(() => state.value === 'connected'),
|
||||
isConnecting: computed(() => state.value === 'connecting'),
|
||||
hasVideo: computed(() => videoTrack.value !== null),
|
||||
@@ -749,7 +710,6 @@ export function useWebRTC() {
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
disconnect()
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// WebSocket composable for real-time event streaming
|
||||
//
|
||||
// Usage:
|
||||
// const { connected, on, off } = useWebSocket()
|
||||
// on('stream.state_changed', (data) => { ... })
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { buildWsUrl, WS_RECONNECT_DELAY } from '@/types/websocket'
|
||||
@@ -151,7 +146,6 @@ function handleEvent(payload: WsEvent) {
|
||||
}
|
||||
})
|
||||
}
|
||||
// Silently ignore events without handlers
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
@@ -170,7 +164,6 @@ export function useWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
// Global lifecycle - disconnect when page unloads
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
disconnect()
|
||||
|
||||
@@ -109,7 +109,6 @@ export default {
|
||||
settingsTip: 'System settings',
|
||||
fullscreen: 'Fullscreen',
|
||||
fullscreenTip: 'Toggle fullscreen mode',
|
||||
// Video Config
|
||||
videoConfig: 'Video',
|
||||
streamSettings: 'Stream Settings',
|
||||
deviceSettings: 'Device Settings',
|
||||
@@ -141,7 +140,6 @@ export default {
|
||||
notRecommended: 'Not Recommended',
|
||||
multiSourceCodecLocked: '{sources} are enabled. Current codec is locked.',
|
||||
multiSourceVideoParamsWarning: '{sources} are enabled. Changing video device and input parameters will interrupt the stream.',
|
||||
// HID Config
|
||||
hidConfig: 'HID',
|
||||
mouseSettings: 'Mouse Settings',
|
||||
hidDeviceSettings: 'HID Device Settings',
|
||||
@@ -154,7 +152,6 @@ export default {
|
||||
absolute: 'Absolute',
|
||||
relative: 'Relative',
|
||||
applying: 'Applying...',
|
||||
// Audio Config
|
||||
audioConfig: 'Audio',
|
||||
playbackControl: 'Playback',
|
||||
volume: 'Volume',
|
||||
@@ -218,19 +215,16 @@ export default {
|
||||
title: 'Initial Setup',
|
||||
welcome: 'Welcome to One-KVM',
|
||||
description: 'Complete the initial setup to get started',
|
||||
// Step titles
|
||||
stepAccount: 'Account Setup',
|
||||
stepVideo: 'Video Setup',
|
||||
stepAudioVideo: 'Audio/Video Setup',
|
||||
stepHid: 'HID Setup',
|
||||
// Account
|
||||
setUsername: 'Set Admin Username',
|
||||
usernameHint: 'Username must be at least 2 characters',
|
||||
setPassword: 'Set Admin Password',
|
||||
passwordHint: 'Password must be at least 4 characters',
|
||||
confirmPassword: 'Confirm Password',
|
||||
passwordMismatch: 'Passwords do not match',
|
||||
// Video
|
||||
videoDevice: 'Video Device',
|
||||
selectVideoDevice: 'Select video capture device',
|
||||
videoFormat: 'Video Format',
|
||||
@@ -242,13 +236,11 @@ export default {
|
||||
noVideoDevices: 'No video devices detected',
|
||||
noSignalDetected: 'No HDMI signal detected. Please connect an HDMI cable and refresh.',
|
||||
refreshDevices: 'Refresh Devices',
|
||||
// Audio
|
||||
audioDevice: 'Audio Device',
|
||||
selectAudioDevice: 'Select audio capture device',
|
||||
noAudio: 'No audio',
|
||||
noAudioDevices: 'No audio devices detected',
|
||||
audioDeviceHelp: 'Select the audio capture device for capturing remote host audio. Usually on the same USB device as the video capture card.',
|
||||
// HID
|
||||
hidBackend: 'HID Backend',
|
||||
selectHidBackend: 'Select HID control method',
|
||||
serialHid: 'Serial HID',
|
||||
@@ -261,31 +253,25 @@ export default {
|
||||
selectUdc: 'Select UDC',
|
||||
noUdcDevices: 'No UDC devices detected',
|
||||
hidDisabledHint: 'Disabling HID will prevent keyboard and mouse control of the remote host',
|
||||
// Complete
|
||||
complete: 'Complete Setup',
|
||||
setupFailed: 'Setup failed',
|
||||
// Advanced encoder
|
||||
advancedEncoder: 'Advanced: Encoder Backend',
|
||||
encoderHint: 'The default "Auto" option works for most cases. Only change if you need a specific encoder backend.',
|
||||
autoRecommended: 'Auto (Recommended)',
|
||||
hardware: 'Hardware',
|
||||
software: 'Software',
|
||||
// Progress
|
||||
progress: 'Step {current} of {total}',
|
||||
// Help tooltips
|
||||
ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.',
|
||||
otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.',
|
||||
otgLowEndpointHint: 'Detected low-endpoint UDC; Consumer Control Keyboard will be disabled automatically.',
|
||||
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
|
||||
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
|
||||
// Extensions
|
||||
stepExtensions: 'Extensions',
|
||||
extensionsDescription: 'Choose which extensions to auto-start',
|
||||
ttydTitle: 'Web Terminal (ttyd)',
|
||||
ttydDescription: 'Access device command line in browser',
|
||||
extensionsHint: 'These settings can be changed later in Settings',
|
||||
notInstalled: 'Not installed',
|
||||
// Password strength
|
||||
passwordStrength: 'Password Strength',
|
||||
passwordWeak: 'Weak',
|
||||
passwordMedium: 'Medium',
|
||||
@@ -350,7 +336,6 @@ export default {
|
||||
uvc_capture_stall: '',
|
||||
},
|
||||
},
|
||||
// WebRTC
|
||||
webrtcConnected: 'WebRTC Connected',
|
||||
webrtcConnectedDesc: 'Using low-latency H.264 video stream',
|
||||
webrtcFailed: 'WebRTC Connection Failed',
|
||||
@@ -363,29 +348,23 @@ export default {
|
||||
webrtcPhaseSetRemote: 'Applying remote description...',
|
||||
webrtcPhaseApplyIce: 'Applying ICE candidates...',
|
||||
webrtcPhaseNegotiating: 'Negotiating secure connection...',
|
||||
// Pointer Lock
|
||||
pointerLocked: 'Pointer Locked',
|
||||
pointerLockedDesc: 'Press Escape to release the pointer',
|
||||
pointerLockFailed: 'Failed to lock pointer',
|
||||
relativeModeHint: 'Relative Mouse Mode',
|
||||
relativeModeHintDesc: 'Click on the video area to lock the pointer, press Escape to release',
|
||||
// Meta Key Hint
|
||||
metaKeyHint: 'System Key Detected',
|
||||
metaKeyHintDesc: 'Enter fullscreen mode to capture Win/Meta keys',
|
||||
// Stream mode change
|
||||
streamModeChanged: 'Video Mode Changed',
|
||||
streamModeChangedDesc: 'Server switched to {mode} mode',
|
||||
// Device monitoring
|
||||
deviceLost: 'Video Device Lost',
|
||||
deviceLostDesc: '{device}: {reason}',
|
||||
deviceRecovering: 'Video Device Recovering',
|
||||
deviceRecoveringDesc: 'Attempting to recover video device (attempt {attempt})',
|
||||
deviceRecovered: 'Video Device Recovered',
|
||||
deviceRecoveredDesc: 'Video device reconnected successfully',
|
||||
// Loading state
|
||||
pleaseWait: 'Please wait...',
|
||||
retryCount: 'Retrying (attempt {count})',
|
||||
// Error details
|
||||
errorDetails: 'Error details',
|
||||
},
|
||||
hid: {
|
||||
@@ -397,7 +376,6 @@ export default {
|
||||
pasteText: 'Paste Text',
|
||||
absoluteMouse: 'Absolute',
|
||||
relativeMouse: 'Relative',
|
||||
// Device monitoring
|
||||
deviceLost: 'HID Device Lost',
|
||||
deviceLostDesc: '{backend}: {reason}',
|
||||
reconnecting: 'HID Reconnecting',
|
||||
@@ -424,7 +402,6 @@ export default {
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
// Device monitoring
|
||||
deviceLost: 'Audio Device Lost',
|
||||
deviceLostDesc: '{device}: {reason}',
|
||||
reconnecting: 'Audio Reconnecting',
|
||||
@@ -468,7 +445,6 @@ export default {
|
||||
uploadImageHint: 'Click to upload ISO/IMG',
|
||||
imageMounted: 'Image {name} mounted',
|
||||
imageUnmounted: 'Image unmounted',
|
||||
// URL download
|
||||
downloadFromUrl: 'Download from URL',
|
||||
downloadFromUrlDesc: 'Enter the URL of an image file (ISO/IMG supported)',
|
||||
url: 'URL',
|
||||
@@ -479,16 +455,13 @@ export default {
|
||||
downloadFailed: 'Download failed',
|
||||
largeFileWarning: '>2.2GB',
|
||||
largeFileTooltip: 'File is larger than 2.2GB, please use Flash mode to mount',
|
||||
// Device monitoring
|
||||
error: 'MSD Error',
|
||||
errorDesc: '{reason}',
|
||||
recovered: 'MSD Recovered',
|
||||
recoveredDesc: 'MSD operation completed successfully',
|
||||
// Operation status
|
||||
operationInProgress: 'Operation in progress, please wait',
|
||||
driveConnected: 'Virtual USB drive connected',
|
||||
imageConnected: 'Image {name} connected',
|
||||
// Drive initialization
|
||||
selectDriveSize: 'Select virtual drive size',
|
||||
selectedSize: 'Selected size',
|
||||
customSize: 'Custom size',
|
||||
@@ -520,7 +493,6 @@ export default {
|
||||
security: 'Security',
|
||||
about: 'About',
|
||||
aboutDesc: 'Open and Lightweight IP-KVM Solution',
|
||||
// Device info
|
||||
deviceInfo: 'Device Info',
|
||||
deviceInfoDesc: 'Host system information',
|
||||
hostname: 'Hostname',
|
||||
@@ -545,11 +517,9 @@ export default {
|
||||
networkSettings: 'Network Settings',
|
||||
msdSettings: 'MSD Settings',
|
||||
atxSettings: 'ATX Settings',
|
||||
// Network tab
|
||||
httpSettings: 'HTTP Settings',
|
||||
httpPort: 'HTTP Port',
|
||||
configureHttpPort: 'Configure HTTP server port',
|
||||
// Web server
|
||||
webServer: 'Access Address',
|
||||
webServerDesc: 'Configure HTTP/HTTPS ports and listening addresses. Restart required for changes to take effect.',
|
||||
httpsPort: 'HTTPS Port',
|
||||
@@ -569,20 +539,17 @@ export default {
|
||||
bindAddressListEmpty: 'Add at least one IP address.',
|
||||
httpsEnabled: 'Enable HTTPS',
|
||||
httpsEnabledDesc: 'Enable HTTPS encrypted connection (a self-signed certificate is generated if none is specified)',
|
||||
// Port config
|
||||
portConfig: 'Port & Protocol',
|
||||
portConfigDesc: 'The service runs on a single port at a time, determined by the HTTPS toggle',
|
||||
httpPortReserved: 'HTTP port (reserved)',
|
||||
httpsPortReserved: 'HTTPS port (reserved)',
|
||||
previewUrl: 'Access URL preview',
|
||||
// Listen address
|
||||
listenAddress: 'Listen Address',
|
||||
listenAddressDesc: 'Configure which network interfaces the web server listens on',
|
||||
bindModeAllDesc: '0.0.0.0 — Listen on all network interfaces',
|
||||
bindModeLocalDesc: '127.0.0.1 — Allow local access only',
|
||||
bindModeCustomDesc: 'Specify a list of IP addresses',
|
||||
effectiveAddresses: 'Listen address preview',
|
||||
// SSL certificate
|
||||
sslCertificate: 'SSL Certificate',
|
||||
sslCertificateDesc: 'Upload a custom PEM certificate to replace the self-signed one, restart required',
|
||||
sslCertCustom: 'Custom Certificate',
|
||||
@@ -628,13 +595,11 @@ export default {
|
||||
updateMsgVerifying: 'Verifying (SHA256)',
|
||||
updateMsgInstalling: 'Replacing binary',
|
||||
updateMsgRestarting: 'Restarting service',
|
||||
// Auth
|
||||
auth: 'Access',
|
||||
authSettings: 'Access Settings',
|
||||
authSettingsDesc: 'Single-user access and session behavior',
|
||||
allowMultipleSessions: 'Allow multiple web sessions',
|
||||
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.',
|
||||
// User management
|
||||
userManagement: 'User Management',
|
||||
userManagementDesc: 'Manage user accounts and permissions',
|
||||
addUser: 'Add User',
|
||||
@@ -649,7 +614,6 @@ export default {
|
||||
noUsers: 'No users found',
|
||||
create: 'Create',
|
||||
confirmDeleteUser: 'Are you sure you want to delete user "{name}"?',
|
||||
// MSD/ATX status
|
||||
msdStatus: 'MSD Status',
|
||||
atxStatus: 'ATX Status',
|
||||
available: 'Available',
|
||||
@@ -665,7 +629,6 @@ export default {
|
||||
disabled: 'Disabled',
|
||||
msdDesc: 'Mass Storage Device allows you to mount ISO images and virtual drives to the target machine. Use the MSD panel on the main page to manage images.',
|
||||
atxDesc: 'ATX power control allows you to remotely power on/off and reset the target machine. Use the ATX panel on the main page to control power.',
|
||||
// ATX configuration
|
||||
atxSettingsDesc: 'Configure ATX power control hardware bindings',
|
||||
atxEnable: 'Enable ATX Control',
|
||||
atxEnableDesc: 'Enable remote control of power and reset buttons',
|
||||
@@ -693,16 +656,13 @@ export default {
|
||||
atxLedPin: 'GPIO Pin',
|
||||
atxLedInverted: 'Invert Logic',
|
||||
atxLedInvertedDesc: 'GPIO is low when LED is on',
|
||||
// WOL configuration
|
||||
atxWolSettings: 'Wake-on-LAN Settings',
|
||||
atxWolSettingsDesc: 'Configure WOL magic packet sending options',
|
||||
atxWolInterface: 'Network Interface',
|
||||
atxWolInterfacePlaceholder: 'e.g. eth0, enp0s3',
|
||||
atxWolInterfaceHint: 'Specify network interface for WOL packets, leave empty for default routing',
|
||||
// Basic tab descriptions
|
||||
themeDesc: 'Choose your preferred color scheme',
|
||||
languageDesc: 'Select your preferred language',
|
||||
// Video tab
|
||||
videoSettings: 'Video Settings',
|
||||
videoSettingsDesc: 'Configure video capture device',
|
||||
videoDevice: 'Video Device',
|
||||
@@ -719,7 +679,6 @@ export default {
|
||||
software: 'Software',
|
||||
supportedFormats: 'Supported Formats',
|
||||
encoderHint: 'Hardware encoders provide better performance with lower CPU usage. Software encoders are more compatible but require more CPU resources.',
|
||||
// HID tab
|
||||
hidSettings: 'HID Settings',
|
||||
hidSettingsDesc: 'Configure keyboard and mouse control',
|
||||
hidBackend: 'HID Backend',
|
||||
@@ -748,7 +707,6 @@ export default {
|
||||
otgProfileWarning: 'Changing HID functions will reconnect the USB device',
|
||||
otgLowEndpointHint: 'Low-endpoint UDC detected; Consumer Control Keyboard will be disabled automatically.',
|
||||
otgFunctionMinWarning: 'Enable at least one HID function before saving',
|
||||
// OTG Descriptor
|
||||
otgDescriptor: 'USB Device Descriptor',
|
||||
otgDescriptorDesc: 'Configure USB device identification',
|
||||
vendorId: 'Vendor ID (VID)',
|
||||
@@ -862,7 +820,6 @@ export default {
|
||||
resetConfirmDesc: 'This will reset USB device "{device}" by cycling its authorized attribute. All connections to this device will be temporarily interrupted. Continue?',
|
||||
resetAction: 'Reset Device',
|
||||
},
|
||||
// WebRTC / ICE
|
||||
webrtcSettings: 'WebRTC Settings',
|
||||
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
|
||||
publicIceServersHint: 'Empty uses Google public STUN, configure your own TURN for production',
|
||||
@@ -931,7 +888,6 @@ export default {
|
||||
notConnected: 'Not Connected',
|
||||
connected: 'Connected',
|
||||
image: 'Image',
|
||||
// MSD status details
|
||||
msdStatus: 'Status',
|
||||
msdStandby: 'Idle',
|
||||
msdImageMode: 'Image Mode',
|
||||
@@ -941,7 +897,6 @@ export default {
|
||||
msdNoImage: 'None',
|
||||
},
|
||||
extensions: {
|
||||
// Common
|
||||
available: 'Available',
|
||||
unavailable: 'Unavailable',
|
||||
running: 'Running',
|
||||
@@ -958,7 +913,6 @@ export default {
|
||||
title: 'Remote Access',
|
||||
desc: 'GOSTC NAT traversal and Easytier networking',
|
||||
},
|
||||
// ttyd
|
||||
ttyd: {
|
||||
title: 'Ttyd Web Terminal',
|
||||
desc: 'Web terminal access via ttyd',
|
||||
@@ -967,7 +921,6 @@ export default {
|
||||
port: 'Port',
|
||||
shell: 'Shell',
|
||||
},
|
||||
// gostc
|
||||
gostc: {
|
||||
title: 'GOSTC NAT Traversal',
|
||||
desc: 'NAT traversal via GOSTC',
|
||||
@@ -976,7 +929,6 @@ export default {
|
||||
key: 'Client Key',
|
||||
tls: 'Enable TLS',
|
||||
},
|
||||
// easytier
|
||||
easytier: {
|
||||
title: 'Easytier Network',
|
||||
desc: 'P2P VPN networking via EasyTier',
|
||||
@@ -987,7 +939,6 @@ export default {
|
||||
virtualIp: 'Virtual IP',
|
||||
virtualIpHint: 'Leave empty for DHCP, or specify with CIDR (e.g., 10.0.0.1/24)',
|
||||
},
|
||||
// rustdesk
|
||||
rustdesk: {
|
||||
title: 'RustDesk Remote',
|
||||
desc: 'Remote access via RustDesk client',
|
||||
@@ -1078,31 +1029,24 @@ export default {
|
||||
p2p: 'P2P Direct',
|
||||
relay: 'TURN Relay',
|
||||
},
|
||||
// Help tooltip texts
|
||||
help: {
|
||||
// MSD related
|
||||
flashMode: 'Flash mode mounts the image as a USB drive, compatible with most BIOS boot',
|
||||
cdromMode: 'CDROM mode mounts the image as a CD drive, for systems requiring optical boot',
|
||||
readOnlyMode: 'Read-only mode is safer, the target system cannot modify the image',
|
||||
readWriteMode: 'Read-write mode allows writing data, useful for saving configurations',
|
||||
driveSize: 'Virtual drive size. Larger drives can store more files but take longer to initialize',
|
||||
// Video related
|
||||
mjpegMode: 'MJPEG mode has best compatibility, works with all browsers, but higher latency',
|
||||
webrtcMode: 'WebRTC mode has lower latency, but requires browser codec support',
|
||||
videoBitratePreset: 'Speed: lowest latency, best for slow networks. Balanced: good quality and latency. Quality: best visual, needs good bandwidth',
|
||||
encoderBackend: 'Hardware encoder has better performance and lower power. Software encoder has better compatibility',
|
||||
// HID related
|
||||
absoluteMode: 'Absolute mode maps mouse coordinates directly, suitable for most scenarios',
|
||||
relativeMode: 'Relative mode sends mouse movement delta, for games or special software',
|
||||
mouseThrottle: 'Send interval controls mouse event frequency. Higher values reduce network load',
|
||||
hidBackend: 'OTG backend requires USB OTG hardware support. CH9329 is a serial HID chip solution',
|
||||
// ATX related
|
||||
atxActiveLevel: 'Active level depends on your hardware wiring. High means high voltage when triggered',
|
||||
wolInterface: 'Network interface name for sending Wake-on-LAN magic packets, e.g., eth0 or br0',
|
||||
// Network related
|
||||
stunServer: 'STUN server for NAT traversal to establish P2P connections. Leave empty for public servers',
|
||||
turnServer: 'TURN server provides relay when P2P fails. Requires more bandwidth but more reliable',
|
||||
// Audio related
|
||||
audioQuality: 'Higher quality means better audio but requires more network bandwidth',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createI18n } from 'vue-i18n'
|
||||
import zhCN from './zh-CN'
|
||||
import enUS from './en-US'
|
||||
|
||||
// Supported languages
|
||||
export const supportedLanguages = [
|
||||
{ code: 'zh-CN', name: '中文', flag: '🇨🇳' },
|
||||
{ code: 'en-US', name: 'English', flag: '🇺🇸' },
|
||||
@@ -10,33 +9,26 @@ export const supportedLanguages = [
|
||||
|
||||
export type SupportedLocale = (typeof supportedLanguages)[number]['code']
|
||||
|
||||
// Detect browser language with improved logic
|
||||
function detectLanguage(): SupportedLocale {
|
||||
// 1. Check localStorage for saved preference
|
||||
const stored = localStorage.getItem('language')
|
||||
if (stored && supportedLanguages.some((l) => l.code === stored)) {
|
||||
return stored as SupportedLocale
|
||||
}
|
||||
|
||||
// 2. Check browser language list (navigator.languages is more comprehensive)
|
||||
const languages = navigator.languages || [navigator.language]
|
||||
for (const lang of languages) {
|
||||
const normalizedLang = lang.toLowerCase()
|
||||
// Check for Chinese variants (zh, zh-CN, zh-TW, zh-HK, etc.)
|
||||
if (normalizedLang.startsWith('zh')) {
|
||||
return 'zh-CN'
|
||||
}
|
||||
// Check for English variants
|
||||
if (normalizedLang.startsWith('en')) {
|
||||
return 'en-US'
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Default to English
|
||||
return 'en-US'
|
||||
}
|
||||
|
||||
// Initialize language and set HTML lang attribute
|
||||
function initializeLanguage(): SupportedLocale {
|
||||
const lang = detectLanguage()
|
||||
document.documentElement.setAttribute('lang', lang)
|
||||
|
||||
@@ -109,7 +109,6 @@ export default {
|
||||
settingsTip: '系统设置',
|
||||
fullscreen: '全屏',
|
||||
fullscreenTip: '切换全屏模式',
|
||||
// Video Config
|
||||
videoConfig: '视频配置',
|
||||
streamSettings: '流设置',
|
||||
deviceSettings: '设备配置',
|
||||
@@ -141,7 +140,6 @@ export default {
|
||||
notRecommended: '不推荐',
|
||||
multiSourceCodecLocked: '{sources} 已启用,当前编码已锁定',
|
||||
multiSourceVideoParamsWarning: '{sources} 已启用,修改视频设备和输入参数将导致流中断',
|
||||
// HID Config
|
||||
hidConfig: '鼠键配置',
|
||||
mouseSettings: '鼠标设置',
|
||||
hidDeviceSettings: 'HID 设备设置',
|
||||
@@ -154,7 +152,6 @@ export default {
|
||||
absolute: '绝对定位',
|
||||
relative: '相对定位',
|
||||
applying: '应用中...',
|
||||
// Audio Config
|
||||
audioConfig: '音频',
|
||||
playbackControl: '播放控制',
|
||||
volume: '音量',
|
||||
@@ -218,19 +215,16 @@ export default {
|
||||
title: '初始化设置',
|
||||
welcome: '欢迎使用 One-KVM',
|
||||
description: '请完成初始设置以开始使用',
|
||||
// Step titles
|
||||
stepAccount: '账号设置',
|
||||
stepVideo: '视频设置',
|
||||
stepAudioVideo: '音视频设置',
|
||||
stepHid: '鼠键设置',
|
||||
// Account
|
||||
setUsername: '设置管理员用户名',
|
||||
usernameHint: '用户名至少2个字符',
|
||||
setPassword: '设置管理员密码',
|
||||
passwordHint: '密码至少4个字符',
|
||||
confirmPassword: '确认密码',
|
||||
passwordMismatch: '两次输入的密码不一致',
|
||||
// Video
|
||||
videoDevice: '视频设备',
|
||||
selectVideoDevice: '选择视频采集设备',
|
||||
videoFormat: '画面格式',
|
||||
@@ -242,13 +236,11 @@ export default {
|
||||
noVideoDevices: '未检测到视频设备',
|
||||
noSignalDetected: '未检测到 HDMI 信号,请连接 HDMI 线缆后刷新。',
|
||||
refreshDevices: '刷新设备',
|
||||
// Audio
|
||||
audioDevice: '音频设备',
|
||||
selectAudioDevice: '选择音频采集设备',
|
||||
noAudio: '不使用音频',
|
||||
noAudioDevices: '未检测到音频设备',
|
||||
audioDeviceHelp: '选择用于捕获远程主机音频的设备。通常与视频采集卡在同一 USB 设备上。',
|
||||
// HID
|
||||
hidBackend: 'HID 后端',
|
||||
selectHidBackend: '选择 HID 控制方式',
|
||||
serialHid: '串口 HID',
|
||||
@@ -261,31 +253,25 @@ export default {
|
||||
selectUdc: '选择 UDC',
|
||||
noUdcDevices: '未检测到 UDC 设备',
|
||||
hidDisabledHint: '禁用 HID 后将无法控制远程主机的键盘和鼠标',
|
||||
// Complete
|
||||
complete: '完成设置',
|
||||
setupFailed: '设置失败',
|
||||
// Advanced encoder
|
||||
advancedEncoder: '高级选项:编码器后端',
|
||||
encoderHint: '默认的"自动"选项适用于大多数情况。仅在需要特定编码器后端时更改。',
|
||||
autoRecommended: '自动(推荐)',
|
||||
hardware: '硬件',
|
||||
software: '软件',
|
||||
// Progress
|
||||
progress: '步骤 {current} / {total}',
|
||||
// Help tooltips
|
||||
ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。',
|
||||
otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。',
|
||||
otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键盘。',
|
||||
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
|
||||
videoFormatHelp: 'MJPEG 格式兼容性最好,H.264/H.265 带宽占用更低但需要编码支持。',
|
||||
// Extensions
|
||||
stepExtensions: '扩展设置',
|
||||
extensionsDescription: '选择要自动启动的扩展服务',
|
||||
ttydTitle: 'Web 终端 (ttyd)',
|
||||
ttydDescription: '在浏览器中访问设备的命令行终端',
|
||||
extensionsHint: '这些设置可以在设置页面中随时更改',
|
||||
notInstalled: '未安装',
|
||||
// Password strength
|
||||
passwordStrength: '密码强度',
|
||||
passwordWeak: '弱',
|
||||
passwordMedium: '中',
|
||||
@@ -349,7 +335,6 @@ export default {
|
||||
uvc_capture_stall: '',
|
||||
},
|
||||
},
|
||||
// WebRTC
|
||||
webrtcConnected: 'WebRTC 已连接',
|
||||
webrtcConnectedDesc: '正在使用 H.264 低延迟视频流',
|
||||
webrtcFailed: 'WebRTC 连接失败',
|
||||
@@ -362,29 +347,23 @@ export default {
|
||||
webrtcPhaseSetRemote: '正在应用远端会话描述...',
|
||||
webrtcPhaseApplyIce: '正在应用 ICE 候选...',
|
||||
webrtcPhaseNegotiating: '正在协商安全连接...',
|
||||
// Pointer Lock
|
||||
pointerLocked: '鼠标已锁定',
|
||||
pointerLockedDesc: '按 Escape 键释放鼠标',
|
||||
pointerLockFailed: '鼠标锁定失败',
|
||||
relativeModeHint: '相对鼠标模式',
|
||||
relativeModeHintDesc: '点击视频区域以锁定鼠标,按 Escape 释放',
|
||||
// Meta Key Hint
|
||||
metaKeyHint: '检测到系统键',
|
||||
metaKeyHintDesc: '请进入全屏模式以捕获 Win/Meta 键',
|
||||
// Stream mode change
|
||||
streamModeChanged: '视频模式已切换',
|
||||
streamModeChangedDesc: '服务器已切换到 {mode} 模式',
|
||||
// 设备监控
|
||||
deviceLost: '视频设备丢失',
|
||||
deviceLostDesc: '{device}: {reason}',
|
||||
deviceRecovering: '视频设备恢复中',
|
||||
deviceRecoveringDesc: '正在尝试恢复视频设备(第 {attempt} 次)',
|
||||
deviceRecovered: '视频设备已恢复',
|
||||
deviceRecoveredDesc: '视频设备已成功重连',
|
||||
// 加载状态
|
||||
pleaseWait: '请稍候...',
|
||||
retryCount: '正在重试 (第 {count} 次)',
|
||||
// 错误详情
|
||||
errorDetails: '错误详情',
|
||||
},
|
||||
hid: {
|
||||
@@ -396,7 +375,6 @@ export default {
|
||||
pasteText: '粘贴文本',
|
||||
absoluteMouse: '绝对定位',
|
||||
relativeMouse: '相对定位',
|
||||
// 设备监控
|
||||
deviceLost: 'HID 设备丢失',
|
||||
deviceLostDesc: '{backend}: {reason}',
|
||||
reconnecting: 'HID 重连中',
|
||||
@@ -423,7 +401,6 @@ export default {
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
// 设备监控
|
||||
deviceLost: '音频设备丢失',
|
||||
deviceLostDesc: '{device}: {reason}',
|
||||
reconnecting: '音频重连中',
|
||||
@@ -467,7 +444,6 @@ export default {
|
||||
uploadImageHint: '点击上传 ISO/IMG 镜像',
|
||||
imageMounted: '镜像 {name} 已挂载',
|
||||
imageUnmounted: '镜像已卸载',
|
||||
// URL download
|
||||
downloadFromUrl: '从 URL 下载',
|
||||
downloadFromUrlDesc: '输入镜像文件的 URL 地址,支持 ISO/IMG 格式',
|
||||
url: 'URL 地址',
|
||||
@@ -478,16 +454,13 @@ export default {
|
||||
downloadFailed: '下载失败',
|
||||
largeFileWarning: '>2.2GB',
|
||||
largeFileTooltip: '文件大于 2.2GB,请使用 Flash 模式挂载',
|
||||
// 设备监控
|
||||
error: 'MSD 错误',
|
||||
errorDesc: '{reason}',
|
||||
recovered: 'MSD 已恢复',
|
||||
recoveredDesc: 'MSD 设备已恢复正常',
|
||||
// 操作状态
|
||||
operationInProgress: '操作进行中,请稍候',
|
||||
driveConnected: '虚拟U盘已连接',
|
||||
imageConnected: '镜像 {name} 已连接',
|
||||
// 驱动器初始化
|
||||
selectDriveSize: '选择虚拟驱动器大小',
|
||||
selectedSize: '选定大小',
|
||||
customSize: '自定义大小',
|
||||
@@ -519,7 +492,6 @@ export default {
|
||||
security: '安全',
|
||||
about: '关于',
|
||||
aboutDesc: '开放轻量的 IP-KVM 解决方案',
|
||||
// Device info
|
||||
deviceInfo: '设备信息',
|
||||
deviceInfoDesc: '主机系统信息',
|
||||
hostname: '主机名',
|
||||
@@ -544,11 +516,9 @@ export default {
|
||||
networkSettings: '网络设置',
|
||||
msdSettings: 'MSD 设置',
|
||||
atxSettings: 'ATX 设置',
|
||||
// Network tab
|
||||
httpSettings: 'HTTP 设置',
|
||||
httpPort: 'HTTP 端口',
|
||||
configureHttpPort: '配置 HTTP 服务器端口',
|
||||
// Web server
|
||||
webServer: '访问地址',
|
||||
webServerDesc: '配置 HTTP/HTTPS 端口和监听地址,修改后需要重启生效',
|
||||
httpsPort: 'HTTPS 端口',
|
||||
@@ -568,20 +538,17 @@ export default {
|
||||
bindAddressListEmpty: '请至少填写一个 IP 地址。',
|
||||
httpsEnabled: '启用 HTTPS',
|
||||
httpsEnabledDesc: '启用 HTTPS 加密连接(未指定证书将生成自签证书)',
|
||||
// Port config
|
||||
portConfig: '端口与协议',
|
||||
portConfigDesc: '服务一次只运行在一个端口上,由 HTTPS 开关决定使用哪个端口',
|
||||
httpPortReserved: 'HTTP 端口(备用)',
|
||||
httpsPortReserved: 'HTTPS 端口(备用)',
|
||||
previewUrl: '访问地址预览',
|
||||
// Listen address
|
||||
listenAddress: '监听地址',
|
||||
listenAddressDesc: '配置 Web 服务监听哪些网络接口',
|
||||
bindModeAllDesc: '0.0.0.0 — 监听所有网络接口',
|
||||
bindModeLocalDesc: '127.0.0.1 — 仅允许本机访问',
|
||||
bindModeCustomDesc: '指定一组 IP 地址',
|
||||
effectiveAddresses: '监听地址预览',
|
||||
// SSL certificate
|
||||
sslCertificate: 'SSL 证书',
|
||||
sslCertificateDesc: '上传自定义 PEM 证书替换自签名证书,修改后需要重启生效',
|
||||
sslCertCustom: '自定义证书',
|
||||
@@ -627,13 +594,11 @@ export default {
|
||||
updateMsgVerifying: '校验中(SHA256)',
|
||||
updateMsgInstalling: '替换程序中',
|
||||
updateMsgRestarting: '服务重启中',
|
||||
// Auth
|
||||
auth: '访问控制',
|
||||
authSettings: '访问设置',
|
||||
authSettingsDesc: '单用户访问与会话策略',
|
||||
allowMultipleSessions: '允许多个 Web 会话',
|
||||
allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话。',
|
||||
// User management
|
||||
userManagement: '用户管理',
|
||||
userManagementDesc: '管理用户账号和权限',
|
||||
addUser: '添加用户',
|
||||
@@ -648,7 +613,6 @@ export default {
|
||||
noUsers: '暂无用户',
|
||||
create: '创建',
|
||||
confirmDeleteUser: '确定要删除用户 "{name}" 吗?',
|
||||
// MSD/ATX status
|
||||
msdStatus: 'MSD 状态',
|
||||
atxStatus: 'ATX 状态',
|
||||
available: '可用',
|
||||
@@ -664,7 +628,6 @@ export default {
|
||||
disabled: '已禁用',
|
||||
msdDesc: '虚拟存储设备允许您将 ISO 镜像和虚拟驱动器挂载到目标机器。请在主页面的 MSD 面板中管理镜像。',
|
||||
atxDesc: 'ATX 电源控制允许您远程开关机和重启目标机器。请在主页面的 ATX 面板中控制电源。',
|
||||
// ATX configuration
|
||||
atxSettingsDesc: '配置 ATX 电源控制硬件绑定',
|
||||
atxEnable: '启用 ATX 控制',
|
||||
atxEnableDesc: '启用后可以远程控制电源和重启按钮',
|
||||
@@ -692,16 +655,13 @@ export default {
|
||||
atxLedPin: 'GPIO 引脚',
|
||||
atxLedInverted: '反转逻辑',
|
||||
atxLedInvertedDesc: 'LED 亮起时 GPIO 为低电平',
|
||||
// WOL configuration
|
||||
atxWolSettings: '网络唤醒设置',
|
||||
atxWolSettingsDesc: '配置 Wake-on-LAN 魔术包发送选项',
|
||||
atxWolInterface: '网络接口',
|
||||
atxWolInterfacePlaceholder: '例如: eth0, enp0s3',
|
||||
atxWolInterfaceHint: '指定发送 WOL 包的网络接口,留空则使用系统默认路由',
|
||||
// Basic tab descriptions
|
||||
themeDesc: '选择您喜欢的颜色方案',
|
||||
languageDesc: '选择您的首选语言',
|
||||
// Video tab
|
||||
videoSettings: '视频设置',
|
||||
videoSettingsDesc: '配置视频采集设备',
|
||||
videoDevice: '视频设备',
|
||||
@@ -718,7 +678,6 @@ export default {
|
||||
software: '软件',
|
||||
supportedFormats: '支持的格式',
|
||||
encoderHint: '硬件编码器性能更好,CPU 占用更低。软件编码器兼容性更好,但需要更多 CPU 资源。',
|
||||
// HID tab
|
||||
hidSettings: 'HID 设置',
|
||||
hidSettingsDesc: '配置键盘和鼠标控制',
|
||||
hidBackend: 'HID 后端',
|
||||
@@ -747,7 +706,6 @@ export default {
|
||||
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接',
|
||||
otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键盘。',
|
||||
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
|
||||
// OTG Descriptor
|
||||
otgDescriptor: 'USB 设备描述符',
|
||||
otgDescriptorDesc: '配置 USB 设备标识信息',
|
||||
vendorId: '厂商 ID (VID)',
|
||||
@@ -861,7 +819,6 @@ export default {
|
||||
resetConfirmDesc: '将通过 authorized 属性复位 USB 设备「{device}」,该设备上的所有连接将短暂中断。确定继续?',
|
||||
resetAction: '确认复位',
|
||||
},
|
||||
// WebRTC / ICE
|
||||
webrtcSettings: 'WebRTC 设置',
|
||||
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透',
|
||||
publicIceServersHint: '留空将使用 Google 公共 STUN 服务器,TURN 服务器需自行配置',
|
||||
@@ -930,7 +887,6 @@ export default {
|
||||
notConnected: '未连接',
|
||||
connected: '已连接',
|
||||
image: '镜像',
|
||||
// MSD 状态详情
|
||||
msdStatus: '状态',
|
||||
msdStandby: '空闲',
|
||||
msdImageMode: '镜像模式',
|
||||
@@ -940,7 +896,6 @@ export default {
|
||||
msdNoImage: '无',
|
||||
},
|
||||
extensions: {
|
||||
// Common
|
||||
available: '可用',
|
||||
unavailable: '不可用',
|
||||
running: '运行中',
|
||||
@@ -957,7 +912,6 @@ export default {
|
||||
title: '远程访问',
|
||||
desc: 'GOSTC 内网穿透与 Easytier 组网',
|
||||
},
|
||||
// ttyd
|
||||
ttyd: {
|
||||
title: 'Ttyd 网页终端',
|
||||
desc: '通过 ttyd 提供网页终端访问',
|
||||
@@ -966,7 +920,6 @@ export default {
|
||||
port: '端口',
|
||||
shell: 'Shell',
|
||||
},
|
||||
// gostc
|
||||
gostc: {
|
||||
title: 'GOSTC 内网穿透',
|
||||
desc: '通过 GOSTC 实现内网穿透',
|
||||
@@ -975,7 +928,6 @@ export default {
|
||||
key: '客户端密钥',
|
||||
tls: '启用 TLS',
|
||||
},
|
||||
// easytier
|
||||
easytier: {
|
||||
title: 'Easytier 组网',
|
||||
desc: '通过 EasyTier 实现 P2P VPN 组网',
|
||||
@@ -986,7 +938,6 @@ export default {
|
||||
virtualIp: '虚拟 IP',
|
||||
virtualIpHint: '留空则自动分配,手动指定需包含网段(如 10.0.0.1/24)',
|
||||
},
|
||||
// rustdesk
|
||||
rustdesk: {
|
||||
title: 'RustDesk 远程',
|
||||
desc: '使用 RustDesk 客户端进行远程访问',
|
||||
@@ -1077,31 +1028,24 @@ export default {
|
||||
p2p: 'P2P 直连',
|
||||
relay: 'TURN 中继',
|
||||
},
|
||||
// 帮助提示文本
|
||||
help: {
|
||||
// MSD 相关
|
||||
flashMode: 'Flash 模式将镜像作为 U 盘挂载,支持大多数 BIOS 启动',
|
||||
cdromMode: 'CDROM 模式将镜像作为光驱挂载,适用于需要光盘启动的系统',
|
||||
readOnlyMode: '只读模式更安全,目标系统无法修改镜像内容',
|
||||
readWriteMode: '读写模式允许目标系统写入数据,适用于需要保存配置的场景',
|
||||
driveSize: '虚拟驱动器大小。较大的驱动器支持存放更多文件,但初始化时间更长',
|
||||
// 视频相关
|
||||
mjpegMode: 'MJPEG 模式兼容性最好,适用于所有浏览器,但延迟较高',
|
||||
webrtcMode: 'WebRTC 模式延迟更低,但需要浏览器支持相应编解码器',
|
||||
videoBitratePreset: '速度优先:最低延迟,适合网络较差的场景;均衡:画质和延迟平衡;质量优先:最佳画质,需要较好的网络带宽',
|
||||
encoderBackend: '硬件编码器性能更好功耗更低,软件编码器兼容性更好',
|
||||
// HID 相关
|
||||
absoluteMode: '绝对定位模式直接映射鼠标坐标,适用于大多数场景',
|
||||
relativeMode: '相对定位模式发送鼠标移动增量,适用于游戏或特殊软件',
|
||||
mouseThrottle: '发送间隔控制鼠标事件的发送频率,较大的值可减少网络负载',
|
||||
hidBackend: 'OTG 后端需要硬件支持 USB OTG,CH9329 是串口 HID 芯片方案',
|
||||
// ATX 相关
|
||||
atxActiveLevel: '活跃电平取决于您的硬件接线方式。高电平表示触发时输出高电压,低电平相反',
|
||||
wolInterface: '用于发送 Wake-on-LAN 魔术包的网络接口名称,如 eth0 或 br0',
|
||||
// 网络相关
|
||||
stunServer: 'STUN 服务器用于 NAT 穿透,帮助建立 P2P 连接。留空使用公共服务器',
|
||||
turnServer: 'TURN 服务器在 P2P 连接失败时提供中继。需要更多带宽但连接更可靠',
|
||||
// 音频相关
|
||||
audioQuality: '更高的质量意味着更好的音频效果,但需要更多的网络带宽',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Character to HID usage mapping for text paste functionality.
|
||||
// The table follows US QWERTY layout semantics.
|
||||
|
||||
import { type CanonicalKey } from '@/types/generated'
|
||||
import { keys } from '@/lib/keyboardMappings'
|
||||
@@ -10,7 +8,6 @@ export interface CharKeyMapping {
|
||||
}
|
||||
|
||||
const charToKeyMap: Record<string, CharKeyMapping> = {
|
||||
// Lowercase letters
|
||||
a: { key: keys.KeyA, shift: false },
|
||||
b: { key: keys.KeyB, shift: false },
|
||||
c: { key: keys.KeyC, shift: false },
|
||||
@@ -38,7 +35,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
|
||||
y: { key: keys.KeyY, shift: false },
|
||||
z: { key: keys.KeyZ, shift: false },
|
||||
|
||||
// Uppercase letters
|
||||
A: { key: keys.KeyA, shift: true },
|
||||
B: { key: keys.KeyB, shift: true },
|
||||
C: { key: keys.KeyC, shift: true },
|
||||
@@ -66,7 +62,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
|
||||
Y: { key: keys.KeyY, shift: true },
|
||||
Z: { key: keys.KeyZ, shift: true },
|
||||
|
||||
// Number row
|
||||
'0': { key: keys.Digit0, shift: false },
|
||||
'1': { key: keys.Digit1, shift: false },
|
||||
'2': { key: keys.Digit2, shift: false },
|
||||
@@ -78,7 +73,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
|
||||
'8': { key: keys.Digit8, shift: false },
|
||||
'9': { key: keys.Digit9, shift: false },
|
||||
|
||||
// Shifted number row symbols
|
||||
')': { key: keys.Digit0, shift: true },
|
||||
'!': { key: keys.Digit1, shift: true },
|
||||
'@': { key: keys.Digit2, shift: true },
|
||||
@@ -90,7 +84,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
|
||||
'*': { key: keys.Digit8, shift: true },
|
||||
'(': { key: keys.Digit9, shift: true },
|
||||
|
||||
// Punctuation and symbols
|
||||
'-': { key: keys.Minus, shift: false },
|
||||
'=': { key: keys.Equal, shift: false },
|
||||
'[': { key: keys.BracketLeft, shift: false },
|
||||
@@ -103,7 +96,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
|
||||
'.': { key: keys.Period, shift: false },
|
||||
'/': { key: keys.Slash, shift: false },
|
||||
|
||||
// Shifted punctuation and symbols
|
||||
_: { key: keys.Minus, shift: true },
|
||||
'+': { key: keys.Equal, shift: true },
|
||||
'{': { key: keys.BracketLeft, shift: true },
|
||||
@@ -116,7 +108,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
|
||||
'>': { key: keys.Period, shift: true },
|
||||
'?': { key: keys.Slash, shift: true },
|
||||
|
||||
// Whitespace and control
|
||||
' ': { key: keys.Space, shift: false },
|
||||
'\t': { key: keys.Tab, shift: false },
|
||||
'\n': { key: keys.Enter, shift: false },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Virtual keyboard layout data shared by the on-screen keyboard.
|
||||
|
||||
export type KeyboardOsType = 'windows' | 'mac' | 'android'
|
||||
|
||||
|
||||
@@ -122,7 +122,6 @@ export const keys = {
|
||||
|
||||
export type KeyName = keyof typeof keys
|
||||
|
||||
// Consumer Control Usage codes (for multimedia keys)
|
||||
export const consumerKeys = {
|
||||
PlayPause: 0x00cd,
|
||||
Stop: 0x00b7,
|
||||
|
||||
@@ -15,8 +15,6 @@ export function generateUUID(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
// Fallback: generate UUID v4 manually
|
||||
// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
|
||||
@@ -42,17 +42,14 @@ function t(key: string, params?: Record<string, unknown>): string {
|
||||
return String(i18n.global.t(key, params as any))
|
||||
}
|
||||
|
||||
// Navigation guard
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Prevent access to setup after initialization
|
||||
const shouldCheckSetup = to.name === 'Setup' || !authStore.initialized
|
||||
if (shouldCheckSetup) {
|
||||
try {
|
||||
await authStore.checkSetupStatus()
|
||||
} catch {
|
||||
// Continue anyway
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,20 +62,17 @@ router.beforeEach(async (to, _from, next) => {
|
||||
try {
|
||||
await authStore.checkAuth()
|
||||
} catch {
|
||||
// Not authenticated
|
||||
}
|
||||
}
|
||||
|
||||
return next({ name: authStore.isAuthenticated ? 'Console' : 'Login' })
|
||||
}
|
||||
|
||||
// Check authentication for protected routes
|
||||
if (to.meta.requiresAuth !== false) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
try {
|
||||
await authStore.checkAuth()
|
||||
} catch (e) {
|
||||
// Not authenticated
|
||||
if (e instanceof ApiError && e.status === 401 && !sessionExpiredNotified) {
|
||||
const normalized = e.message.toLowerCase()
|
||||
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')
|
||||
|
||||
@@ -311,43 +311,26 @@ export interface WebConfig {
|
||||
ssl_key_path?: string;
|
||||
}
|
||||
|
||||
/** ttyd configuration (Web Terminal) */
|
||||
export interface TtydConfig {
|
||||
/** Enable auto-start */
|
||||
enabled: boolean;
|
||||
/** Port to listen on */
|
||||
port: number;
|
||||
/** Shell to execute */
|
||||
shell: string;
|
||||
}
|
||||
|
||||
/** gostc configuration (NAT traversal based on FRP) */
|
||||
export interface GostcConfig {
|
||||
/** Enable auto-start */
|
||||
enabled: boolean;
|
||||
/** Server address (hostname or IP) */
|
||||
addr: string;
|
||||
/** Client key from GOSTC management panel */
|
||||
key: string;
|
||||
/** Enable TLS */
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
/** EasyTier configuration (P2P VPN) */
|
||||
export interface EasytierConfig {
|
||||
/** Enable auto-start */
|
||||
enabled: boolean;
|
||||
/** Network name */
|
||||
network_name: string;
|
||||
/** Network secret/password */
|
||||
network_secret: string;
|
||||
/** Peer node URLs */
|
||||
peer_urls: string[];
|
||||
/** Virtual IP address (optional, auto-assigned if not set) */
|
||||
virtual_ip?: string;
|
||||
}
|
||||
|
||||
/** Combined extensions configuration */
|
||||
export interface ExtensionsConfig {
|
||||
ttyd: TtydConfig;
|
||||
gostc: GostcConfig;
|
||||
@@ -483,78 +466,50 @@ export interface EasytierConfigUpdate {
|
||||
virtual_ip?: string;
|
||||
}
|
||||
|
||||
/** Extension running status */
|
||||
export type ExtensionStatus =
|
||||
/** Binary not found at expected path */
|
||||
| { state: "unavailable", data?: undefined }
|
||||
/** Extension is stopped */
|
||||
| { state: "stopped", data?: undefined }
|
||||
/** Extension is running */
|
||||
| { state: "running", data: {
|
||||
/** Process ID */
|
||||
pid: number;
|
||||
}}
|
||||
/** Extension failed to start */
|
||||
| { state: "failed", data: {
|
||||
/** Error message */
|
||||
error: string;
|
||||
}};
|
||||
|
||||
/** easytier extension info */
|
||||
export interface EasytierInfo {
|
||||
/** Whether binary exists */
|
||||
available: boolean;
|
||||
/** Current status */
|
||||
status: ExtensionStatus;
|
||||
/** Configuration */
|
||||
config: EasytierConfig;
|
||||
}
|
||||
|
||||
/** Extension info with status and config */
|
||||
export interface ExtensionInfo {
|
||||
/** Whether binary exists */
|
||||
available: boolean;
|
||||
/** Current status */
|
||||
status: ExtensionStatus;
|
||||
}
|
||||
|
||||
/** Extension identifier (fixed set of supported extensions) */
|
||||
export enum ExtensionId {
|
||||
/** Web terminal (ttyd) */
|
||||
Ttyd = "ttyd",
|
||||
/** NAT traversal client (gostc) */
|
||||
Gostc = "gostc",
|
||||
/** P2P VPN (easytier) */
|
||||
Easytier = "easytier",
|
||||
}
|
||||
|
||||
/** Extension logs response */
|
||||
export interface ExtensionLogs {
|
||||
id: ExtensionId;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
/** ttyd extension info */
|
||||
export interface TtydInfo {
|
||||
/** Whether binary exists */
|
||||
available: boolean;
|
||||
/** Current status */
|
||||
status: ExtensionStatus;
|
||||
/** Configuration */
|
||||
config: TtydConfig;
|
||||
}
|
||||
|
||||
/** gostc extension info */
|
||||
export interface GostcInfo {
|
||||
/** Whether binary exists */
|
||||
available: boolean;
|
||||
/** Current status */
|
||||
status: ExtensionStatus;
|
||||
/** Configuration */
|
||||
config: GostcConfig;
|
||||
}
|
||||
|
||||
/** All extensions status response */
|
||||
export interface ExtensionsStatus {
|
||||
ttyd: TtydInfo;
|
||||
gostc: GostcInfo;
|
||||
@@ -677,7 +632,6 @@ export interface StreamConfigUpdate {
|
||||
/** Update ttyd config */
|
||||
export interface TtydConfigUpdate {
|
||||
enabled?: boolean;
|
||||
port?: number;
|
||||
shell?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Shared WebSocket types and utilities
|
||||
// Used by useWebSocket, useHidWebSocket, and useAudioPlayer
|
||||
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
/** WebSocket connection state */
|
||||
|
||||
@@ -20,7 +20,6 @@ import { generateUUID } from '@/lib/utils'
|
||||
import { formatFpsValue } from '@/lib/fps'
|
||||
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
|
||||
|
||||
// Components
|
||||
import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue'
|
||||
import ActionBar from '@/components/ActionBar.vue'
|
||||
import InfoBar from '@/components/InfoBar.vue'
|
||||
@@ -84,10 +83,8 @@ const consoleEvents = useConsoleEvents({
|
||||
onDeviceInfo: handleDeviceInfo,
|
||||
})
|
||||
|
||||
// Video mode state
|
||||
const videoMode = ref<VideoMode>('mjpeg')
|
||||
|
||||
// Video state
|
||||
const videoRef = ref<HTMLImageElement | null>(null)
|
||||
const webrtcVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const videoContainerRef = ref<HTMLDivElement | null>(null)
|
||||
@@ -104,7 +101,6 @@ const streamSignalState = ref<StreamSignalState>('ok')
|
||||
const streamSignalReason = ref<string | null>(null)
|
||||
const streamNextRetryMs = ref<number | null>(null)
|
||||
|
||||
// Video aspect ratio (dynamically updated from actual video dimensions)
|
||||
// Using string format "width/height" to let browser handle the ratio calculation
|
||||
const videoAspectRatio = ref<string | null>(null)
|
||||
|
||||
@@ -123,7 +119,6 @@ const clientsStats = ref<Record<string, ClientStat>>({})
|
||||
// This allows us to identify our own stats in the clients_stat map
|
||||
const myClientId = generateUUID()
|
||||
|
||||
// HID state
|
||||
const mouseMode = ref<'absolute' | 'relative'>('absolute')
|
||||
const pressedKeys = ref<CanonicalKey[]>([])
|
||||
const keyboardLed = computed(() => ({
|
||||
@@ -140,7 +135,6 @@ const isPointerLocked = ref(false) // Track pointer lock state
|
||||
/** Local overlay crosshair position (px, relative to video container); HID uses mousePosition separately */
|
||||
const localCrosshairPos = ref<{ x: number; y: number } | null>(null)
|
||||
|
||||
// Mouse move throttling (60 Hz = ~16.67ms interval)
|
||||
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
|
||||
let mouseMoveSendIntervalMs = DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
|
||||
let mouseFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -148,7 +142,6 @@ let lastMouseMoveSendTime = 0
|
||||
let pendingMouseMove: { type: 'move' | 'move_abs'; x: number; y: number } | null = null
|
||||
let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas between sends
|
||||
|
||||
// Cursor visibility (from localStorage, updated via storage event)
|
||||
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
|
||||
let interactionListenersBound = false
|
||||
const isConsoleActive = ref(false)
|
||||
@@ -162,7 +155,6 @@ function syncMouseModeFromConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Virtual keyboard state
|
||||
const virtualKeyboardVisible = ref(false)
|
||||
const virtualKeyboardAttached = ref(true)
|
||||
const statsSheetOpen = ref(false)
|
||||
@@ -173,18 +165,15 @@ const virtualKeyboardConsumerEnabled = computed(() => {
|
||||
return hid.otg_functions?.consumer !== false
|
||||
})
|
||||
|
||||
// Change password dialog state
|
||||
const changePasswordDialogOpen = ref(false)
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const changingPassword = ref(false)
|
||||
|
||||
// ttyd (web terminal) state
|
||||
const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
|
||||
const showTerminalDialog = ref(false)
|
||||
|
||||
// Theme
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
// Status computed (Device status removed - now only Video, Audio, HID, MSD)
|
||||
@@ -207,9 +196,7 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
|
||||
return 'disconnected'
|
||||
})
|
||||
|
||||
// Convert resolution to short format (e.g., 720p, 1080p, 2K, 4K)
|
||||
function getResolutionShortName(width: number, height: number): string {
|
||||
// Common resolution mappings based on height
|
||||
if (height === 2160 || (height === 2160 && width === 4096)) return '4K'
|
||||
if (height === 1440) return '2K'
|
||||
if (height === 1080) return '1080p'
|
||||
@@ -218,11 +205,9 @@ function getResolutionShortName(width: number, height: number): string {
|
||||
if (height === 600) return '600p'
|
||||
if (height === 1024 && width === 1280) return '1024p'
|
||||
if (height === 960) return '960p'
|
||||
// Fallback: use height + 'p'
|
||||
return `${height}p`
|
||||
}
|
||||
|
||||
// Quick info for status card trigger
|
||||
const videoQuickInfo = computed(() => {
|
||||
const stream = systemStore.stream
|
||||
if (!stream?.resolution) return ''
|
||||
@@ -235,12 +220,10 @@ const videoDetails = computed<StatusDetail[]>(() => {
|
||||
if (!stream) return []
|
||||
const receivedFps = backendFps.value
|
||||
|
||||
// Input (capture) format → output (delivery) mode
|
||||
const inputFmt = stream.format || 'MJPEG'
|
||||
const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
|
||||
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}`
|
||||
|
||||
// Target / actual FPS combined
|
||||
const fpsDisplay = `${formatFpsValue(stream.targetFps ?? 0)} / ${formatFpsValue(receivedFps)}`
|
||||
const fpsStatus: StatusDetail['status'] = receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined
|
||||
|
||||
@@ -269,7 +252,6 @@ const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'
|
||||
}
|
||||
|
||||
// MJPEG mode or WebRTC fallback: check WebSocket HID status
|
||||
// If HID WebSocket has network error, show connecting (yellow)
|
||||
if (hidWs.networkError.value) return 'connecting'
|
||||
|
||||
// If HID WebSocket is not connected (disconnected without error), show disconnected
|
||||
@@ -278,17 +260,14 @@ const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'
|
||||
// If HID backend is unavailable (business error), show disconnected (gray)
|
||||
if (hidWs.hidUnavailable.value) return 'disconnected'
|
||||
|
||||
// Normal status based on system state
|
||||
if (hid?.available && hid.online) return 'connected'
|
||||
if (hid?.available && hid.initialized) return 'connecting'
|
||||
return 'disconnected'
|
||||
})
|
||||
|
||||
// Quick info for HID status card trigger
|
||||
const hidQuickInfo = computed(() => {
|
||||
const hid = systemStore.hid
|
||||
if (!hid?.available) return ''
|
||||
// Show current mode, not hardware capability
|
||||
return mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative')
|
||||
})
|
||||
|
||||
@@ -387,7 +366,6 @@ const hidDetails = computed<StatusDetail[]>(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Channel (merged with availability / connection state)
|
||||
let channelValue: string
|
||||
let channelStatus: StatusDetail['status']
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
@@ -422,7 +400,6 @@ const hidDetails = computed<StatusDetail[]>(() => {
|
||||
return details
|
||||
})
|
||||
|
||||
// Audio status computed
|
||||
const audioStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||
const audio = systemStore.audio
|
||||
if (!audio?.available) return 'disconnected'
|
||||
@@ -431,7 +408,6 @@ const audioStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
|
||||
return 'disconnected'
|
||||
})
|
||||
|
||||
// Helper function to translate audio quality
|
||||
function translateAudioQuality(quality: string | undefined): string {
|
||||
if (!quality) return t('common.unknown')
|
||||
const qualityLower = quality.toLowerCase()
|
||||
@@ -463,7 +439,6 @@ const audioDetails = computed<StatusDetail[]>(() => {
|
||||
]
|
||||
})
|
||||
|
||||
// MSD status computed
|
||||
const msdStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||
const msd = systemStore.msd
|
||||
if (!msd?.available) return 'disconnected'
|
||||
@@ -568,7 +543,6 @@ const hidHoverAlign = computed<'start' | 'end'>(() => {
|
||||
return showMsdStatusCard.value ? 'start' : 'end'
|
||||
})
|
||||
|
||||
// Video handling
|
||||
let retryTimeoutId: number | null = null
|
||||
let retryCount = 0
|
||||
let gracePeriodTimeoutId: number | null = null
|
||||
@@ -618,10 +592,8 @@ async function captureFrameOverlay() {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
// Use JPEG to keep memory reasonable
|
||||
frameOverlayUrl.value = canvas.toDataURL('image/jpeg', 0.7)
|
||||
} catch {
|
||||
// Best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,7 +701,6 @@ function handleVideoLoad() {
|
||||
gracePeriodTimeoutId = null
|
||||
}
|
||||
|
||||
// Reset all error states
|
||||
videoLoading.value = false
|
||||
videoError.value = false
|
||||
videoErrorMessage.value = ''
|
||||
@@ -738,7 +709,6 @@ function handleVideoLoad() {
|
||||
consecutiveErrors = 0
|
||||
clearFrameOverlay()
|
||||
|
||||
// Auto-focus video container for immediate keyboard input
|
||||
const container = videoContainerRef.value
|
||||
if (container && typeof container.focus === 'function') {
|
||||
container.focus()
|
||||
@@ -787,13 +757,11 @@ function handleVideoError() {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any pending retries to avoid duplicate attempts
|
||||
if (retryTimeoutId !== null) {
|
||||
clearTimeout(retryTimeoutId)
|
||||
retryTimeoutId = null
|
||||
}
|
||||
|
||||
// Show loading state immediately
|
||||
videoLoading.value = true
|
||||
mjpegFrameReceived.value = false
|
||||
|
||||
@@ -821,7 +789,6 @@ function handleStreamDeviceLost(data: { device: string; reason: string }) {
|
||||
}
|
||||
|
||||
function scheduleWebRTCRecovery() {
|
||||
// Clear any previous timer
|
||||
if (webrtcRecoveryTimerId !== null) {
|
||||
clearTimeout(webrtcRecoveryTimerId)
|
||||
webrtcRecoveryTimerId = null
|
||||
@@ -862,7 +829,6 @@ function scheduleWebRTCRecovery() {
|
||||
videoErrorMessage.value = ''
|
||||
webrtcRecoveryAttempts = 0
|
||||
} else {
|
||||
// Retry
|
||||
scheduleWebRTCRecovery()
|
||||
}
|
||||
} catch {
|
||||
@@ -883,21 +849,17 @@ function handleStreamRecovered(_data: { device: string }) {
|
||||
// Cancel any pending recovery timer – backend is back
|
||||
cancelWebRTCRecovery()
|
||||
|
||||
// Reset video error state
|
||||
videoError.value = false
|
||||
videoErrorMessage.value = ''
|
||||
// Refresh video stream
|
||||
refreshVideo()
|
||||
}
|
||||
|
||||
async function handleAudioStateChanged(data: { streaming: boolean; device: string | null }) {
|
||||
if (!data.streaming) {
|
||||
// Audio stopped, disconnect
|
||||
unifiedAudio.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
// Audio started streaming
|
||||
if (videoMode.value !== 'mjpeg' && webrtc.isConnected.value) {
|
||||
// WebRTC mode: check if we have an audio track
|
||||
if (!webrtc.audioTrack.value) {
|
||||
@@ -907,9 +869,7 @@ async function handleAudioStateChanged(data: { streaming: boolean; device: strin
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
await connectWebRTCSerial('audio track refresh')
|
||||
// After reconnect, the new session will have audio track
|
||||
// and the watch on audioTrack will add it to MediaStream
|
||||
} else {
|
||||
// We have audio track, ensure it's in MediaStream
|
||||
const currentStream = webrtcVideoRef.value?.srcObject as MediaStream | null
|
||||
if (currentStream && currentStream.getAudioTracks().length === 0) {
|
||||
currentStream.addTrack(webrtc.audioTrack.value)
|
||||
@@ -934,7 +894,6 @@ function handleStreamConfigChanging(data: any) {
|
||||
gracePeriodTimeoutId = null
|
||||
}
|
||||
|
||||
// Reset all counters and states
|
||||
videoRestarting.value = true
|
||||
pendingWebRTCReadyGate = true
|
||||
videoLoading.value = true
|
||||
@@ -952,7 +911,6 @@ function handleStreamConfigChanging(data: any) {
|
||||
}
|
||||
|
||||
async function handleStreamConfigApplied(data: any) {
|
||||
// Reset consecutive error counter for new config
|
||||
consecutiveErrors = 0
|
||||
|
||||
// Start grace period to ignore transient errors
|
||||
@@ -961,7 +919,6 @@ async function handleStreamConfigApplied(data: any) {
|
||||
consecutiveErrors = 0 // Also reset when grace period ends
|
||||
}, GRACE_PERIOD)
|
||||
|
||||
// Refresh video based on current mode
|
||||
videoRestarting.value = true
|
||||
|
||||
// 如果正在进行模式切换,不需要在这里处理(WebRTCReady 事件会处理)
|
||||
@@ -1003,7 +960,6 @@ function handleStreamModeReady(data: { transition_id: string; mode: string }) {
|
||||
}
|
||||
|
||||
function handleStreamModeSwitching(data: { transition_id: string; to_mode: string; from_mode: string }) {
|
||||
// External mode switches: keep UI responsive and avoid black flash
|
||||
if (!isModeSwitching.value) {
|
||||
videoRestarting.value = true
|
||||
videoLoading.value = true
|
||||
@@ -1230,13 +1186,11 @@ function handleDeviceInfo(data: any) {
|
||||
})
|
||||
}
|
||||
|
||||
// Skip mode sync if video config is being changed
|
||||
// This prevents false-positive mode changes during config switching
|
||||
if (data.video?.config_changing) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sync video mode from server's stream_mode
|
||||
if (data.video?.stream_mode) {
|
||||
const serverMode = normalizeServerMode(data.video.stream_mode)
|
||||
if (!serverMode) return
|
||||
@@ -1256,7 +1210,6 @@ function handleDeviceInfo(data: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stream mode change event from server (WebSocket broadcast)
|
||||
function handleStreamModeChanged(data: { mode: string; previous_mode: string }) {
|
||||
const newMode = normalizeServerMode(data.mode)
|
||||
if (!newMode) return
|
||||
@@ -1267,7 +1220,6 @@ function handleStreamModeChanged(data: { mode: string; previous_mode: string })
|
||||
return
|
||||
}
|
||||
|
||||
// Show toast notification only if this is an external mode change
|
||||
toast.info(t('console.streamModeChanged'), {
|
||||
description: t('console.streamModeChangedDesc', { mode: data.mode.toUpperCase() }),
|
||||
duration: 5000,
|
||||
@@ -1300,7 +1252,6 @@ function refreshVideo() {
|
||||
mjpegTimestamp.value = Date.now()
|
||||
|
||||
// For MJPEG streams, the 'load' event fires when first frame arrives
|
||||
// But on reconnection it may not fire again, so use a timeout as fallback
|
||||
setTimeout(() => {
|
||||
isRefreshingVideo = false
|
||||
// Clear loading state after timeout - if stream failed, error handler will show error
|
||||
@@ -1508,7 +1459,6 @@ async function switchToMJPEG() {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to switch to MJPEG mode:', e)
|
||||
// Continue anyway - the mode might already be correct
|
||||
}
|
||||
|
||||
// Step 2: Disconnect WebRTC if connected or session still exists
|
||||
@@ -1542,7 +1492,6 @@ function syncToServerMode(mode: VideoMode) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle video mode change
|
||||
async function handleVideoModeChange(mode: VideoMode) {
|
||||
// 防止重复切换和竞态条件
|
||||
if (mode === videoMode.value) return
|
||||
@@ -1592,10 +1541,8 @@ watch(() => webrtc.videoTrack.value, async (track) => {
|
||||
// Watch for WebRTC audio track changes - update MediaStream when audio arrives
|
||||
watch(() => webrtc.audioTrack.value, async (track) => {
|
||||
if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
|
||||
// Audio track arrived, update the MediaStream to include it
|
||||
const currentStream = webrtcVideoRef.value.srcObject as MediaStream | null
|
||||
if (currentStream && currentStream.getAudioTracks().length === 0) {
|
||||
// Add audio track to existing stream
|
||||
currentStream.addTrack(track)
|
||||
}
|
||||
}
|
||||
@@ -1607,7 +1554,6 @@ watch(webrtcVideoRef, (el) => {
|
||||
}, { immediate: true })
|
||||
|
||||
// Watch for WebRTC stats to update FPS display
|
||||
// Watch the ref directly with deep: true to detect property changes
|
||||
watch(webrtc.stats, (stats) => {
|
||||
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
|
||||
backendFps.value = Math.round(stats.framesPerSecond)
|
||||
@@ -1626,7 +1572,6 @@ let webrtcReconnectFailures = 0
|
||||
watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
console.log('[WebRTC] State changed:', oldState, '->', newState)
|
||||
|
||||
// Clear any pending reconnect
|
||||
if (webrtcReconnectTimeout) {
|
||||
clearTimeout(webrtcReconnectTimeout)
|
||||
webrtcReconnectTimeout = null
|
||||
@@ -1644,7 +1589,6 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
})
|
||||
}
|
||||
} else if (newState === 'disconnected' || newState === 'failed') {
|
||||
// Don't immediately set offline - wait for potential reconnect
|
||||
// The device_info event will eventually sync the correct state
|
||||
}
|
||||
}
|
||||
@@ -1653,7 +1597,6 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-reconnect when disconnected (but was previously connected)
|
||||
if (newState === 'disconnected' && oldState === 'connected' && videoMode.value !== 'mjpeg') {
|
||||
webrtcReconnectTimeout = setTimeout(async () => {
|
||||
if (videoMode.value !== 'mjpeg' && webrtc.state.value === 'disconnected') {
|
||||
@@ -1676,8 +1619,6 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
}
|
||||
|
||||
// Handle direct 'failed' state (ICE or DTLS failure)
|
||||
// Allow one automatic retry before marking as failed, consistent with
|
||||
// the disconnected->reconnect path that allows 2 failures.
|
||||
if (newState === 'failed' && videoMode.value !== 'mjpeg') {
|
||||
webrtcReconnectFailures += 1
|
||||
if (webrtcReconnectFailures >= 2) {
|
||||
@@ -1706,20 +1647,17 @@ async function toggleFullscreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Theme toggle
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
// Logout
|
||||
async function logout() {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// Change password function
|
||||
async function handleChangePassword() {
|
||||
if (!newPassword.value || !confirmPassword.value) {
|
||||
toast.error(t('auth.passwordRequired'))
|
||||
@@ -1741,20 +1679,17 @@ async function handleChangePassword() {
|
||||
await authApi.changePassword(currentPassword.value, newPassword.value)
|
||||
toast.success(t('auth.passwordChanged'))
|
||||
|
||||
// Reset form and close dialog
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
changePasswordDialogOpen.value = false
|
||||
} catch (e) {
|
||||
// Error toast is shown by API layer
|
||||
console.info('[ChangePassword] Failed:', e)
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ttyd (web terminal) functions
|
||||
function openTerminal() {
|
||||
if (!ttydStatus.value?.running) return
|
||||
showTerminalDialog.value = true
|
||||
@@ -1764,13 +1699,11 @@ function openTerminalInNewTab() {
|
||||
window.open('/api/terminal/', '_blank')
|
||||
}
|
||||
|
||||
// ATX actions
|
||||
async function handlePowerShort() {
|
||||
try {
|
||||
await atxApi.power('short')
|
||||
await systemStore.fetchAtxState()
|
||||
} catch {
|
||||
// ATX action failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1779,7 +1712,6 @@ async function handlePowerLong() {
|
||||
await atxApi.power('long')
|
||||
await systemStore.fetchAtxState()
|
||||
} catch {
|
||||
// ATX action failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1788,7 +1720,6 @@ async function handleReset() {
|
||||
await atxApi.power('reset')
|
||||
await systemStore.fetchAtxState()
|
||||
} catch {
|
||||
// ATX action failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1801,14 +1732,10 @@ async function handleWol(mac: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// HID error handling - silently handle all HID errors
|
||||
function handleHidError(_error: any, _operation: string) {
|
||||
// All HID errors are silently ignored
|
||||
}
|
||||
|
||||
// HID channel selection: use WebRTC DataChannel when available, fallback to WebSocket
|
||||
function sendKeyboardEvent(type: 'down' | 'up', key: CanonicalKey, modifier?: number) {
|
||||
// In WebRTC mode with DataChannel ready, use DataChannel for lower latency
|
||||
if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) {
|
||||
const event: HidKeyboardEvent = {
|
||||
type: type === 'down' ? 'keydown' : 'keyup',
|
||||
@@ -1817,14 +1744,11 @@ function sendKeyboardEvent(type: 'down' | 'up', key: CanonicalKey, modifier?: nu
|
||||
}
|
||||
const sent = webrtc.sendKeyboard(event)
|
||||
if (sent) return
|
||||
// Fallback to WebSocket if DataChannel send failed
|
||||
}
|
||||
// Use WebSocket as fallback or for MJPEG mode
|
||||
hidApi.keyboard(type, key, modifier).catch(err => handleHidError(err, `keyboard ${type}`))
|
||||
}
|
||||
|
||||
function sendMouseEvent(data: { type: 'move' | 'move_abs' | 'down' | 'up' | 'scroll'; x?: number; y?: number; button?: 'left' | 'right' | 'middle'; scroll?: number }) {
|
||||
// In WebRTC mode with DataChannel ready, use DataChannel for lower latency
|
||||
if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) {
|
||||
const event: HidMouseEvent = {
|
||||
type: data.type === 'move_abs' ? 'moveabs' : data.type,
|
||||
@@ -1835,50 +1759,37 @@ function sendMouseEvent(data: { type: 'move' | 'move_abs' | 'down' | 'up' | 'scr
|
||||
}
|
||||
const sent = webrtc.sendMouse(event)
|
||||
if (sent) return
|
||||
// Fallback to WebSocket if DataChannel send failed
|
||||
}
|
||||
// Use WebSocket as fallback or for MJPEG mode
|
||||
hidApi.mouse(data).catch(err => handleHidError(err, `mouse ${data.type}`))
|
||||
}
|
||||
|
||||
// Check if a key should be blocked (prevented from default behavior)
|
||||
function shouldBlockKey(e: KeyboardEvent): boolean {
|
||||
// In fullscreen mode, block all keys for maximum capture
|
||||
if (isFullscreen.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Don't block critical browser shortcuts in non-fullscreen mode
|
||||
const key = e.key.toUpperCase()
|
||||
|
||||
// Don't block Ctrl+W (close tab), Ctrl+T (new tab), Ctrl+N (new window)
|
||||
if (e.ctrlKey && ['W', 'T', 'N'].includes(key)) return false
|
||||
|
||||
// Don't block F11 (browser fullscreen toggle)
|
||||
if (key === 'F11') return false
|
||||
|
||||
// Don't block Alt+Tab (already can't capture it anyway)
|
||||
if (e.altKey && key === 'TAB') return false
|
||||
|
||||
// Block everything else
|
||||
return true
|
||||
}
|
||||
|
||||
// Keyboard/Mouse event handling
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const container = videoContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
// Check focus in non-fullscreen mode
|
||||
if (!isFullscreen.value && !container.contains(document.activeElement)) return
|
||||
|
||||
// Try to block the key if appropriate
|
||||
if (shouldBlockKey(e)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// Show hint for Meta key in non-fullscreen mode
|
||||
if (!isFullscreen.value && (e.metaKey || e.key === 'Meta')) {
|
||||
toast.info(t('console.metaKeyHint'), {
|
||||
description: t('console.metaKeyHintDesc'),
|
||||
@@ -1905,10 +1816,8 @@ function handleKeyUp(e: KeyboardEvent) {
|
||||
const container = videoContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
// Check focus in non-fullscreen mode
|
||||
if (!isFullscreen.value && !container.contains(document.activeElement)) return
|
||||
|
||||
// Try to block the key if appropriate
|
||||
if (shouldBlockKey(e)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -2049,20 +1958,16 @@ function handleMouseMove(e: MouseEvent) {
|
||||
pendingMouseMove = { type: 'move_abs', x, y }
|
||||
requestMouseMoveFlush()
|
||||
} else {
|
||||
// Relative mode: use movementX/Y when pointer is locked
|
||||
if (isPointerLocked.value) {
|
||||
const dx = e.movementX
|
||||
const dy = e.movementY
|
||||
|
||||
// Only accumulate if there's actual movement
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
// Accumulate deltas for throttled sending
|
||||
accumulatedDelta.x += dx
|
||||
accumulatedDelta.y += dy
|
||||
requestMouseMoveFlush()
|
||||
}
|
||||
|
||||
// Update display position (accumulated delta for display only)
|
||||
mousePosition.value = {
|
||||
x: mousePosition.value.x + dx,
|
||||
y: mousePosition.value.y + dy,
|
||||
@@ -2086,13 +1991,11 @@ function flushMouseMoveOnce(): boolean {
|
||||
|
||||
if (accumulatedDelta.x === 0 && accumulatedDelta.y === 0) return false
|
||||
|
||||
// Clamp to i8 range (-127 to 127)
|
||||
const clampedDx = Math.max(-127, Math.min(127, accumulatedDelta.x))
|
||||
const clampedDy = Math.max(-127, Math.min(127, accumulatedDelta.y))
|
||||
|
||||
sendMouseEvent({ type: 'move', x: clampedDx, y: clampedDy })
|
||||
|
||||
// Subtract sent amount (keep remainder for next send if clamped)
|
||||
accumulatedDelta.x -= clampedDx
|
||||
accumulatedDelta.y -= clampedDy
|
||||
return true
|
||||
@@ -2147,13 +2050,11 @@ function requestMouseMoveFlush() {
|
||||
scheduleMouseMoveFlush()
|
||||
}
|
||||
|
||||
// Track pressed mouse button for window-level mouseup handling
|
||||
const pressedMouseButton = ref<'left' | 'right' | 'middle' | null>(null)
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
// Auto-focus the video container to enable keyboard input
|
||||
const container = videoContainerRef.value
|
||||
if (container && document.activeElement !== container) {
|
||||
if (typeof container.focus === 'function') {
|
||||
@@ -2161,7 +2062,6 @@ function handleMouseDown(e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// In relative mode, request pointer lock on first click
|
||||
if (mouseMode.value === 'relative' && !isPointerLocked.value) {
|
||||
requestPointerLock()
|
||||
return
|
||||
@@ -2186,7 +2086,6 @@ function handleMouseUp(e: MouseEvent) {
|
||||
handleMouseUpInternal(e.button)
|
||||
}
|
||||
|
||||
// Window-level mouseup handler (catches releases outside the container)
|
||||
function handleWindowMouseUp(e: MouseEvent) {
|
||||
if (pressedMouseButton.value !== null) {
|
||||
handleMouseUpInternal(e.button)
|
||||
@@ -2201,7 +2100,6 @@ function handleMouseUpInternal(rawButton: number) {
|
||||
|
||||
const button = rawButton === 0 ? 'left' : rawButton === 2 ? 'right' : 'middle'
|
||||
|
||||
// Only send if this button was actually pressed
|
||||
if (pressedMouseButton.value !== button) {
|
||||
return
|
||||
}
|
||||
@@ -2220,7 +2118,6 @@ function handleContextMenu(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
// Pointer Lock API for relative mouse mode
|
||||
function requestPointerLock() {
|
||||
const container = videoContainerRef.value
|
||||
if (!container) return
|
||||
@@ -2243,7 +2140,6 @@ function handlePointerLockChange() {
|
||||
isPointerLocked.value = document.pointerLockElement === container
|
||||
|
||||
if (isPointerLocked.value) {
|
||||
// Reset mouse position display when locked
|
||||
mousePosition.value = { x: 0, y: 0 }
|
||||
if (cursorVisible.value && container) {
|
||||
const r = container.getBoundingClientRect()
|
||||
@@ -2269,7 +2165,6 @@ function handleFullscreenChange() {
|
||||
function handleBlur() {
|
||||
pressedKeys.value = []
|
||||
activeModifierMask.value = 0
|
||||
// Release any pressed mouse button when window loses focus
|
||||
if (pressedMouseButton.value !== null) {
|
||||
const button = pressedMouseButton.value
|
||||
pressedMouseButton.value = null
|
||||
@@ -2277,7 +2172,6 @@ function handleBlur() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cursor visibility change from HidConfigPopover
|
||||
function handleCursorVisibilityChange(e: Event) {
|
||||
const customEvent = e as CustomEvent<{ visible: boolean }>
|
||||
cursorVisible.value = customEvent.detail.visible
|
||||
@@ -2365,7 +2259,6 @@ async function activateConsoleView() {
|
||||
void systemStore.fetchAllStates()
|
||||
void configStore.refreshHid().then(() => syncMouseModeFromConfig()).catch(() => {})
|
||||
|
||||
// Ensure HID WebSocket is connected when console becomes active
|
||||
if (!hidWs.connected.value) {
|
||||
hidWs.connect().catch(() => {})
|
||||
}
|
||||
@@ -2395,28 +2288,22 @@ function deactivateConsoleView() {
|
||||
unregisterInteractionListeners()
|
||||
}
|
||||
|
||||
// ActionBar handlers
|
||||
// (MSD and Settings are now handled by ActionBar component directly)
|
||||
|
||||
function handleToggleVirtualKeyboard() {
|
||||
virtualKeyboardVisible.value = !virtualKeyboardVisible.value
|
||||
}
|
||||
|
||||
// Virtual keyboard key event handlers
|
||||
function handleVirtualKeyDown(key: CanonicalKey) {
|
||||
// Add to pressedKeys for InfoBar display
|
||||
if (!pressedKeys.value.includes(key)) {
|
||||
pressedKeys.value = [...pressedKeys.value, key]
|
||||
}
|
||||
}
|
||||
|
||||
function handleVirtualKeyUp(key: CanonicalKey) {
|
||||
// Remove from pressedKeys
|
||||
pressedKeys.value = pressedKeys.value.filter(k => k !== key)
|
||||
}
|
||||
|
||||
function handleToggleMouseMode() {
|
||||
// Exit pointer lock when switching away from relative mode
|
||||
if (mouseMode.value === 'relative' && isPointerLocked.value) {
|
||||
exitPointerLock()
|
||||
}
|
||||
@@ -2424,7 +2311,6 @@ function handleToggleMouseMode() {
|
||||
mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute'
|
||||
pendingMouseMove = null
|
||||
accumulatedDelta = { x: 0, y: 0 }
|
||||
// Reset position when switching modes
|
||||
lastMousePosition.value = { x: 0, y: 0 }
|
||||
mousePosition.value = { x: 0, y: 0 }
|
||||
|
||||
@@ -2436,16 +2322,13 @@ function handleToggleMouseMode() {
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// 1. 先订阅 WebSocket 事件,再连接(内部会 connect)
|
||||
consoleEvents.subscribe()
|
||||
|
||||
// 3. Watch WebSocket connection states and sync to store
|
||||
watch([wsConnected, wsNetworkError], ([connected, netError], [_prevConnected, prevNetError]) => {
|
||||
systemStore.updateWsConnection(connected, netError)
|
||||
|
||||
// Auto-refresh video when network recovers (wsNetworkError: true -> false)
|
||||
if (prevNetError === true && netError === false && connected === true) {
|
||||
refreshVideo()
|
||||
}
|
||||
@@ -2476,7 +2359,6 @@ onMounted(async () => {
|
||||
|
||||
// Note: Video mode is now synced from server via device_info event
|
||||
// The handleDeviceInfo function will automatically switch to the server's mode
|
||||
// localStorage preference is only used when server mode matches
|
||||
try {
|
||||
const modeResp = await streamApi.getMode()
|
||||
const serverMode = normalizeServerMode(modeResp?.mode)
|
||||
@@ -2504,13 +2386,11 @@ onUnmounted(() => {
|
||||
initialModeRestoreDone = false
|
||||
initialModeRestoreInProgress = false
|
||||
|
||||
// Clear mouse flush timer
|
||||
if (mouseFlushTimer !== null) {
|
||||
clearTimeout(mouseFlushTimer)
|
||||
mouseFlushTimer = null
|
||||
}
|
||||
|
||||
// Clear all timers
|
||||
if (retryTimeoutId !== null) {
|
||||
clearTimeout(retryTimeoutId)
|
||||
retryTimeoutId = null
|
||||
@@ -2522,7 +2402,6 @@ onUnmounted(() => {
|
||||
cancelWebRTCRecovery()
|
||||
videoSession.clearWaiters()
|
||||
|
||||
// Reset counters
|
||||
retryCount = 0
|
||||
|
||||
consoleEvents.unsubscribe()
|
||||
@@ -2533,7 +2412,6 @@ onUnmounted(() => {
|
||||
void webrtc.disconnect()
|
||||
}
|
||||
|
||||
// Exit pointer lock if active
|
||||
exitPointerLock()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -106,7 +106,6 @@ const systemStore = useSystemStore()
|
||||
const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Settings state
|
||||
const activeSection = ref('appearance')
|
||||
const mobileMenuOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -127,7 +126,6 @@ const SETTINGS_SECTION_IDS = new Set([
|
||||
'about',
|
||||
])
|
||||
|
||||
// Navigation structure
|
||||
const navGroups = computed(() => [
|
||||
{
|
||||
title: t('settings.system'),
|
||||
@@ -175,10 +173,8 @@ function normalizeSettingsSection(value: unknown): string | null {
|
||||
return SETTINGS_SECTION_IDS.has(value) ? value : null
|
||||
}
|
||||
|
||||
// Theme
|
||||
const theme = ref<'light' | 'dark' | 'system'>('system')
|
||||
|
||||
// Account settings
|
||||
const usernameInput = ref('')
|
||||
const usernamePassword = ref('')
|
||||
const usernameSaving = ref(false)
|
||||
@@ -192,7 +188,6 @@ const passwordSaved = ref(false)
|
||||
const passwordError = ref('')
|
||||
const showPasswords = ref(false)
|
||||
|
||||
// Auth config state
|
||||
const authConfig = ref<AuthConfig>({
|
||||
session_timeout_secs: 3600 * 24,
|
||||
single_user_allow_multiple_sessions: false,
|
||||
@@ -201,7 +196,6 @@ const authConfig = ref<AuthConfig>({
|
||||
})
|
||||
const authConfigLoading = ref(false)
|
||||
|
||||
// Extensions management
|
||||
const extensions = ref<ExtensionsStatus | null>(null)
|
||||
const extensionsLoading = ref(false)
|
||||
const extensionLogs = ref<Record<string, string[]>>({
|
||||
@@ -215,17 +209,14 @@ const showLogs = ref<Record<string, boolean>>({
|
||||
easytier: false,
|
||||
})
|
||||
|
||||
// Terminal dialog
|
||||
const showTerminalDialog = ref(false)
|
||||
|
||||
// Extension config (local edit state)
|
||||
const extConfig = ref({
|
||||
ttyd: { enabled: false, shell: '/bin/bash' },
|
||||
gostc: { enabled: false, addr: '', key: '', tls: true },
|
||||
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
|
||||
})
|
||||
|
||||
// RustDesk config state
|
||||
const rustdeskConfig = ref<RustDeskConfigResponse | null>(null)
|
||||
const rustdeskStatus = ref<RustDeskStatusResponse | null>(null)
|
||||
const rustdeskPassword = ref<RustDeskPasswordResponse | null>(null)
|
||||
@@ -239,7 +230,6 @@ const rustdeskLocalConfig = ref({
|
||||
relay_key: '',
|
||||
})
|
||||
|
||||
// RTSP config state
|
||||
const rtspStatus = ref<RtspStatusResponse | null>(null)
|
||||
const rtspLoading = ref(false)
|
||||
const rtspLocalConfig = ref<RtspConfigUpdate & { password?: string }>({
|
||||
@@ -267,7 +257,6 @@ const rtspStreamUrl = computed(() => {
|
||||
return `rtsp://${host}:${port}/${path}`
|
||||
})
|
||||
|
||||
// Web server config state
|
||||
const webServerConfig = ref<WebConfig>({
|
||||
http_port: 8080,
|
||||
https_port: 8443,
|
||||
@@ -277,14 +266,12 @@ const webServerConfig = ref<WebConfig>({
|
||||
has_custom_cert: false,
|
||||
})
|
||||
const webServerLoading = ref(false)
|
||||
// SSL certificate state
|
||||
const sslCertPem = ref('')
|
||||
const sslKeyPem = ref('')
|
||||
const certSaving = ref(false)
|
||||
const certClearing = ref(false)
|
||||
const showRestartDialog = ref(false)
|
||||
const restarting = ref(false)
|
||||
// Auto-restart flow (no dialog needed for web-config saves)
|
||||
const autoRestarting = ref(false)
|
||||
const autoRestartFailed = ref(false)
|
||||
// For HTTPS targets: can't poll (self-signed cert), show manual link instead
|
||||
@@ -339,7 +326,6 @@ const previewAccessUrl = computed(() => {
|
||||
return `${scheme}://${host}:${port}`
|
||||
})
|
||||
|
||||
// Config
|
||||
interface DeviceConfig {
|
||||
video: Array<{
|
||||
path: string
|
||||
@@ -389,14 +375,12 @@ const config = ref({
|
||||
msd_enabled: false,
|
||||
msd_dir: '',
|
||||
encoder_backend: 'auto',
|
||||
// STUN/TURN settings
|
||||
stun_server: '',
|
||||
turn_server: '',
|
||||
turn_username: '',
|
||||
turn_password: '',
|
||||
})
|
||||
|
||||
// Tracks whether TURN password is configured on the server
|
||||
const hasTurnPassword = ref(false)
|
||||
|
||||
type OtgSelfCheckLevel = 'info' | 'warn' | 'error'
|
||||
@@ -658,7 +642,6 @@ async function onRunVideoEncoderSelfCheckClick() {
|
||||
await runVideoEncoderSelfCheck()
|
||||
}
|
||||
|
||||
// USB devices state
|
||||
const usbDevices = ref<import('@/api').UsbDeviceInfo[]>([])
|
||||
const usbDevicesLoading = ref(false)
|
||||
const usbDevicesError = ref('')
|
||||
@@ -683,11 +666,9 @@ async function confirmUsbReset() {
|
||||
try {
|
||||
await usbApi.resetDevice(usbResetTarget.value.bus_num, usbResetTarget.value.dev_num)
|
||||
} catch {
|
||||
// Error already shown by request helper toast
|
||||
} finally {
|
||||
usbResetLoading.value = false
|
||||
usbResetTarget.value = null
|
||||
// Refresh the list after a short delay for USB re-enumeration
|
||||
setTimeout(() => fetchUsbDevices(), 1500)
|
||||
}
|
||||
}
|
||||
@@ -782,14 +763,12 @@ const isHidFunctionSelectionValid = computed(() => {
|
||||
return !!(f.keyboard || f.mouse_relative || f.mouse_absolute || f.consumer)
|
||||
})
|
||||
|
||||
// OTG Descriptor settings
|
||||
const otgVendorIdHex = ref('1d6b')
|
||||
const otgProductIdHex = ref('0104')
|
||||
const otgManufacturer = ref('One-KVM')
|
||||
const otgProduct = ref('One-KVM USB Device')
|
||||
const otgSerialNumber = ref('')
|
||||
|
||||
// Validate hex input
|
||||
const validateHex = (event: Event, _field: string) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
input.value = input.value.replace(/[^0-9a-fA-F]/g, '').toLowerCase()
|
||||
@@ -807,7 +786,6 @@ watch(bindMode, (mode) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ATX config state
|
||||
const atxConfig = ref({
|
||||
enabled: false,
|
||||
power: {
|
||||
@@ -833,7 +811,6 @@ const atxConfig = ref({
|
||||
wol_interface: '',
|
||||
})
|
||||
|
||||
// ATX devices for discovery
|
||||
const atxDevices = ref<AtxDevices>({
|
||||
gpio_chips: [],
|
||||
usb_relays: [],
|
||||
@@ -856,7 +833,6 @@ const isSharedAtxSerialRelay = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// Encoder backend
|
||||
const availableBackends = ref<EncoderBackendInfo[]>([])
|
||||
|
||||
const selectedBackendFormats = computed(() => {
|
||||
@@ -921,7 +897,6 @@ const availableFps = computed(() => {
|
||||
return currentRes ? currentRes.fps : []
|
||||
})
|
||||
|
||||
// Keep the selected format aligned with currently selectable formats.
|
||||
watch(
|
||||
selectableFormats,
|
||||
() => {
|
||||
@@ -938,7 +913,6 @@ watch(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// Watch for format change to set default resolution
|
||||
watch(() => config.value.video_format, () => {
|
||||
if (availableResolutions.value.length > 0) {
|
||||
const isValid = availableResolutions.value.some(
|
||||
@@ -955,7 +929,6 @@ watch(() => config.value.video_format, () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for resolution change to set default FPS
|
||||
watch(() => [config.value.video_width, config.value.video_height], () => {
|
||||
const fpsList = availableFps.value
|
||||
if (fpsList.length > 0) {
|
||||
@@ -975,7 +948,6 @@ watch(() => authStore.user, (value) => {
|
||||
})
|
||||
|
||||
|
||||
// Format bytes to human readable string
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
@@ -984,7 +956,6 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
// Theme handling
|
||||
function setTheme(newTheme: 'light' | 'dark' | 'system') {
|
||||
theme.value = newTheme
|
||||
localStorage.setItem('theme', newTheme)
|
||||
@@ -997,7 +968,6 @@ function setTheme(newTheme: 'light' | 'dark' | 'system') {
|
||||
}
|
||||
}
|
||||
|
||||
// Account updates
|
||||
async function changeUsername() {
|
||||
usernameError.value = ''
|
||||
usernameSaved.value = false
|
||||
@@ -1062,17 +1032,13 @@ async function changePassword() {
|
||||
}
|
||||
}
|
||||
|
||||
// Save config using domain-separated APIs
|
||||
async function saveConfig() {
|
||||
loading.value = true
|
||||
saved.value = false
|
||||
|
||||
try {
|
||||
// Save only config related to the active section.
|
||||
// Sequential awaits: backend ConfigStore uses read-modify-write; parallel PATCH
|
||||
// requests could overwrite each other's section (last writer wins on full JSON).
|
||||
|
||||
// Video config (including encoder and WebRTC/STUN/TURN settings)
|
||||
if (activeSection.value === 'video') {
|
||||
await configStore.updateVideo({
|
||||
device: config.value.video_device || undefined,
|
||||
@@ -1100,7 +1066,6 @@ async function saveConfig() {
|
||||
ch9329_port: config.value.hid_serial_device || undefined,
|
||||
ch9329_baudrate: config.value.hid_serial_baudrate,
|
||||
}
|
||||
// Add descriptor config for OTG backend
|
||||
if (config.value.hid_backend === 'otg') {
|
||||
hidUpdate.otg_descriptor = {
|
||||
vendor_id: parseInt(otgVendorIdHex.value, 16) || 0x1d6b,
|
||||
@@ -1120,7 +1085,6 @@ async function saveConfig() {
|
||||
})
|
||||
}
|
||||
|
||||
// MSD config
|
||||
if (activeSection.value === 'msd') {
|
||||
await configStore.updateMsd({
|
||||
msd_dir: config.value.msd_dir || undefined,
|
||||
@@ -1137,10 +1101,8 @@ async function saveConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Load config using domain-separated APIs
|
||||
async function loadConfig() {
|
||||
try {
|
||||
// Load all domain configs in parallel
|
||||
const [video, stream, hid, msd] = await Promise.all([
|
||||
configStore.refreshVideo(),
|
||||
configStore.refreshStream(),
|
||||
@@ -1170,17 +1132,14 @@ async function loadConfig() {
|
||||
msd_enabled: msd.enabled || false,
|
||||
msd_dir: msd.msd_dir || '',
|
||||
encoder_backend: stream.encoder || 'auto',
|
||||
// STUN/TURN settings
|
||||
stun_server: stream.stun_server || '',
|
||||
turn_server: stream.turn_server || '',
|
||||
turn_username: stream.turn_username || '',
|
||||
turn_password: '', // Password is never returned from server; set-only field
|
||||
}
|
||||
|
||||
// Track whether TURN password is configured
|
||||
hasTurnPassword.value = stream.has_turn_password || false
|
||||
|
||||
// Load OTG descriptor config
|
||||
if (hid.otg_descriptor) {
|
||||
otgVendorIdHex.value = hid.otg_descriptor.vendor_id?.toString(16).padStart(4, '0') || '1d6b'
|
||||
otgProductIdHex.value = hid.otg_descriptor.product_id?.toString(16).padStart(4, '0') || '0104'
|
||||
@@ -1211,7 +1170,6 @@ async function loadBackends() {
|
||||
}
|
||||
}
|
||||
|
||||
// Auth config functions
|
||||
async function loadAuthConfig() {
|
||||
authConfigLoading.value = true
|
||||
try {
|
||||
@@ -1236,12 +1194,10 @@ async function saveAuthConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Extension management functions
|
||||
async function loadExtensions() {
|
||||
extensionsLoading.value = true
|
||||
try {
|
||||
extensions.value = await extensionsApi.getAll()
|
||||
// Sync config from server
|
||||
if (extensions.value) {
|
||||
const ttyd = extensions.value.ttyd.config
|
||||
extConfig.value.ttyd = {
|
||||
@@ -1359,7 +1315,6 @@ function removeEasytierPeer(index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ATX management functions
|
||||
async function loadAtxConfig() {
|
||||
try {
|
||||
const config = await configStore.refreshAtx()
|
||||
@@ -1485,7 +1440,6 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// RustDesk management functions
|
||||
async function loadRustdeskConfig() {
|
||||
rustdeskLoading.value = true
|
||||
try {
|
||||
@@ -1573,7 +1527,6 @@ function removeBindAddress(index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Web server config functions
|
||||
async function loadWebServerConfig() {
|
||||
try {
|
||||
const config = await configStore.refreshWeb()
|
||||
@@ -1668,7 +1621,6 @@ async function pollUntilReady(targetOrigin: string, maxMs = 30000): Promise<bool
|
||||
clearTimeout(tid)
|
||||
if (res.ok) return true
|
||||
} catch {
|
||||
// server still restarting — keep polling
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -1836,7 +1788,6 @@ async function saveRustdeskConfig() {
|
||||
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
|
||||
})
|
||||
await loadRustdeskConfig()
|
||||
// Clear relay_key input after save (it's a password field)
|
||||
rustdeskLocalConfig.value.relay_key = ''
|
||||
saved.value = true
|
||||
setTimeout(() => (saved.value = false), 2000)
|
||||
@@ -1878,7 +1829,6 @@ async function regenerateRustdeskPassword() {
|
||||
async function startRustdesk() {
|
||||
rustdeskLoading.value = true
|
||||
try {
|
||||
// Enable and save config to start the service
|
||||
await configStore.updateRustdesk({ enabled: true })
|
||||
rustdeskLocalConfig.value.enabled = true
|
||||
await loadRustdeskConfig()
|
||||
@@ -1892,7 +1842,6 @@ async function startRustdesk() {
|
||||
async function stopRustdesk() {
|
||||
rustdeskLoading.value = true
|
||||
try {
|
||||
// Disable and save config to stop the service
|
||||
await configStore.updateRustdesk({ enabled: false })
|
||||
rustdeskLocalConfig.value.enabled = false
|
||||
await loadRustdeskConfig()
|
||||
@@ -1919,7 +1868,6 @@ function getRustdeskServiceStatusText(status: string | undefined): string {
|
||||
case 'stopped': return t('extensions.stopped')
|
||||
case 'not_initialized': return t('extensions.rustdesk.notInitialized')
|
||||
default:
|
||||
// Handle "error: xxx" format
|
||||
if (status.startsWith('error:')) return t('extensions.failed')
|
||||
return status
|
||||
}
|
||||
@@ -1933,7 +1881,6 @@ function getRustdeskRendezvousStatusText(status: string | null | undefined): str
|
||||
case 'connecting': return t('extensions.rustdesk.connecting')
|
||||
case 'disconnected': return t('extensions.rustdesk.disconnected')
|
||||
default:
|
||||
// Handle "error: xxx" format
|
||||
if (status.startsWith('error:')) return t('extensions.failed')
|
||||
return status
|
||||
}
|
||||
@@ -1950,7 +1897,6 @@ function getRustdeskStatusClass(status: string | null | undefined): string {
|
||||
case 'not_initialized':
|
||||
case 'disconnected': return 'bg-gray-400'
|
||||
default:
|
||||
// Handle "error: xxx" format
|
||||
if (status?.startsWith('error:')) return 'bg-red-500'
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
@@ -2058,9 +2004,7 @@ function getRtspStatusClass(status: string | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// Load theme preference
|
||||
const storedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null
|
||||
if (storedTheme) {
|
||||
theme.value = storedTheme
|
||||
|
||||
@@ -42,20 +42,17 @@ const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Steps: 1 = Account, 2 = Audio/Video, 3 = HID, 4 = Extensions
|
||||
const step = ref(1)
|
||||
const totalSteps = 4
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const slideDirection = ref<'forward' | 'backward'>('forward')
|
||||
|
||||
// Account settings
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const showPassword = ref(false)
|
||||
|
||||
// Form validation states
|
||||
const usernameError = ref('')
|
||||
const passwordError = ref('')
|
||||
const confirmPasswordError = ref('')
|
||||
@@ -63,17 +60,14 @@ const usernameTouched = ref(false)
|
||||
const passwordTouched = ref(false)
|
||||
const confirmPasswordTouched = ref(false)
|
||||
|
||||
// Video settings
|
||||
const videoDevice = ref('')
|
||||
const videoFormat = ref('')
|
||||
const videoResolution = ref('')
|
||||
const videoFps = ref<number | null>(null)
|
||||
|
||||
// Audio settings
|
||||
const audioDevice = ref('')
|
||||
const audioEnabled = ref(true)
|
||||
|
||||
// HID settings
|
||||
const hidBackend = ref('ch9329')
|
||||
const ch9329Port = ref('')
|
||||
const ch9329Baudrate = ref(9600)
|
||||
@@ -83,7 +77,6 @@ const otgMsdEnabled = ref(true)
|
||||
const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six')
|
||||
const otgKeyboardLeds = ref(true)
|
||||
|
||||
// Extension settings
|
||||
const ttydEnabled = ref(false)
|
||||
const ttydAvailable = ref(false)
|
||||
|
||||
@@ -137,7 +130,6 @@ const devices = ref<DeviceInfo>({
|
||||
},
|
||||
})
|
||||
|
||||
// Password strength calculation
|
||||
const passwordStrength = computed(() => {
|
||||
const pwd = password.value
|
||||
if (!pwd) return 0
|
||||
@@ -183,7 +175,6 @@ async function refreshDeviceList() {
|
||||
ttydAvailable.value = result.extensions.ttyd_available
|
||||
}
|
||||
} catch {
|
||||
// keep current list
|
||||
} finally {
|
||||
refreshingDevices.value = false
|
||||
}
|
||||
@@ -195,13 +186,11 @@ const availableFormats = computed(() => {
|
||||
return device?.formats || []
|
||||
})
|
||||
|
||||
// Computed: available resolutions for selected format
|
||||
const availableResolutions = computed(() => {
|
||||
const format = availableFormats.value.find((f) => f.format === videoFormat.value)
|
||||
return format?.resolutions || []
|
||||
})
|
||||
|
||||
// Computed: available FPS for selected resolution
|
||||
const availableFps = computed(() => {
|
||||
const [width, height] = (videoResolution.value || '').split('x').map(Number)
|
||||
const resolution = availableResolutions.value.find(
|
||||
@@ -252,10 +241,8 @@ function applyOtgDefaults() {
|
||||
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
|
||||
}
|
||||
|
||||
// Common baud rates for CH9329
|
||||
const baudRates = [9600, 19200, 38400, 57600, 115200]
|
||||
|
||||
// Step labels for the indicator
|
||||
const stepLabels = computed(() => [
|
||||
t('setup.stepAccount'),
|
||||
t('setup.stepAudioVideo'),
|
||||
@@ -263,7 +250,6 @@ const stepLabels = computed(() => [
|
||||
t('setup.stepExtensions'),
|
||||
])
|
||||
|
||||
// Real-time validation functions
|
||||
function validateUsername() {
|
||||
usernameTouched.value = true
|
||||
if (username.value.length === 0) {
|
||||
@@ -284,7 +270,6 @@ function validatePassword() {
|
||||
} else {
|
||||
passwordError.value = ''
|
||||
}
|
||||
// Also validate confirm password if it was touched
|
||||
if (confirmPasswordTouched.value) {
|
||||
validateConfirmPassword()
|
||||
}
|
||||
@@ -335,12 +320,10 @@ watch(videoDevice, (newDevice) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Watch format change to auto-select best resolution
|
||||
watch(videoFormat, () => {
|
||||
videoResolution.value = ''
|
||||
videoFps.value = null
|
||||
if (availableResolutions.value.length > 0) {
|
||||
// Prefer 1080p if available, otherwise highest resolution
|
||||
const r1080 = availableResolutions.value.find((r) => r.width === 1920 && r.height === 1080)
|
||||
const r720 = availableResolutions.value.find((r) => r.width === 1280 && r.height === 720)
|
||||
const best = r1080 || r720 || availableResolutions.value[0]
|
||||
@@ -350,11 +333,9 @@ watch(videoFormat, () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Watch resolution change to auto-select FPS
|
||||
watch(videoResolution, () => {
|
||||
videoFps.value = null
|
||||
if (availableFps.value.length > 0) {
|
||||
// Prefer 30fps if available
|
||||
videoFps.value = availableFps.value.includes(30) ? 30 : availableFps.value[0] || null
|
||||
}
|
||||
})
|
||||
@@ -389,7 +370,6 @@ onMounted(async () => {
|
||||
ch9329Port.value = result.serial[0].path
|
||||
}
|
||||
|
||||
// Auto-select first UDC for OTG
|
||||
if (result.udc.length > 0 && result.udc[0]) {
|
||||
otgUdc.value = result.udc[0].name
|
||||
}
|
||||
@@ -407,7 +387,6 @@ onMounted(async () => {
|
||||
ttydAvailable.value = result.extensions.ttyd_available
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
|
||||
// Load encoder backends
|
||||
@@ -415,10 +394,8 @@ onMounted(async () => {
|
||||
const codecsResult = await streamApi.getCodecs()
|
||||
availableBackends.value = codecsResult.backends || []
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
|
||||
// Add keyboard navigation
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
@@ -427,7 +404,6 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Don't interfere with input fields
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
@@ -446,7 +422,6 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
function validateStep1(): boolean {
|
||||
// Trigger validation for all fields
|
||||
validateUsername()
|
||||
validatePassword()
|
||||
validateConfirmPassword()
|
||||
@@ -521,7 +496,6 @@ async function handleSetup() {
|
||||
|
||||
loading.value = true
|
||||
|
||||
// Parse resolution
|
||||
const [width, height] = (videoResolution.value || '').split('x').map(Number)
|
||||
|
||||
const setupData: Parameters<typeof authStore.setup>[0] = {
|
||||
@@ -529,7 +503,6 @@ async function handleSetup() {
|
||||
password: password.value,
|
||||
}
|
||||
|
||||
// Video settings
|
||||
if (videoDevice.value) {
|
||||
setupData.video_device = videoDevice.value
|
||||
}
|
||||
@@ -544,7 +517,6 @@ async function handleSetup() {
|
||||
setupData.video_fps = toConfigFps(videoFps.value)
|
||||
}
|
||||
|
||||
// HID settings
|
||||
setupData.hid_backend = hidBackend.value
|
||||
if (hidBackend.value === 'ch9329') {
|
||||
setupData.hid_ch9329_port = ch9329Port.value
|
||||
@@ -563,18 +535,15 @@ async function handleSetup() {
|
||||
setupData.encoder_backend = encoderBackend.value
|
||||
}
|
||||
|
||||
// Audio settings
|
||||
if (audioDevice.value && audioDevice.value !== '__none__') {
|
||||
setupData.audio_device = audioDevice.value
|
||||
}
|
||||
|
||||
// Extension settings
|
||||
setupData.ttyd_enabled = ttydEnabled.value
|
||||
|
||||
const success = await authStore.setup(setupData)
|
||||
|
||||
if (success) {
|
||||
// Auto login after setup
|
||||
await authStore.login(username.value, password.value)
|
||||
router.push('/')
|
||||
} else {
|
||||
@@ -584,7 +553,6 @@ async function handleSetup() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// Step icon component helper
|
||||
const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user