d131ba461c
Lint & Test / test (push) Successful in 20s
Security - Default scripts_management, callbacks_management, links_management, and media_folders_management to False so a leaked token cannot escalate to RCE through admin CRUD endpoints. - TokenSpec + scope hierarchy (read | control | admin); legacy bare-string api_tokens entries promote to admin for back-compat. Management endpoints now require admin scope. - WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>) preferred over ?token= query so the token no longer lands in URL/history/ Referer; query fallback retained for HA integration back-compat. - Origin allow-list check on the WS endpoint (CSWSH defence). - In-process token-bucket rate limiter: 5/min for failed auths, 10/min for /api/scripts/execute and /api/callbacks/execute. - shell=False subprocess path (shlex.split) + per-parameter regex `pattern` in ScriptParameterConfig to harden shell=true scripts against parameter injection (Windows cmd.exe env-var expansion). - CSP gains form-action, worker-src, manifest-src directives. - Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access logs; validate Gitea release tag against strict SemVer regex. - noopener noreferrer + no-referrer referrerpolicy on every outbound link. - icacls hardening of config.yaml on Windows (current user + SYSTEM + Administrators only); 0600 still enforced on POSIX. - WS volume handler clamps input and never drops the socket on bad messages. Performance - Album-art read in windows_media gated by track key — was decoding the WinRT thumbnail twice per second regardless of track changes. - /api/media/artwork returns content-derived ETag + Cache-Control so the browser sends If-None-Match and gets 304s on track repeats. - Foreground-service ctypes argtypes hoisted to one-time module init (was re-declaring ~14 prototypes per probe). - display_service _static_cache keyed by (edid_hash, ...) tuple with eviction of disappeared monitors — fixes stale capabilities on hot-plug swaps where the new topology has the same monitor count. - Visualizer rAF loop paused on document.hidden, resumed on visible. Reliability / bug fixes - Lifespan rewritten as try/yield/finally so a partial-startup failure cannot orphan background tasks or executors. - _run_callback in routes/media.py keeps a strong task ref (GC-safe) and uses the dedicated callback executor instead of the default pool. - macos_media.set_volume() no longer always returns True. - TrayManager._restart_requested initialised in __init__; set before signalling exit so the main thread observes it correctly. - Missing static_dir now logs a WARNING instead of silent UI disable. UX / accessibility / PWA - manifest.json theme_color and background_color match the Studio Reference base (#0E0D0B); added id and scope for PWA installability. - ARIA on mini-player icon buttons; inner SVGs marked aria-hidden. - OS mediaSession API wired so headset / lockscreen / Bluetooth buttons drive play/pause/next/prev/seek and show track metadata + artwork. Observability - X-Request-ID middleware (accept upstream id if it matches a safe regex, otherwise UUID4); request_id_var added to ContextVars and included in every log line alongside the token label. - Audit log (append-only JSONL) for every script + callback execution, including the on_play/on_pause/etc. event callbacks. Background-thread writer; queue capped; flushed in lifespan teardown. Deployment - proxy_headers + forwarded_allow_ips plumbed through Settings → uvicorn.Config for reverse-proxy installs. - HTTPS support via ssl_certfile + ssl_keyfile (+ optional password); startup refuses to launch with only one of the pair set. - Thumbnail cache moved from project-root .cache to %LOCALAPPDATA%/media-server/cache (Windows) and $XDG_CACHE_HOME/media-server/thumbnails (POSIX). Tests - 35 new tests across auth scopes, rate limiter, browser path traversal (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
829 lines
52 KiB
HTML
829 lines
52 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<title>Media Server</title>
|
|
<meta name="description" content="Remote media player control and file browser">
|
|
<meta name="theme-color" content="#121212">
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="apple-mobile-web-app-title" content="Media Server">
|
|
<link rel="manifest" href="/static/manifest.json">
|
|
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
|
|
<link rel="apple-touch-icon" href="/static/icons/icon.svg">
|
|
<link rel="stylesheet" href="/static/css/styles.css">
|
|
</head>
|
|
<body class="loading-translations">
|
|
<!-- Mini Player (sticky) -->
|
|
<div class="mini-player hidden" id="mini-player">
|
|
<div class="mini-player-info">
|
|
<img id="mini-album-art" class="mini-album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
|
|
<div class="mini-track-details">
|
|
<div id="mini-track-title" class="mini-track-title">No media playing</div>
|
|
<div id="mini-artist" class="mini-artist"></div>
|
|
</div>
|
|
</div>
|
|
<div class="mini-controls">
|
|
<button class="mini-control-btn mini-nav-btn" data-onclick="previousTrack()" data-i18n-title="player.previous" data-i18n-aria-label="player.previous" title="Previous" aria-label="Previous">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
|
</button>
|
|
<button class="mini-control-btn" data-onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause" aria-label="Play/Pause">
|
|
<svg viewBox="0 0 24 24" id="mini-play-pause-icon" aria-hidden="true" focusable="false">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
</button>
|
|
<button class="mini-control-btn mini-nav-btn" data-onclick="nextTrack()" data-i18n-title="player.next" data-i18n-aria-label="player.next" title="Next" aria-label="Next">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="mini-progress-container">
|
|
<div class="mini-time-display">
|
|
<span id="mini-current-time">0:00</span>
|
|
<span id="mini-total-time">0:00</span>
|
|
</div>
|
|
<div class="mini-progress-bar" id="mini-progress-bar" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
|
<div class="mini-progress-fill" id="mini-progress-fill"></div>
|
|
</div>
|
|
</div>
|
|
<div class="mini-volume-container">
|
|
<button class="mini-control-btn" data-onclick="toggleMute()" id="mini-btn-mute" title="Mute" aria-label="Mute" aria-pressed="false">
|
|
<svg viewBox="0 0 24 24" id="mini-mute-icon" aria-hidden="true" focusable="false">
|
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
|
</svg>
|
|
</button>
|
|
<input type="range" id="mini-volume-slider" class="mini-volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
|
<div class="mini-volume-display" id="mini-volume-display">50%</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dynamic Background -->
|
|
<canvas id="bg-shader-canvas" class="bg-shader-canvas"></canvas>
|
|
|
|
<!-- Auth Modal -->
|
|
<div id="auth-overlay" class="hidden">
|
|
<div class="auth-modal">
|
|
<h2 data-i18n="app.title">Media Server</h2>
|
|
<p data-i18n="auth.message">Enter your API token to connect to the media server.</p>
|
|
<input type="text" id="token-input" data-i18n-placeholder="auth.placeholder" placeholder="Enter API Token" autocomplete="off">
|
|
<button class="btn-connect" data-onclick="authenticate()" data-i18n="auth.connect">Connect</button>
|
|
<div class="help-text">
|
|
<p data-i18n="auth.help">To get your token, run:</p>
|
|
<code>media-server --show-token</code>
|
|
</div>
|
|
<div class="error-message" id="auth-error"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Folio marks at page corners -->
|
|
<span class="folio tl"><span class="status-dot" id="status-dot" aria-live="polite"></span><span data-i18n="header.connected">Connected</span> · <span id="folio-host">Local 8765</span></span>
|
|
<span class="folio tr"><span data-i18n="header.volume">Vol. I</span> — <span data-i18n="header.edition">Studio Reference</span> · <span id="version-label"></span></span>
|
|
|
|
<div class="container">
|
|
<header>
|
|
<div class="brand">
|
|
<span class="brand-name" data-i18n="app.title">Media Server</span>
|
|
<span class="brand-sub" data-i18n="header.edition_sub">Studio Reference Edition</span>
|
|
</div>
|
|
<div class="header-toolbar">
|
|
<div id="headerLinks" class="header-links"></div>
|
|
<a class="header-btn" href="/docs" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" title="API Documentation" aria-label="API Documentation">
|
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
|
|
</a>
|
|
<button class="header-btn" data-onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
|
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M11 7h2v2h-2V7zm0 4h2v6h-2v-6zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>
|
|
</button>
|
|
<div class="accent-picker">
|
|
<button class="header-btn" data-onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
|
|
<span class="accent-dot" id="accentDot"></span>
|
|
</button>
|
|
<div class="accent-picker-dropdown" id="accentDropdown"></div>
|
|
</div>
|
|
<button class="header-btn" data-onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
|
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
|
|
</button>
|
|
<button class="header-btn" data-onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen" title="Fullscreen player" aria-label="Fullscreen player" id="fullscreenToggle">
|
|
<svg id="fullscreen-icon-enter" viewBox="0 0 24 24"><path fill="currentColor" d="M5 5h5v2H7v3H5V5zm9 0h5v5h-2V7h-3V5zm0 14v-2h3v-3h2v5h-5zM5 14h2v3h3v2H5v-5z"/></svg>
|
|
<svg id="fullscreen-icon-exit" viewBox="0 0 24 24" style="display:none"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
|
</button>
|
|
<button class="header-btn" data-onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
|
|
<svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
|
|
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
|
|
</svg>
|
|
<svg id="theme-icon-moon" viewBox="0 0 24 24">
|
|
<path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
|
|
</svg>
|
|
</button>
|
|
<select id="locale-select" class="header-locale" data-onchange="changeLocale()" title="Change language">
|
|
<option value="en">EN</option>
|
|
<option value="ru">RU</option>
|
|
</select>
|
|
<span class="header-toolbar-sep"></span>
|
|
<button class="header-btn header-btn-logout" data-onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
|
|
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Update Banner -->
|
|
<div class="update-banner hidden" id="updateBanner">
|
|
<span id="updateBannerText"></span>
|
|
<a id="updateBannerLink" href="#" target="_blank" rel="noopener noreferrer" data-i18n="update.view_release">View Release</a>
|
|
<button class="update-banner-close" id="updateBannerClose">×</button>
|
|
</div>
|
|
|
|
<!-- Connection Banner -->
|
|
<div class="connection-banner hidden" id="connectionBanner">
|
|
<span id="connectionBannerText"></span>
|
|
<button class="connection-banner-btn" id="connectionBannerBtn" data-onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
|
|
</div>
|
|
|
|
<!-- Tab Bar (editorial: numbered, italic active) -->
|
|
<div class="tab-bar" id="tabBar" role="tablist">
|
|
<div class="tab-indicator" id="tabIndicator"></div>
|
|
<button class="tab-btn active" data-tab="player" data-onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
|
|
<span class="tab-num">01</span>
|
|
<span data-i18n="tab.player">Now Spinning</span>
|
|
</button>
|
|
<button class="tab-btn" data-tab="display" data-onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
|
|
<span class="tab-num">02</span>
|
|
<span data-i18n="tab.display">Display</span>
|
|
</button>
|
|
<button class="tab-btn" data-tab="browser" data-onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
|
|
<span class="tab-num">03</span>
|
|
<span data-i18n="tab.browser">Library</span>
|
|
</button>
|
|
<button class="tab-btn" data-tab="quick-actions" data-onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
|
|
<span class="tab-num">04</span>
|
|
<span data-i18n="tab.quick_access">Quick Access</span>
|
|
</button>
|
|
<button class="tab-btn" data-tab="settings" data-onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
|
|
<span class="tab-num">05</span>
|
|
<span data-i18n="tab.settings">Settings</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
|
<!-- Fullscreen-only chrome: floating top strip with kicker + exit. Auto-hides on idle. -->
|
|
<div class="fs-chrome" id="fsChrome" aria-hidden="true">
|
|
<div class="fs-chrome-mark">
|
|
<span class="fs-chrome-edition" data-i18n="header.edition">Studio Reference</span>
|
|
<span class="fs-chrome-sep">·</span>
|
|
<span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span>
|
|
</div>
|
|
<button class="fs-chrome-exit" data-onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen.exit" title="Exit fullscreen" aria-label="Exit fullscreen">
|
|
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
|
<span data-i18n="player.fullscreen.exit_short">Exit</span>
|
|
<kbd class="fs-chrome-kbd">ESC</kbd>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Ambient album-art bloom: paints the room in the record's color while in fullscreen -->
|
|
<div class="fs-bloom" id="fsBloom" aria-hidden="true">
|
|
<img id="fs-bloom-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="">
|
|
</div>
|
|
|
|
<section class="now-playing player-layout">
|
|
|
|
<!-- Vinyl stage: cardstock sleeve + disc peeking out, plus tonearm -->
|
|
<div class="vinyl-stage album-art-container">
|
|
<img id="album-art-glow" class="album-art-glow" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="" aria-hidden="true">
|
|
<div class="sleeve">
|
|
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
|
|
<div class="sleeve-grain" aria-hidden="true"></div>
|
|
<div class="sleeve-corner" aria-hidden="true"></div>
|
|
</div>
|
|
<div class="vinyl-wrap">
|
|
<div class="vinyl">
|
|
<div class="vinyl-label">
|
|
<!-- Stylised record-label catalogue mark, not user-facing
|
|
copy — intentionally not in the i18n bundle. -->
|
|
<span class="vinyl-label-text">REF · 24</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
|
<defs>
|
|
<linearGradient id="armGrad" x1="0" x2="1">
|
|
<stop offset="0" stop-color="#6d5f44"/>
|
|
<stop offset="0.5" stop-color="#d8c39a"/>
|
|
<stop offset="1" stop-color="#8a7a5a"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<circle cx="176" cy="24" r="14" fill="#2a241c" stroke="#9C835A" stroke-width="1.5"/>
|
|
<circle cx="176" cy="24" r="6" fill="#5C5447"/>
|
|
<circle cx="176" cy="24" r="2.5" fill="#E08038"/>
|
|
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad)" stroke-width="5" stroke-linecap="round"/>
|
|
<rect x="180" y="14" width="14" height="20" fill="#3A3528" stroke="#9C835A" stroke-width="1"/>
|
|
<rect x="56" y="128" width="22" height="18" rx="2" fill="#3A3528" stroke="#9C835A" stroke-width="1" transform="rotate(-45 67 137)"/>
|
|
<circle cx="62" cy="138" r="3.5" fill="#E08038" opacity="0.95"/>
|
|
<circle cx="62" cy="138" r="7" fill="none" stroke="#E08038" stroke-width="0.8" opacity="0.5"/>
|
|
</svg>
|
|
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
|
|
</div>
|
|
|
|
<!-- Track masthead -->
|
|
<div class="track-masthead player-details">
|
|
|
|
<div class="kicker"><span data-i18n="player.kicker">Now Playing</span></div>
|
|
|
|
<h1 class="track-title" id="track-title" data-i18n="player.no_media">No media playing</h1>
|
|
<div class="track-byline" id="artist"></div>
|
|
<div class="track-album" id="album"></div>
|
|
|
|
<!-- 2-cell metadata grid -->
|
|
<div class="meta-grid meta-grid-2">
|
|
<div class="meta-cell">
|
|
<div class="label" data-i18n="meta.state">State</div>
|
|
<div class="value">
|
|
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
|
</svg>
|
|
<span id="playback-state" data-i18n="state.idle">Idle</span>
|
|
</div>
|
|
</div>
|
|
<div class="meta-cell">
|
|
<div class="label" data-i18n="meta.source">Source</div>
|
|
<div class="value source-value">
|
|
<span class="source-icon" id="sourceIcon"></span>
|
|
<span id="source" data-i18n="player.unknown_source">Unknown</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Spectrum bars — driven by real audio when visualizer is active,
|
|
CSS-animated synthetic motion otherwise. JS injects the spans. -->
|
|
<div class="spectrum" id="player-spectrum" aria-hidden="true"></div>
|
|
|
|
<!-- Transport -->
|
|
<div class="transport">
|
|
<div class="progress-row">
|
|
<span class="timecode elapsed" id="current-time">0:00</span>
|
|
<div class="progress-track progress-bar" id="progress-bar" data-duration="0" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
|
<div class="progress-fill" id="progress-fill"></div>
|
|
</div>
|
|
<span class="timecode" id="total-time">0:00</span>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<button class="btn-trans" data-onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
|
|
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
|
</button>
|
|
<button class="btn-trans primary" data-onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
|
|
<svg viewBox="0 0 24 24" id="play-pause-icon"><path d="M8 5v14l11-7z"/></svg>
|
|
</button>
|
|
<button class="btn-trans" data-onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
|
|
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
|
</button>
|
|
|
|
<div class="vu-cluster">
|
|
<div class="vu-meter" aria-hidden="true">
|
|
<div class="vu-needle" id="vuNeedle"></div>
|
|
</div>
|
|
<div class="vu-readout">
|
|
<span>OUT <strong id="vu-out">SYS</strong></span>
|
|
<span>VOL <strong id="vu-vol">50%</strong></span>
|
|
</div>
|
|
<!-- Volume control: mute + slim slider, integrated -->
|
|
<div class="vu-volume">
|
|
<button class="mute-btn" data-onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
|
|
<svg viewBox="0 0 24 24" id="mute-icon">
|
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
|
</svg>
|
|
</button>
|
|
<input type="range" id="volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden but functional: legacy display + visualizer toggle. -->
|
|
<div class="visually-hidden">
|
|
<div id="volume-display">50%</div>
|
|
<button data-onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
|
|
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Media Browser Section -->
|
|
<div class="browser-container" data-tab-content="browser" role="tabpanel" id="panel-browser">
|
|
<!-- Breadcrumb Navigation -->
|
|
<div class="breadcrumb" id="breadcrumb"></div>
|
|
|
|
<!-- Browser Toolbar -->
|
|
<div class="browser-toolbar" id="browserToolbar">
|
|
<div class="browser-toolbar-left">
|
|
<div class="view-toggle">
|
|
<button class="view-toggle-btn active" id="viewGridBtn" data-onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view">
|
|
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/></svg>
|
|
</button>
|
|
<button class="view-toggle-btn" id="viewCompactBtn" data-onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view">
|
|
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M2 2h5v5H2V2zm0 8h5v5H2v-5zm0 8h5v5H2v-5zm7-16h5v5H9V2zm0 8h5v5H9v-5zm0 8h5v5H9v-5zm7-16h5v5h-5V2zm0 8h5v5h-5v-5zm0 8h5v5h-5v-5z"/></svg>
|
|
</button>
|
|
<button class="view-toggle-btn" id="viewListBtn" data-onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view">
|
|
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
|
|
</button>
|
|
</div>
|
|
<button class="view-toggle-btn browser-refresh-btn" id="refreshBtn" data-onclick="refreshBrowser()" data-i18n-title="browser.refresh" title="Refresh">
|
|
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
|
</button>
|
|
<button class="browser-play-all-btn" id="playAllBtn" data-onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;">
|
|
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="browser-search-wrapper" id="browserSearchWrapper" style="display: none;">
|
|
<svg class="browser-search-icon" viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
|
<input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." data-oninput="onBrowserSearch()">
|
|
<button class="browser-search-clear" id="browserSearchClear" data-onclick="clearBrowserSearch()" style="display: none;">
|
|
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="browser-toolbar-right">
|
|
<label class="items-per-page-label">
|
|
<span data-i18n="browser.items_per_page">Items per page:</span>
|
|
<select id="itemsPerPageSelect" data-onchange="onItemsPerPageChanged()">
|
|
<option value="25">25</option>
|
|
<option value="50">50</option>
|
|
<option value="100" selected>100</option>
|
|
<option value="200">200</option>
|
|
<option value="500">500</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File/Folder Grid -->
|
|
<div class="browser-grid" id="browserGrid">
|
|
<div class="browser-empty empty-state-illustration">
|
|
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
|
|
<p data-i18n="browser.no_folder_selected">Select a folder to browse media files</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div class="pagination" id="browserPagination" style="display: none;">
|
|
<button id="prevPage" data-onclick="previousPage()" data-i18n="browser.previous">Previous</button>
|
|
<div class="pagination-center">
|
|
<span data-i18n="browser.page">Page</span>
|
|
<input type="number" id="pageInput" class="page-input" min="1" value="1" data-onchange="goToPage()">
|
|
<span id="pageTotal">/ 1</span>
|
|
</div>
|
|
<button id="nextPage" data-onclick="nextPage()" data-i18n="browser.next">Next</button>
|
|
<span class="pagination-showing" id="paginationShowing"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scripts Section (Quick Actions) -->
|
|
<div class="scripts-container" data-tab-content="quick-actions" role="tabpanel" id="panel-quick-actions">
|
|
<div class="scripts-grid" id="scripts-grid">
|
|
<div class="scripts-empty empty-state-illustration">
|
|
<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg>
|
|
<p data-i18n="quick_access.no_items">No quick actions or links configured</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Settings Section (Scripts, Callbacks, Links management) -->
|
|
<div class="settings-container" data-tab-content="settings" role="tabpanel" id="panel-settings">
|
|
<details class="settings-section" open id="audioDeviceSection" style="display: none;">
|
|
<summary data-i18n="settings.section.audio">Audio</summary>
|
|
<div class="settings-section-content">
|
|
<p class="settings-section-description" data-i18n="settings.audio.description">
|
|
Select which audio output device to capture for the visualizer.
|
|
</p>
|
|
<div class="audio-device-selector">
|
|
<label>
|
|
<span data-i18n="settings.audio.device">Loopback Device</span>
|
|
<select id="audioDeviceSelect" data-onchange="onAudioDeviceChanged()">
|
|
<option value="" data-i18n="settings.audio.auto">Auto-detect</option>
|
|
</select>
|
|
</label>
|
|
<div class="audio-device-status" id="audioDeviceStatus"></div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="settings-section" open id="mediaFoldersSection" style="display: none;">
|
|
<summary data-i18n="settings.section.media_folders">Media Folders</summary>
|
|
<div class="settings-section-content">
|
|
<p class="settings-section-description" data-i18n="browser.folders_description">
|
|
Media folders available for browsing. Folders on network shares show availability status.
|
|
</p>
|
|
<table class="scripts-table">
|
|
<thead>
|
|
<tr>
|
|
<th data-i18n="browser.folders_table.id">ID</th>
|
|
<th data-i18n="browser.folders_table.label">Label</th>
|
|
<th data-i18n="browser.folders_table.path">Path</th>
|
|
<th data-i18n="browser.folders_table.status">Status</th>
|
|
<th data-i18n="browser.folders_table.actions">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="foldersTableBody">
|
|
<tr>
|
|
<td colspan="5" class="empty-state">
|
|
<div class="empty-state-illustration">
|
|
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
|
|
<p data-i18n="browser.folders_empty">No media folders configured. Click "+" to add one.</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="add-card" data-onclick="showAddFolderDialog()">
|
|
<span class="add-card-icon">+</span>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="settings-section" open>
|
|
<summary data-i18n="settings.section.scripts">Scripts</summary>
|
|
<div class="settings-section-content">
|
|
<table class="scripts-table">
|
|
<thead>
|
|
<tr>
|
|
<th data-i18n="scripts.table.name">Name</th>
|
|
<th data-i18n="scripts.table.label">Label</th>
|
|
<th data-i18n="scripts.table.command">Command</th>
|
|
<th data-i18n="scripts.table.timeout">Timeout</th>
|
|
<th data-i18n="scripts.table.actions">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="scriptsTableBody">
|
|
<tr>
|
|
<td colspan="5" class="empty-state">
|
|
<div class="empty-state-illustration">
|
|
<svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg>
|
|
<p data-i18n="scripts.empty">No scripts configured. Click "Add" to create one.</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="add-card" data-onclick="showAddScriptDialog()">
|
|
<span class="add-card-icon">+</span>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="settings-section" open>
|
|
<summary data-i18n="settings.section.links">Links</summary>
|
|
<div class="settings-section-content">
|
|
<p class="settings-section-description" data-i18n="links.description">
|
|
Quick links displayed as icons in the header bar. Click an icon to open the URL in a new tab.
|
|
</p>
|
|
<table class="scripts-table">
|
|
<thead>
|
|
<tr>
|
|
<th data-i18n="links.table.name">Name</th>
|
|
<th data-i18n="links.table.url">URL</th>
|
|
<th data-i18n="links.table.label">Label</th>
|
|
<th data-i18n="links.table.actions">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="linksTableBody">
|
|
<tr>
|
|
<td colspan="4" class="empty-state">
|
|
<div class="empty-state-illustration">
|
|
<svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg>
|
|
<p data-i18n="links.empty">No links configured. Click "Add" to create one.</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="add-card" data-onclick="showAddLinkDialog()">
|
|
<span class="add-card-icon">+</span>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="settings-section" open>
|
|
<summary data-i18n="settings.section.callbacks">Callbacks</summary>
|
|
<div class="settings-section-content">
|
|
<p class="settings-section-description" data-i18n="callbacks.description">
|
|
Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)
|
|
</p>
|
|
<table class="scripts-table">
|
|
<thead>
|
|
<tr>
|
|
<th data-i18n="callbacks.table.event">Event</th>
|
|
<th data-i18n="callbacks.table.command">Command</th>
|
|
<th data-i18n="callbacks.table.timeout">Timeout</th>
|
|
<th data-i18n="callbacks.table.actions">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="callbacksTableBody">
|
|
<tr>
|
|
<td colspan="4" class="empty-state">
|
|
<div class="empty-state-illustration">
|
|
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
|
|
<p data-i18n="callbacks.empty">No callbacks configured. Click "Add" to create one.</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="add-card" data-onclick="showAddCallbackDialog()">
|
|
<span class="add-card-icon">+</span>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<!-- Display Control Section (monitors first, foreground overview below) -->
|
|
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
|
|
<div class="display-monitors" id="displayMonitors">
|
|
<div class="empty-state-illustration">
|
|
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
|
|
<p data-i18n="display.loading">Loading monitors...</p>
|
|
</div>
|
|
</div>
|
|
<div class="foreground-stage" id="foregroundStage">
|
|
<div class="empty-state-illustration">
|
|
<svg viewBox="0 0 64 64"><rect x="6" y="12" width="52" height="36" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="20" x2="58" y2="20" stroke="currentColor" stroke-width="2"/><circle cx="11" cy="16" r="1.4" fill="currentColor"/><circle cx="15" cy="16" r="1.4" fill="currentColor"/><circle cx="19" cy="16" r="1.4" fill="currentColor"/></svg>
|
|
<p data-i18n="foreground.loading">Waiting for foreground signal…</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add/Edit Script Dialog -->
|
|
<dialog id="scriptDialog">
|
|
<div class="dialog-header">
|
|
<h3 id="dialogTitle" data-i18n="scripts.dialog.add">Add Script</h3>
|
|
</div>
|
|
<form id="scriptForm" data-onsubmit="saveScript(event)">
|
|
<div class="dialog-body">
|
|
<input type="hidden" id="scriptOriginalName">
|
|
<input type="hidden" id="scriptIsEdit">
|
|
|
|
<label>
|
|
<span data-i18n="scripts.field.name">Script Name *</span>
|
|
<input type="text" id="scriptName" required pattern="[a-zA-Z0-9_]+"
|
|
data-i18n-title="scripts.placeholder.name" title="Only letters, numbers, and underscores allowed" maxlength="64">
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="scripts.field.label">Label</span>
|
|
<input type="text" id="scriptLabel" data-i18n-placeholder="scripts.placeholder.label" placeholder="Human-readable name">
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="scripts.field.command">Command *</span>
|
|
<textarea id="scriptCommand" required rows="3" data-i18n-placeholder="scripts.placeholder.command" placeholder="e.g., shutdown /s /t 0"></textarea>
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="scripts.field.description">Description</span>
|
|
<textarea id="scriptDescription" data-i18n-placeholder="scripts.placeholder.description" placeholder="What does this script do?"></textarea>
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="scripts.field.icon">Icon (MDI)</span>
|
|
<div class="icon-input-wrapper">
|
|
<input type="text" id="scriptIcon" data-i18n-placeholder="scripts.placeholder.icon" placeholder="e.g., mdi:power">
|
|
<div class="icon-preview" id="scriptIconPreview"></div>
|
|
</div>
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="scripts.field.timeout">Timeout (seconds)</span>
|
|
<input type="number" id="scriptTimeout" value="30" min="1" max="300">
|
|
</label>
|
|
|
|
<div class="params-section">
|
|
<div class="params-header">
|
|
<span data-i18n="scripts.field.parameters">Parameters</span>
|
|
<button type="button" class="btn-small" data-onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button>
|
|
</div>
|
|
<div id="scriptParamsContainer"></div>
|
|
</div>
|
|
</div>
|
|
<div class="dialog-footer">
|
|
<button type="button" class="btn-secondary" data-onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
|
<button type="submit" class="btn-primary" data-i18n="scripts.button.save">Save</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
|
|
<dialog id="scriptParamsDialog">
|
|
<div class="dialog-header">
|
|
<h3 id="scriptParamsDialogTitle" data-i18n="scripts.params.execute">Execute Script</h3>
|
|
</div>
|
|
<form id="scriptParamsForm" data-onsubmit="submitScriptWithParams(event)">
|
|
<div class="dialog-body">
|
|
<div id="scriptParamsInputs"></div>
|
|
</div>
|
|
<div class="dialog-footer">
|
|
<button type="button" class="btn-secondary" data-onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
|
<button type="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<!-- Add/Edit Callback Dialog -->
|
|
<dialog id="callbackDialog">
|
|
<div class="dialog-header">
|
|
<h3 id="callbackDialogTitle" data-i18n="callbacks.dialog.add">Add Callback</h3>
|
|
</div>
|
|
<form id="callbackForm" data-onsubmit="saveCallback(event)">
|
|
<div class="dialog-body">
|
|
<input type="hidden" id="callbackIsEdit">
|
|
|
|
<label>
|
|
<span data-i18n="callbacks.field.event">Event *</span>
|
|
<select id="callbackName" required>
|
|
<option value="" data-i18n="callbacks.placeholder.event">Select event...</option>
|
|
<option value="on_play" data-i18n="callbacks.event.on_play">on_play - After play succeeds</option>
|
|
<option value="on_pause" data-i18n="callbacks.event.on_pause">on_pause - After pause succeeds</option>
|
|
<option value="on_stop" data-i18n="callbacks.event.on_stop">on_stop - After stop succeeds</option>
|
|
<option value="on_next" data-i18n="callbacks.event.on_next">on_next - After next track succeeds</option>
|
|
<option value="on_previous" data-i18n="callbacks.event.on_previous">on_previous - After previous track succeeds</option>
|
|
<option value="on_volume" data-i18n="callbacks.event.on_volume">on_volume - After volume change</option>
|
|
<option value="on_mute" data-i18n="callbacks.event.on_mute">on_mute - After mute toggle</option>
|
|
<option value="on_seek" data-i18n="callbacks.event.on_seek">on_seek - After seek succeeds</option>
|
|
<option value="on_turn_on" data-i18n="callbacks.event.on_turn_on">on_turn_on - Callback-only action</option>
|
|
<option value="on_turn_off" data-i18n="callbacks.event.on_turn_off">on_turn_off - Callback-only action</option>
|
|
<option value="on_toggle" data-i18n="callbacks.event.on_toggle">on_toggle - Callback-only action</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="callbacks.field.command">Command *</span>
|
|
<textarea id="callbackCommand" required rows="3" data-i18n-placeholder="callbacks.placeholder.command" placeholder="e.g., shutdown /s /t 0"></textarea>
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="callbacks.field.timeout">Timeout (seconds)</span>
|
|
<input type="number" id="callbackTimeout" value="30" min="1" max="300">
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="callbacks.field.workdir">Working Directory</span>
|
|
<input type="text" id="callbackWorkingDir" data-i18n-placeholder="callbacks.placeholder.workdir" placeholder="Optional">
|
|
</label>
|
|
</div>
|
|
<div class="dialog-footer">
|
|
<button type="button" class="btn-secondary" data-onclick="closeCallbackDialog()" data-i18n="callbacks.button.cancel">Cancel</button>
|
|
<button type="submit" class="btn-primary" data-i18n="callbacks.button.save">Save</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<!-- Add/Edit Link Dialog -->
|
|
<dialog id="linkDialog">
|
|
<div class="dialog-header">
|
|
<h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3>
|
|
</div>
|
|
<form id="linkForm" data-onsubmit="saveLink(event)">
|
|
<div class="dialog-body">
|
|
<input type="hidden" id="linkOriginalName">
|
|
<input type="hidden" id="linkIsEdit">
|
|
|
|
<label>
|
|
<span data-i18n="links.field.name">Link Name *</span>
|
|
<input type="text" id="linkName" required pattern="[a-zA-Z0-9_]+"
|
|
data-i18n-title="links.placeholder.name" title="Only letters, numbers, and underscores allowed" maxlength="64">
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="links.field.url">URL *</span>
|
|
<input type="url" id="linkUrl" required data-i18n-placeholder="links.placeholder.url" placeholder="https://example.com">
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="links.field.icon">Icon (MDI)</span>
|
|
<div class="icon-input-wrapper">
|
|
<input type="text" id="linkIcon" data-i18n-placeholder="links.placeholder.icon" placeholder="mdi:link">
|
|
<div class="icon-preview" id="linkIconPreview"></div>
|
|
</div>
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="links.field.label">Label</span>
|
|
<input type="text" id="linkLabel" data-i18n-placeholder="links.placeholder.label" placeholder="Tooltip text">
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="links.field.description">Description</span>
|
|
<textarea id="linkDescription" data-i18n-placeholder="links.placeholder.description" placeholder="What does this link point to?"></textarea>
|
|
</label>
|
|
</div>
|
|
<div class="dialog-footer">
|
|
<button type="button" class="btn-secondary" data-onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
|
|
<button type="submit" class="btn-primary" data-i18n="links.button.save">Save</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<!-- Execution Result Dialog -->
|
|
<dialog id="executionDialog">
|
|
<div class="dialog-header">
|
|
<h3 id="executionDialogTitle" data-i18n="scripts.execution.title">Execution Result</h3>
|
|
</div>
|
|
<div class="dialog-body">
|
|
<div class="execution-status" id="executionStatus"></div>
|
|
<div class="result-section" id="outputSection" style="display: none;">
|
|
<h4 data-i18n="scripts.execution.output">Output</h4>
|
|
<div class="execution-result">
|
|
<pre id="executionOutput"></pre>
|
|
</div>
|
|
</div>
|
|
<div class="result-section" id="errorSection" style="display: none;">
|
|
<h4 data-i18n="scripts.execution.error_output">Error Output</h4>
|
|
<div class="execution-result">
|
|
<pre id="executionError"></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="dialog-footer">
|
|
<button type="button" class="btn-secondary" data-onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button>
|
|
</div>
|
|
</dialog>
|
|
|
|
<!-- Folder Management Dialog -->
|
|
<dialog id="folderDialog">
|
|
<div class="dialog-header">
|
|
<h3 id="folderDialogTitle" data-i18n="browser.folder_dialog.title_add">Add Media Folder</h3>
|
|
</div>
|
|
<form id="folderForm" data-onsubmit="saveFolder(event)">
|
|
<div class="dialog-body">
|
|
<input type="hidden" id="folderIsEdit">
|
|
<input type="hidden" id="folderOriginalId">
|
|
|
|
<label>
|
|
<span data-i18n="browser.folder_dialog.folder_id">Folder ID *</span>
|
|
<input type="text" id="folderId" required pattern="[a-zA-Z0-9_]+"
|
|
data-i18n-title="browser.folder_dialog.folder_id_help" title="Alphanumeric and underscore only" maxlength="32">
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="browser.folder_dialog.label">Label *</span>
|
|
<input type="text" id="folderLabel" required data-i18n-placeholder="browser.folder_dialog.label_help" placeholder="Display name">
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="browser.folder_dialog.path">Path *</span>
|
|
<input type="text" id="folderPath" required data-i18n-placeholder="browser.folder_dialog.path_help" placeholder="C:\Users\YourName\Music">
|
|
</label>
|
|
|
|
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
|
<input type="checkbox" id="folderEnabled" checked style="width: auto; margin: 0;">
|
|
<span data-i18n="browser.folder_dialog.enabled">Enabled</span>
|
|
</label>
|
|
</div>
|
|
<div class="dialog-footer">
|
|
<button type="button" class="btn-secondary" data-onclick="closeFolderDialog()" data-i18n="browser.folder_dialog.cancel">Cancel</button>
|
|
<button type="submit" class="btn-primary" data-i18n="browser.folder_dialog.save">Save</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<!-- Confirm Dialog -->
|
|
<dialog id="confirmDialog" class="confirm-dialog">
|
|
<p id="confirmDialogMessage"></p>
|
|
<div class="confirm-dialog-actions">
|
|
<button type="button" class="btn-cancel" id="confirmDialogCancel" data-i18n="dialog.cancel">Cancel</button>
|
|
<button type="button" class="btn-danger" id="confirmDialogConfirm" data-i18n="dialog.confirm">Confirm</button>
|
|
</div>
|
|
</dialog>
|
|
|
|
<!-- Toast Notifications -->
|
|
<div class="toast-container" id="toast-container"></div>
|
|
|
|
<!-- About Dialog -->
|
|
<dialog id="aboutDialog" class="about-dialog">
|
|
<div class="dialog-header">
|
|
<h3 data-i18n="about.title">About</h3>
|
|
</div>
|
|
<div class="dialog-body">
|
|
<p class="about-credit">
|
|
<span data-i18n="about.created_by">Created by</span>
|
|
<strong>Alexei Dolgolyov</strong>
|
|
</p>
|
|
<ul class="about-links">
|
|
<li>
|
|
<span class="about-links-label" data-i18n="about.email">Email</span>
|
|
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
|
</li>
|
|
<li>
|
|
<span class="about-links-label" data-i18n="about.repository">Repository</span>
|
|
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="about.source_code">Source Code</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div class="dialog-footer">
|
|
<button type="button" class="btn-secondary" data-onclick="closeAboutDialog()" data-i18n="dialog.close">Close</button>
|
|
</div>
|
|
</dialog>
|
|
|
|
<script src="/static/dist/app.bundle.js"></script>
|
|
</body>
|
|
</html>
|