diff --git a/app.go b/app.go index 421e476..6d9c8a0 100644 --- a/app.go +++ b/app.go @@ -2,6 +2,7 @@ package main import ( "context" + "ffmpeg-gui/internal/config" "ffmpeg-gui/internal/ffmpeg" "ffmpeg-gui/internal/gpu" "ffmpeg-gui/internal/hwaccel" @@ -9,6 +10,8 @@ import ( "ffmpeg-gui/internal/platform" "ffmpeg-gui/internal/task" "fmt" + "os" + "strings" "time" "github.com/wailsapp/wails/v2/pkg/runtime" @@ -17,6 +20,7 @@ import ( // App is the main application struct. Its exported methods are bound to the frontend. type App struct { ctx context.Context + cfg *config.Config exec *ffmpeg.Executor taskMgr *task.Manager hwDetect *hwaccel.Detector @@ -30,9 +34,31 @@ func NewApp() *App { // startup is called when the app starts. func (a *App) startup(ctx context.Context) { a.ctx = ctx + a.cfg = config.Load() + runtime.LogInfo(ctx, "[app] config loaded") - // Detect ffmpeg/ffprobe binaries - bins, err := ffmpeg.Detect() + // Detect ffmpeg/ffprobe (system → saved → bundled) + bins, err := detectBins(a.cfg) + if err != nil { + // System/PATH not found — extract embedded zip if bundled + zipData, zipErr := bundledDir.ReadFile("bundled/ffmpeg.zip") + runtime.LogInfo(ctx, fmt.Sprintf("ffmpeg not in PATH, bundled zip: err=%v size=%d", zipErr, len(zipData))) + if zipErr == nil { + ffmpegPath, ffprobePath := a.cfg.ExtractZip(zipData) + if ffmpegPath != "" { + a.cfg.FFmpegPath = ffmpegPath + } + if ffprobePath != "" { + a.cfg.FFprobePath = ffprobePath + } + if a.cfg.FFmpegPath != "" || a.cfg.FFprobePath != "" { + a.cfg.Save() + runtime.LogInfo(ctx, fmt.Sprintf("config saved with ffmpeg=%s ffprobe=%s", a.cfg.FFmpegPath, a.cfg.FFprobePath)) + } + } + // Retry with extracted binaries + bins, err = detectBins(a.cfg) + } if err != nil { runtime.LogError(ctx, fmt.Sprintf("ffmpeg detect failed: %v", err)) return @@ -43,6 +69,11 @@ func (a *App) startup(ctx context.Context) { a.hwDetect = hwaccel.NewDetector(a.exec) a.taskMgr = task.NewManager(a.exec) + // Restore saved preferred accelerator + if a.cfg.PreferredAccel != "" { + a.taskMgr.SetHWAccel(a.cfg.PreferredAccel) + } + a.taskMgr.SetEventCallback(func(eventType string, data any) { runtime.EventsEmit(ctx, eventType, data) }) @@ -109,12 +140,106 @@ func (a *App) GetHardwareEncoders() ([]hwaccel.HWEncoder, error) { // GetGPUInfo returns detected GPU models and their encoder capabilities. func (a *App) GetGPUInfo() []gpu.GPUInfo { - if a.exec == nil { - return nil - } return gpu.DetectGPUs(a.exec) } +// detectBins finds ffmpeg/ffprobe, checking config paths first. +func detectBins(cfg *config.Config) (ffmpeg.BinPaths, error) { + // Try config-saved paths first + if cfg.FFmpegPath != "" && cfg.FFprobePath != "" { + if fileExists(cfg.FFmpegPath) && fileExists(cfg.FFprobePath) { + return ffmpeg.BinPaths{FFmpeg: cfg.FFmpegPath, FFprobe: cfg.FFprobePath}, nil + } + } + // Fall back to default detection (bundled, then PATH) + return ffmpeg.Detect() +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// ---- Config API ---- + +// GetConfig returns the current app configuration. +func (a *App) GetConfig() map[string]string { + return map[string]string{ + "theme": a.cfg.Theme, + "preferredAccel": a.cfg.PreferredAccel, + "outputDir": a.cfg.OutputDir, + "namingRule": a.cfg.NamingRule, + } +} + +// SaveConfig saves a configuration value. +func (a *App) SaveConfig(key, value string) { + switch key { + case "theme": + a.cfg.Theme = value + case "preferredAccel": + a.cfg.PreferredAccel = value + case "outputDir": + a.cfg.OutputDir = value + case "namingRule": + a.cfg.NamingRule = value + } + a.cfg.Save() +} + +// ---- Hardware Detection ---- + +// CheckFFmpeg returns ffmpeg and ffprobe paths and version info. +func (a *App) CheckFFmpeg() map[string]string { + result := map[string]string{ + "ffmpegPath": "", + "ffprobePath": "", + "ffmpegVer": "", + "ffprobeVer": "", + } + if a.exec == nil { + // Try config paths as fallback + bins, err := detectBins(a.cfg) + if err != nil { + return result + } + result["ffmpegPath"] = bins.FFmpeg + result["ffprobePath"] = bins.FFprobe + // Can't get version without executor, but at least show paths + return result + } + + // Use saved config paths if available + if a.cfg.FFmpegPath != "" && fileExists(a.cfg.FFmpegPath) { + result["ffmpegPath"] = a.cfg.FFmpegPath + } else { + bins, _ := ffmpeg.Detect() + result["ffmpegPath"] = bins.FFmpeg + } + if a.cfg.FFprobePath != "" && fileExists(a.cfg.FFprobePath) { + result["ffprobePath"] = a.cfg.FFprobePath + } else { + bins, _ := ffmpeg.Detect() + result["ffprobePath"] = bins.FFprobe + } + + out, err := a.exec.RunSync("-version") + if err == nil { + lines := strings.Split(out, "\n") + if len(lines) > 0 { + result["ffmpegVer"] = strings.TrimSpace(lines[0]) + } + } + out, err = a.exec.Probe("-version") + if err == nil { + lines := strings.Split(out, "\n") + if len(lines) > 0 { + result["ffprobeVer"] = strings.TrimSpace(lines[0]) + } + } + return result +} + // GetAccelerators returns detected hardware acceleration methods. func (a *App) GetAccelerators() ([]hwaccel.Accelerator, error) { if a.hwDetect == nil { @@ -220,6 +345,13 @@ func (a *App) SelectOutputFile(defaultName string) (string, error) { }) } +// SelectOutputDir opens a directory dialog for selecting a default output folder. +func (a *App) SelectOutputDir() (string, error) { + return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "选择默认输出目录", + }) +} + // SelectSubtitleFile opens a file dialog for selecting an external subtitle file. func (a *App) SelectSubtitleFile() (string, error) { return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ecaf48f..a475e17 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -12,13 +12,19 @@
- - - - +
+ ⚠ FFmpeg 未就绪 — 请前往「设置」页面检测并配置 + +
+ + + + + +
- +
@@ -40,6 +46,17 @@ import type { Task, Progress } from './types' const currentView = ref('encode') const theme = ref<'dark'|'light'>('light') +const ffmpegReady = ref(true) +async function restoreTheme() { + try { + const app = (window as any).go?.main?.App + if (app?.GetConfig) { + const cfg = await app.GetConfig() + if (cfg.theme) theme.value = cfg.theme + } + } catch {} + document.documentElement.setAttribute('data-theme', theme.value) +} const tasks = ref([]) const gpuInfo = ref([]) @@ -47,7 +64,7 @@ const logPageRef = ref | null>(null) onMounted(() => { document.addEventListener('contextmenu', e => e.preventDefault()) - document.documentElement.setAttribute('data-theme', theme.value) + restoreTheme() loadTasks() loadGPUInfo() @@ -87,6 +104,10 @@ function navigate(view: string) { function toggleTheme() { theme.value = theme.value === 'dark' ? 'light' : 'dark' document.documentElement.setAttribute('data-theme', theme.value) + try { + const app = (window as any).go?.main?.App + if (app?.SaveConfig) app.SaveConfig('theme', theme.value) + } catch {} } async function loadTasks() { @@ -118,7 +139,11 @@ function groupByVendor(encs: any[]): any[] { } groups[e.type].encoders.push({ name: e.name, codec: e.codec, label: e.label, available: e.available }) } - return [...Object.values(groups), { name: 'CPU', vendor: 'cpu', encoders: [{ name:'libx264',codec:'h264',label:'H.264',available:true },{ name:'libx265',codec:'hevc',label:'HEVC',available:true }]}] + return [...Object.values(groups), { name: 'CPU', vendor: 'cpu', encoders: [{ name:'libx264',codec:'h264',label:'H.264 (libx264)',available:true },{ name:'libx265',codec:'hevc',label:'H.265/HEVC (libx265)',available:true }]}] +} + +function onFFmpegChecked(ok: boolean) { + ffmpegReady.value = ok } function onTaskAdded() { loadTasks() } @@ -126,6 +151,13 @@ function onTaskAdded() { loadTasks() } async function handleCancel(id: string) { try { await api.cancelTask(id); await loadTasks() } catch {} } + +async function handleRemove(id: string) { + try { + await api.removeTask(id) + tasks.value = tasks.value.filter(t => t.id !== id) + } catch {} +} diff --git a/frontend/src/api/wails.ts b/frontend/src/api/wails.ts index 68557ba..8900564 100644 --- a/frontend/src/api/wails.ts +++ b/frontend/src/api/wails.ts @@ -6,7 +6,7 @@ import type { HWEncoder, Accelerator, Task, -} from '../types' +} from '@/types' interface AppBindings { GetMediaInfo(inputFile: string): Promise @@ -23,6 +23,7 @@ interface AppBindings { SelectInputFile(): Promise SelectOutputFile(defaultName: string): Promise SelectSubtitleFile(): Promise + SelectOutputDir(): Promise } // Get the bound Go App instance @@ -44,6 +45,7 @@ export const api = { selectInputFile: () => getApp().SelectInputFile(), selectOutputFile: (name: string) => getApp().SelectOutputFile(name), selectSubtitleFile: () => getApp().SelectSubtitleFile(), + selectOutputDir: () => getApp().SelectOutputDir(), } // Event listeners for Wails events diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index cb84a1b..f4bf234 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/components/StreamInfo.vue b/frontend/src/components/StreamInfo.vue new file mode 100644 index 0000000..d083143 --- /dev/null +++ b/frontend/src/components/StreamInfo.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/frontend/src/components/TaskDrawer.vue b/frontend/src/components/TaskDrawer.vue index 0def5d6..06b7c6f 100644 --- a/frontend/src/components/TaskDrawer.vue +++ b/frontend/src/components/TaskDrawer.vue @@ -5,7 +5,7 @@
任务进度 - {{ runningTasks.length }} 个运行中 — {{ fmtFps(runningTasks[0].progress.fps) }} + {{ runningTasks.length }} 个运行中 — 速度:{{ fmtFps(runningTasks[0].progress.fps) }} FPS 空闲
@@ -27,7 +27,10 @@
{{ basename(t.inputFile) }} - {{ codecLabel(t) }} +
+ {{ typeLabel(t) }} + {{ codecLabel(t) }} +
@@ -72,10 +75,55 @@
已完成 ({{ doneTasks.length }})
-
- {{ basename(t.inputFile) }} - {{ statusLabel(t) }} -
+
@@ -88,9 +136,15 @@ import type { Task } from '../types' const props = defineProps<{ tasks: Task[] }>() defineEmits<{ cancel: [id: string] + remove: [id: string] }>() const expanded = ref(false) +const expandedDone = ref(null) + +function toggleDoneDetail(id: string) { + expandedDone.value = expandedDone.value === id ? null : id +} const runningTasks = computed(() => props.tasks.filter(t => t.status === 'running')) const queuedTasks = computed(() => props.tasks.filter(t => t.status === 'pending')) @@ -139,6 +193,14 @@ function fmtFps(v: number | undefined): string { if (v === undefined || v === null || isNaN(v) || v === 0) return '—' return v.toFixed(0) } + +function fmtTime(iso?: string): string { + if (!iso) return '' + try { + const d = new Date(iso) + return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` + } catch { return iso } +} diff --git a/frontend/src/style.css b/frontend/src/style.css index f75211c..70d3b74 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -214,6 +214,11 @@ input[readonly] { cursor: pointer; } gap: 16px; } +/* === Radio === */ +.output-options { display: flex; gap: 24px; margin-bottom: 12px; } +.radio-label { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); cursor: pointer; } +.radio-label input[type="radio"] { accent-color: var(--accent); width: 14px; height: 14px; margin: 0; cursor: pointer; } + /* === Scrollbar === */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7934e18..9ba933a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,5 +1,10 @@ // Shared types matching Go structs +export interface StreamTags { + language?: string + title?: string +} + export interface StreamInfo { index: number codec_type: string @@ -9,7 +14,7 @@ export interface StreamInfo { duration?: string bit_rate?: string r_frame_rate?: string - 'tags>language'?: string + tags?: StreamTags } export interface FormatInfo { @@ -67,6 +72,8 @@ export interface EncodeSettings { export interface RemuxSettings { outputFormat: string mapStreams: number[] + subFiles: string[] + audioFiles: string[] } export interface SubTrack { @@ -74,6 +81,8 @@ export interface SubTrack { index: number filePath: string language: string + alignment: number // 2=底部, 6=顶部, 10=中部; 0=不指定 + marginV: number // 垂直边距(px) } export interface SubtitleSettings { diff --git a/frontend/src/views/BurnPage.vue b/frontend/src/views/BurnPage.vue index 98ac5cf..5d5631a 100644 --- a/frontend/src/views/BurnPage.vue +++ b/frontend/src/views/BurnPage.vue @@ -1,6 +1,6 @@ @@ -266,8 +375,27 @@ function streamLabel(s: StreamInfo) { display: flex; flex-direction: column; gap: 12px; } .sub-card + .sub-card { margin-top: 8px; } -.sub-card-header { display: flex; justify-content: space-between; align-items: center; } -.sub-num { font-size: 13px; font-weight: 600; } +.sub-card-header { display: flex; align-items: center; gap: 6px; } +.sub-card-header-right { display: flex; align-items: center; gap: 2px; margin-left: auto; } +.sub-num { font-size: 13px; font-weight: 600; flex-shrink: 0; } +.sub-track-hint { + font-size: 11px; + color: var(--text-dim); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .submit-area { display: flex; justify-content: flex-end; padding-top: 4px; } + +.crf-group { display: flex; align-items: center; gap: 10px; } +.crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; } +input[type="range"] { accent-color: var(--accent); height: auto; padding: 0; box-shadow: none; flex: 1; } + +.fields-row.encode-params-row { + display: flex; + gap: 16px; + align-items: flex-end; +} diff --git a/frontend/src/views/EncodePage.vue b/frontend/src/views/EncodePage.vue index 1e1a4c0..a563a64 100644 --- a/frontend/src/views/EncodePage.vue +++ b/frontend/src/views/EncodePage.vue @@ -1,6 +1,6 @@ @@ -92,15 +86,21 @@
- - {{ encodeSettings.crf || 23 }} + {{ encodeSettings.crf ?? 23 }}
高质量低质量
- +
+ + +
@@ -114,11 +114,11 @@
- +
- +
@@ -134,17 +134,28 @@ -
-

输出位置

+
+ + +
+
+ + + WebM 仅支持 VP9/AV1 + Opus,请确认编码器兼容 +
- - + +
@@ -159,9 +170,10 @@ diff --git a/frontend/src/views/LogPage.vue b/frontend/src/views/LogPage.vue index 6198d6d..47225b6 100644 --- a/frontend/src/views/LogPage.vue +++ b/frontend/src/views/LogPage.vue @@ -23,7 +23,10 @@
- Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }} + Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }} +
@@ -56,6 +59,17 @@ const taskTabs = computed(() => tasks.value.filter(t => t.logs.length > 0 || t.s const activeTask = computed(() => tasks.value.find(t => t.id === activeTab.value)) const activeLogs = computed(() => activeTask.value?.logs || []) +const copied = ref(false) + +async function copyLogs() { + const text = activeLogs.value.join('\n') + if (!text) return + try { + await navigator.clipboard.writeText(text) + copied.value = true + setTimeout(() => { copied.value = false }, 1500) + } catch {} +} // Auto-scroll on new lines watch(activeLogs, () => { @@ -227,15 +241,21 @@ defineExpose({ upsertTask, appendLog }) } .log-head { + display: flex; + justify-content: space-between; + align-items: center; font-size: 11px; font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; color: var(--text-dim); - padding: 10px 12px; + padding: 8px 12px; border-bottom: 1px solid var(--border-light); background: var(--bg-surface); - white-space: nowrap; +} +.log-cmd { + flex: 1; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } .log-lines { @@ -249,6 +269,7 @@ defineExpose({ upsertTask, appendLog }) font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-size: 12px; line-height: 1.8; + user-select: text; } .log-line:hover { background: var(--bg-hover); } diff --git a/frontend/src/views/RemuxPage.vue b/frontend/src/views/RemuxPage.vue index 8d06098..a09317d 100644 --- a/frontend/src/views/RemuxPage.vue +++ b/frontend/src/views/RemuxPage.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/views/SettingsPage.vue b/frontend/src/views/SettingsPage.vue index 7285c54..9276a84 100644 --- a/frontend/src/views/SettingsPage.vue +++ b/frontend/src/views/SettingsPage.vue @@ -2,10 +2,50 @@

设置

+ +
+
+

FFmpeg 状态

+ +
+
+
+
+ + FFmpeg +
+
+ 版本 + {{ ffmpeg.ver || '未检测到' }} +
+
+ 路径 + {{ ffmpeg.path || '—' }} +
+
+
+
+ + FFprobe +
+
+ 版本 + {{ ffmpeg.probeVer || '未检测到' }} +
+
+ 路径 + {{ ffmpeg.probePath || '—' }} +
+
+
+
+

硬件检测

-
正在检测硬件编码器...
+
正在检测硬件...
@@ -50,10 +90,10 @@
- + - +
@@ -64,11 +104,14 @@ import { ref, computed, onMounted } from 'vue' import { api } from '../api/wails' +const emit = defineEmits<{ ffmpegChecked: [ok: boolean] }>() + interface EncoderCap { name: string; codec: string; label: string; available: boolean } interface GPUInfo { name: string; vendor: string; encoders: EncoderCap[] } -const loading = ref(true) +const detecting = ref(true) const gpus = ref([]) +const ffmpeg = ref({ ok: false, path: '', probePath: '', ver: '', probeVer: '' }) const outputDir = ref('') const namingRule = ref('{name}_{codec}') @@ -78,43 +121,79 @@ interface SelectOpt { key: string; label: string; available: boolean } const allOpts = computed(() => { const opts: SelectOpt[] = [] for (const g of gpus.value) { - opts.push({ key: g.vendor, label: g.name, available: hasAvailable(g) }) + const accel = vendorToAccel(g.vendor) + opts.push({ key: accel || g.vendor, label: g.name + (accel ? ` (${accel.toUpperCase()})` : ''), available: hasAvailable(g) }) } return opts }) -onMounted(async () => { +onMounted(() => detectAll()) + +async function detectAll() { + detecting.value = true try { const app = (window as any).go?.main?.App + + // Check ffmpeg + if (app?.CheckFFmpeg) { + const info = await app.CheckFFmpeg() + ffmpeg.value = { + ok: !!info.ffmpegVer, + path: info.ffmpegPath || '', + probePath: info.ffprobePath || '', + ver: info.ffmpegVer || '', + probeVer: info.ffprobeVer || '', + } + emit('ffmpegChecked', ffmpeg.value.ok) + } + + // Detect GPUs if (app?.GetGPUInfo) { gpus.value = await app.GetGPUInfo() || [] } else { - const encoders = await api.getHardwareEncoders() - gpus.value = buildFromEncoders(encoders) + try { + const encoders = await api.getHardwareEncoders() + gpus.value = buildFromEncoders(encoders) + } catch { gpus.value = [] } } - } catch { - try { - const encoders = await api.getHardwareEncoders() - gpus.value = buildFromEncoders(encoders) - } catch { gpus.value = [] } - } finally { - loading.value = false - // Default to first available hardware GPU - const firstAvail = gpus.value.find(g => g.vendor !== 'cpu' && hasAvailable(g)) - preferredAccel.value = firstAvail ? firstAvail.vendor : 'cpu' - syncAccel() - } -}) -function onPrefChange() { syncAccel() } + // Restore saved preference, or default to first available + if (app?.GetConfig) { + const saved = await app.GetConfig() + if (saved.preferredAccel) preferredAccel.value = saved.preferredAccel + if (saved.outputDir) outputDir.value = saved.outputDir + if (saved.namingRule) namingRule.value = saved.namingRule + } + if (!preferredAccel.value || !gpus.value.find((g: GPUInfo) => vendorToAccel(g.vendor) === preferredAccel.value)) { + const firstAvail = gpus.value.find((g: GPUInfo) => g.vendor !== 'cpu' && hasAvailable(g)) + preferredAccel.value = firstAvail ? vendorToAccel(firstAvail.vendor) : '' + } + syncAccel() + } catch (e) { + console.error('Detection failed:', e) + } finally { + detecting.value = false + } +} + +function onPrefChange() { + syncAccel() + saveSetting('preferredAccel', preferredAccel.value) +} + +function onNamingChange() { + saveSetting('namingRule', namingRule.value) +} + +async function saveSetting(key: string, value: string) { + try { + const app = (window as any).go?.main?.App + if (app?.SaveConfig) await app.SaveConfig(key, value) + } catch {} +} function syncAccel() { - const g = gpus.value.find(g => g.vendor === preferredAccel.value) - if (g && g.vendor !== 'cpu' && hasAvailable(g)) { - api.setHWAccel(vendorToAccel(g.vendor)) - } else { - api.setHWAccel('') - } + api.setHWAccel(preferredAccel.value || '') } function vendorToAccel(v: string): string { @@ -141,8 +220,11 @@ function buildFromEncoders(encoders: any[]): GPUInfo[] { async function selectOutputDir() { try { - const path = await api.selectOutputFile('') - if (path) outputDir.value = path.replace(/[^\\/]+$/, '') + const app = (window as any).go?.main?.App + if (app?.SelectOutputDir) { + const path = await app.SelectOutputDir() + if (path) { outputDir.value = path; saveSetting('outputDir', path) } + } } catch {} } @@ -152,13 +234,61 @@ async function selectOutputDir() { .settings-page > h2 { margin-bottom: 4px; } .loading-hint { color: var(--text-dim); padding: 12px 0; } +/* FFmpeg status */ +.ffmpeg-status { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.bin-card { + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} +.bin-card-header { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-light); +} +.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.status-dot.ok { background: var(--success); } +.status-dot.fail { background: var(--danger); } +.status-label { font-size: 14px; font-weight: 600; color: var(--text-primary); } +.bin-card-row { + display: flex; + align-items: baseline; + gap: 10px; + font-size: 13px; +} +.bin-card-key { + color: var(--text-dim); + font-size: 11px; + text-transform: uppercase; + min-width: 28px; + flex-shrink: 0; +} +.bin-card-val { + color: var(--text-primary); + flex: 1; + min-width: 0; +} +.bin-card-val.path { + color: var(--text-dim); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* GPU Cards */ .gpu-cards { display: flex; flex-direction: column; gap: 12px; } .gpu-card { border: 1px solid var(--border-light); border-radius: var(--radius); padding: 16px; background: var(--bg-surface); } .gpu-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } .gpu-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .gpu-dot.nvidia { background: #76b900; } .gpu-dot.intel { background: #00aaff; } -.gpu-dot.amd { background: #ed1c24; } .gpu-dot.cpu,.gpu-dot.unknown { background: var(--text-dim); } +.gpu-dot.amd { background: #ed1c24; } .gpu-dot.cpu { background: #6c5ce7; } .gpu-dot.unknown { background: var(--text-dim); } .gpu-name { font-size: 14px; font-weight: 600; flex: 1; } .gpu-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; } .gpu-badge.avail { background: #e6f4ea; color: var(--success); }