- Refactored index.html: Split into separate HTML (309 lines), CSS (908 lines), and JS (1,286 lines) files - Implemented media browser with folder configuration, recursive navigation, and thumbnail display - Added metadata extraction using mutagen library (title, artist, album, duration, bitrate, codec) - Implemented thumbnail generation and caching with SHA256 hash-based keys and LRU eviction - Added platform-specific file playback (os.startfile on Windows, xdg-open on Linux, open on macOS) - Implemented path validation security to prevent directory traversal attacks - Added smooth thumbnail loading with fade-in animation and loading spinner - Added i18n support for browser (English and Russian) - Updated dependencies: mutagen>=1.47.0, pillow>=10.0.0 - Added comprehensive media browser documentation to README Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
375 lines
20 KiB
HTML
375 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Media Server</title>
|
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='grad' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%231db954;stop-opacity:1' /%3E%3Cstop offset='100%25' style='stop-color:%231ed760;stop-opacity:1' /%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='50' cy='50' r='45' fill='url(%23grad)'/%3E%3Cpath fill='white' d='M35 25 L35 75 L75 50 Z'/%3E%3C/svg%3E">
|
|
<link rel="stylesheet" href="/static/css/styles.css">
|
|
</head>
|
|
<body class="loading-translations">
|
|
<!-- Clear Token Button -->
|
|
<button class="clear-token-btn" onclick="clearToken()" data-i18n-title="auth.logout.title" data-i18n="auth.logout" title="Clear saved token">Logout</button>
|
|
|
|
<!-- 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>
|
|
<h1 data-i18n="app.title">Media Server</h1>
|
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="player.theme" title="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" onchange="changeLocale()" title="Change language">
|
|
<option value="en">English</option>
|
|
<option value="ru">Русский</option>
|
|
</select>
|
|
<div class="status-indicator">
|
|
<span class="status-dot" id="status-dot"></span>
|
|
<span id="status-text" data-i18n="player.status.disconnected">Disconnected</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="player-container">
|
|
<div class="album-art-container">
|
|
<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 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 class="playback-state">
|
|
<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="progress-container">
|
|
<div class="time-display">
|
|
<span id="current-time">0:00</span>
|
|
<span id="total-time">0:00</span>
|
|
</div>
|
|
<div class="progress-bar" id="progress-bar" data-duration="0">
|
|
<div class="progress-fill" id="progress-fill"></div>
|
|
</div>
|
|
</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>
|
|
</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">
|
|
<div class="volume-display" id="volume-display">50%</div>
|
|
</div>
|
|
|
|
<div class="source-info">
|
|
<span data-i18n="player.source">Source:</span> <span id="source" data-i18n="player.unknown_source">Unknown</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Media Browser Section -->
|
|
<div class="browser-container">
|
|
<h2 data-i18n="browser.title">Media Browser</h2>
|
|
|
|
<!-- Folder Selection -->
|
|
<div class="browser-controls">
|
|
<select id="folderSelect" onchange="onFolderSelected()" data-i18n-empty="browser.select_folder">
|
|
<option value="" data-i18n="browser.select_folder_option">Select a folder...</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Breadcrumb Navigation -->
|
|
<div class="breadcrumb" id="breadcrumb"></div>
|
|
|
|
<!-- File/Folder Grid -->
|
|
<div class="browser-grid" id="browserGrid">
|
|
<div class="browser-empty" data-i18n="browser.no_folder_selected">Select a folder to browse media files</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div class="pagination" id="browserPagination" style="display: none;">
|
|
<button id="prevPage" onclick="previousPage()" data-i18n="browser.previous">Previous</button>
|
|
<span id="pageInfo">1 / 1</span>
|
|
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scripts Section -->
|
|
<div class="scripts-container" id="scripts-container" style="display: none;">
|
|
<h2 data-i18n="scripts.quick_actions">Quick Actions</h2>
|
|
<div class="scripts-grid" id="scripts-grid">
|
|
<div class="scripts-empty" data-i18n="scripts.no_scripts">No scripts configured</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Script Management Section -->
|
|
<div class="script-management">
|
|
<div class="script-management-header">
|
|
<h2 data-i18n="scripts.management">Script Management</h2>
|
|
<button class="add-script-btn" onclick="showAddScriptDialog()" data-i18n="scripts.add">+ Add</button>
|
|
</div>
|
|
<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" data-i18n="scripts.empty">No scripts configured. Click "Add" to create one.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Callback Management Section -->
|
|
<div class="script-management">
|
|
<div class="script-management-header">
|
|
<h2 data-i18n="callbacks.management">Callback Management</h2>
|
|
<button class="add-script-btn" onclick="showAddCallbackDialog()" data-i18n="callbacks.add">+ Add</button>
|
|
</div>
|
|
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;" 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">No callbacks configured. Click "Add Callback" to create one.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</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>
|
|
<input type="text" id="scriptIcon" data-i18n-placeholder="scripts.placeholder.icon" placeholder="e.g., mdi:power">
|
|
</label>
|
|
|
|
<label>
|
|
<span data-i18n="scripts.field.timeout">Timeout (seconds)</span>
|
|
<input type="number" id="scriptTimeout" value="30" min="1" max="300">
|
|
</label>
|
|
</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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- Execution Result Dialog -->
|
|
<dialog id="executionDialog">
|
|
<div class="dialog-header">
|
|
<h3 id="executionDialogTitle">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>Output</h4>
|
|
<div class="execution-result">
|
|
<pre id="executionOutput"></pre>
|
|
</div>
|
|
</div>
|
|
<div class="result-section" id="errorSection" style="display: none;">
|
|
<h4>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()">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>
|
|
|
|
<!-- Toast Notification -->
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<!-- Footer -->
|
|
<footer>
|
|
<div>
|
|
Created by <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">Source Code</a>
|
|
</div>
|
|
</footer>
|
|
|
|
<script src="/static/js/app.js"></script>
|
|
</body>
|
|
</html>
|