Files
media-player-server/media_server/static/index.html
T
alexei.dolgolyov 14e9f2294e feat(ui): rebuild player view to match Studio Reference mockup
Restructures the player tab DOM to actually look like the editorial
mockup, not just inherit new fonts. The previous commit only swapped
tokens & typography on the legacy Spotify-clone layout.

DOM additions (all preserve existing JS-touched IDs):
- Vinyl stage: rotating vinyl wrapping the existing #album-art as a
  circular center label; spins only when state=playing via CSS hook
- SVG tonearm: pivots in/out based on data-playstate
- Kicker line: copper italic mono header above the track title
- Editorial 4-cell metadata grid: State / Source / Elapsed / Length
- Decorative spectrum bars (30, CSS-only animation, paused when idle)
- VU meter cluster: needle visual driven by volume %, alongside the
  preserved volume slider for a11y
- Folio marks: top-left and top-right of the player container

JS hooks (small, additive):
- updatePlaybackState now sets :root[data-playstate] for CSS
- progress tick mirrors timecode into meta-grid cells
- volume update rotates the VU needle
- folio-version mirrors the version label

i18n:
- new keys: player.kicker, player.modes, player.folio_*, meta.*
- added to both en.json and ru.json

Restored: media_server/static/redesign-mockup.html (Studio Reference
visual reference; deleting it in the prior commit was a mistake).
2026-04-25 01:24:11 +03:00

797 lines
50 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="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" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button class="mini-control-btn" onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause">
<svg viewBox="0 0 24 24" id="mini-play-pause-icon">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<button class="mini-control-btn mini-nav-btn" onclick="nextTrack()" data-i18n-title="player.next" title="Next">
<svg viewBox="0 0 24 24"><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" onclick="toggleMute()" id="mini-btn-mute" title="Mute">
<svg viewBox="0 0 24 24" id="mini-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="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" 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>
<div class="container">
<header>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span class="status-dot" id="status-dot" aria-live="polite"></span>
<span class="version-label" id="version-label"></span>
</div>
<div class="header-toolbar">
<div id="headerLinks" class="header-links"></div>
<a class="header-btn" href="/docs" target="_blank" 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>
<div class="accent-picker">
<button class="header-btn" 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" 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" 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" 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" 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">&times;</button>
</div>
<!-- Connection Banner -->
<div class="connection-banner hidden" id="connectionBanner">
<span id="connectionBannerText"></span>
<button class="connection-banner-btn" id="connectionBannerBtn" onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
</div>
<!-- Tab Bar -->
<div class="tab-bar" id="tabBar" role="tablist">
<div class="tab-indicator" id="tabIndicator"></div>
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
<span data-i18n="tab.player">Player</span>
</button>
<button class="tab-btn" data-tab="display" onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/></svg>
<span data-i18n="tab.display">Display</span>
</button>
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
<span data-i18n="tab.browser">Browser</span>
</button>
<button class="tab-btn" data-tab="quick-actions" onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>
<span data-i18n="tab.quick_access">Quick Access</span>
</button>
<button class="tab-btn" data-tab="settings" onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<span data-i18n="tab.settings">Settings</span>
</button>
</div>
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
<span class="folio tl"><span data-i18n="player.folio_left">Now Spinning</span> · <span id="folio-version">v—</span></span>
<span class="folio tr" data-i18n="player.folio_right">Vol. I — Studio Reference</span>
<div class="player-layout now-playing">
<!-- Vinyl stage with album art as label -->
<div class="album-art-container vinyl-stage">
<div class="vinyl">
<div class="vinyl-label">
<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">
<img id="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>
</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="#3a3528"/>
<stop offset="0.5" stop-color="#9C937F"/>
<stop offset="1" stop-color="#5C5447"/>
</linearGradient>
</defs>
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
<circle cx="176" cy="24" r="2" fill="#E08038"/>
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad)" stroke-width="3.5" stroke-linecap="round"/>
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
<circle cx="62" cy="138" r="6" fill="none" stroke="#E08038" stroke-width="0.5" opacity="0.4"/>
</svg>
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
</div>
<!-- Track masthead -->
<div class="player-details track-masthead">
<div class="kicker"><span data-i18n="player.kicker">Now Playing</span></div>
<div class="track-info">
<div id="track-title" data-i18n="player.no_media">No media playing</div>
<div id="artist"></div>
<div id="album"></div>
</div>
<!-- Editorial metadata grid (4 cells) -->
<div class="meta-grid">
<div class="meta-cell">
<div class="meta-label" data-i18n="meta.state">State</div>
<div class="meta-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="meta-label" data-i18n="meta.source">Source</div>
<div class="meta-value source-value">
<span class="source-icon" id="sourceIcon"></span>
<span id="source" data-i18n="player.unknown_source">Unknown</span>
</div>
</div>
<div class="meta-cell">
<div class="meta-label" data-i18n="meta.elapsed">Elapsed</div>
<div class="meta-value mono" id="meta-elapsed">0:00</div>
</div>
<div class="meta-cell">
<div class="meta-label" data-i18n="meta.length">Length</div>
<div class="meta-value mono" id="meta-length">0:00</div>
</div>
</div>
<!-- Decorative spectrum bars -->
<div class="spectrum" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
</div>
<!-- Transport: progress + controls + VU cluster -->
<div class="transport">
<div class="progress-container progress-row">
<span class="timecode elapsed" id="current-time">0:00</span>
<div class="progress-bar progress-track" 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 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="primary" 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 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>
<!-- VU cluster: needle visual + slider + readout -->
<div class="vu-cluster">
<div class="vu-meter" aria-hidden="true">
<div class="vu-needle" id="vuNeedle"></div>
</div>
<div class="volume-container">
<button class="mute-btn" 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 class="volume-display" id="volume-display">50%</div>
</div>
</div>
</div>
</div>
<!-- Player toggles -->
<div class="source-info">
<span class="source-label">
<span class="vinyl-mode-label" data-i18n="player.modes">Modes</span>
</span>
<div class="player-toggles">
<button class="vinyl-toggle-btn" onclick="toggleVinylMode()" id="vinylToggle" data-i18n-title="player.vinyl" title="Vinyl mode">
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.5"/></svg>
</button>
<button class="vinyl-toggle-btn" 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>
</div>
</div>
</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" 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" 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" 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" 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" 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..." oninput="onBrowserSearch()">
<button class="browser-search-clear" id="browserSearchClear" 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" 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" 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" onchange="goToPage()">
<span id="pageTotal">/ 1</span>
</div>
<button id="nextPage" 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" 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" 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" 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" 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>No callbacks configured. Click "Add" to create one.</p>
</div>
</td>
</tr>
</tbody>
</table>
<div class="add-card" onclick="showAddCallbackDialog()">
<span class="add-card-icon">+</span>
</div>
</div>
</details>
</div>
<!-- Display Control Section -->
<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>
</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" 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" 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" 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">Execute Script</h3>
</div>
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)">
<div class="dialog-body">
<div id="scriptParamsInputs"></div>
</div>
<div class="dialog-footer">
<button type="button" class="btn-secondary" 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" 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" 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" 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" 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" 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" 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" 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>
<!-- Footer -->
<footer>
<div>
<span data-i18n="footer.created_by">Created by</span> <strong>Alexei Dolgolyov</strong>
<span class="separator"></span>
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
<span class="separator"></span>
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="footer.source_code">Source Code</a>
</div>
</footer>
<script src="/static/dist/app.bundle.js"></script>
</body>
</html>