diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 4f472a6..aa9fb3d 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -1,1627 +1,1742 @@ - :root { - --bg-primary: #121212; - --bg-secondary: #1e1e1e; - --bg-tertiary: #282828; - --text-primary: #ffffff; - --text-secondary: #b3b3b3; - --text-muted: #6a6a6a; - --accent: #1db954; - --accent-hover: #1ed760; - --border: #404040; - --error: #e74c3c; - } - - :root[data-theme="light"] { - --bg-primary: #ffffff; - --bg-secondary: #f5f5f5; - --bg-tertiary: #e8e8e8; - --text-primary: #1a1a1a; - --text-secondary: #4a4a4a; - --text-muted: #888888; - --accent: #1db954; - --accent-hover: #1ed760; - --border: #d0d0d0; - --error: #e74c3c; - } - - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - html { - overflow-y: scroll; - } - - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif; - background: var(--bg-primary); - color: var(--text-primary); - line-height: 1.6; - } - - /* Prevent flash of untranslated content */ - body.loading-translations { - opacity: 0; - transition: opacity 0.1s ease-in; - } - - body.translations-loaded { - opacity: 1; - } - - /* Custom Scrollbars */ - ::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - ::-webkit-scrollbar-track { - background: var(--bg-primary); - } - - ::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; - } - - ::-webkit-scrollbar-thumb:hover { - background: var(--text-muted); - } - - * { - scrollbar-width: thin; - scrollbar-color: var(--border) var(--bg-primary); - } - - /* Focus-visible states for keyboard accessibility */ - :focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - - button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.2); - } - - input:focus-visible, - select:focus-visible, - textarea:focus-visible { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15); - } - - .tab-btn:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; - } - - /* Prevent scrolling when dialog is open */ - body.dialog-open { - overflow: hidden; - } - - .container { - max-width: 800px; - margin: 0 auto; - padding: 2rem 2rem 0.5rem; - } - - header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.75rem; - padding-bottom: 0.5rem; - } - - h1 { - font-size: 1.5rem; - font-weight: 600; - } - - .version-label { - font-size: 0.7rem; - color: var(--text-muted); - background: var(--bg-tertiary); - padding: 0.15rem 0.5rem; - border-radius: 1rem; - font-weight: 500; - align-self: center; - } - - .status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--error); - transition: background 0.3s; - } - - .status-dot.connected { - background: var(--accent); - } - - .theme-toggle { - background: transparent; - border: none; - padding: 0.3rem; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: opacity 0.3s; - width: 32px; - height: 32px; - opacity: 0.7; - } - - .theme-toggle:hover { - opacity: 1; - } - - .theme-toggle svg { - width: 16px; - height: 16px; - fill: currentColor; - } - - /* Accent Color Picker */ - .accent-picker { - position: relative; - } - - .accent-picker-btn { - background: transparent; - border: none; - cursor: pointer; - padding: 4px; - display: flex; - align-items: center; - opacity: 0.8; - transition: opacity 0.2s; - } - - .accent-picker-btn:hover { - opacity: 1; - transform: none; - } - - .accent-dot { - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--accent); - border: 2px solid var(--border); - display: block; - } - - .accent-picker-dropdown { - display: none; - position: absolute; - right: 0; - top: calc(100% + 4px); - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px; - gap: 6px; - z-index: 100; - box-shadow: 0 4px 12px rgba(0,0,0,0.3); - display: none; - grid-template-columns: repeat(3, 24px); - } - - .accent-picker-dropdown.open { - display: grid; - } - - .accent-swatch { - width: 24px; - height: 24px; - border-radius: 50%; - border: 2px solid transparent; - cursor: pointer; - transition: transform 0.15s, border-color 0.15s; - } - - .accent-swatch:hover { - transform: scale(1.2); - } - - .accent-swatch.active { - border-color: var(--text-primary); - } - - #locale-select { - background: var(--bg-tertiary); - border: 1px solid var(--border); - color: var(--text-primary); - border-radius: 6px; - padding: 4px 8px; - cursor: pointer; - font-size: 12px; - font-weight: 500; - transition: all 0.3s ease; - } - - #locale-select:hover { - border-color: var(--accent); - } - - #locale-select:focus { - outline: none; - border-color: var(--accent); - } - - #locale-select option { - background: var(--bg-secondary); - color: var(--text-primary); - } - - /* Tab Bar */ - .tab-bar { - display: flex; - gap: 0.25rem; - margin-bottom: 1.5rem; - padding: 0.25rem; - background: var(--bg-secondary); - border-radius: 10px; - border: 1px solid var(--border); - position: relative; - } - - .tab-indicator { - position: absolute; - top: 0.25rem; - bottom: 0.25rem; - left: 0; - background: var(--bg-tertiary); - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), width 0.3s cubic-bezier(0.4, 0, 0.2, 1); - z-index: 0; - pointer-events: none; - } - - .tab-btn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - padding: 0.6rem 0.5rem; - background: transparent; - border: none; - border-radius: 8px; - color: var(--text-muted); - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; - transition: color 0.2s; - width: auto; - height: auto; - position: relative; - z-index: 1; - } - - .tab-btn:hover { - color: var(--text-primary); - transform: none !important; - } - - .tab-btn.active { - color: var(--accent); - background: transparent; - } - - .tab-btn svg { - width: 16px; - height: 16px; - flex-shrink: 0; - } - - [data-tab-content] { - display: none; - } - - [data-tab-content].active { - display: block; - } - - @media (max-width: 600px) { - .tab-btn span { - display: none; - } - - .tab-btn { - padding: 0.6rem; - } - } - - .player-container { - background: var(--bg-secondary); - border-radius: 12px; - padding: 2rem; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); - } - - .player-layout { - display: flex; - flex-direction: column; - } - - .player-details { - flex: 1; - min-width: 0; - } - - .album-art-container { - display: flex; - justify-content: center; - margin-bottom: 2rem; - position: relative; - } - - .album-art-glow { - position: absolute; - width: 300px; - height: 300px; - object-fit: cover; - border-radius: 8px; - filter: blur(40px) saturate(1.5); - opacity: 0.5; - top: 50%; - left: 50%; - transform: translate(-50%, -50%) scale(1.1); - z-index: 0; - pointer-events: none; - transition: opacity 0.5s ease, border-radius 0.6s ease; - } - - #album-art { - width: 300px; - height: 300px; - object-fit: cover; - border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); - background: var(--bg-tertiary); - position: relative; - z-index: 1; - margin: 0; - transition: border-radius 0.6s ease, width 0.6s ease, height 0.6s ease, box-shadow 0.6s ease, margin 0.6s ease, filter 0.6s ease; - } - - :root[data-theme="light"] .album-art-glow { - opacity: 0.35; - filter: blur(50px) saturate(1.8); - } - - /* Vinyl Record Mode */ - .album-art-container.vinyl #album-art { - border-radius: 50%; - width: 210px; - height: 210px; - margin: 45px; - filter: saturate(0.8) brightness(0.92) contrast(1.05); - box-shadow: - 0 0 0 3px #2a2a2a, - 0 0 0 5px #1a1a1a, - 0 0 0 6px rgba(255,255,255,0.05), - 0 0 0 12px #1a1a1a, - 0 0 0 13px rgba(255,255,255,0.03), - 0 0 0 20px #1a1a1a, - 0 0 0 21px rgba(255,255,255,0.05), - 0 0 0 28px #1a1a1a, - 0 0 0 29px rgba(255,255,255,0.03), - 0 0 0 36px #1a1a1a, - 0 0 0 37px rgba(255,255,255,0.05), - 0 0 0 42px #1a1a1a, - 0 0 0 43px #2a2a2a, - 0 0 0 45px #111, - 0 4px 15px 45px rgba(0,0,0,0.4); - } - - /* Vinyl label vignette overlay */ - .album-art-container.vinyl::before { - content: ''; - position: absolute; - width: 210px; - height: 210px; - border-radius: 50%; - background: radial-gradient( - circle, - transparent 50%, - rgba(0,0,0,0.25) 100% - ); - z-index: 2; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - pointer-events: none; - opacity: 0; - transition: opacity 0.6s ease; - } - - .album-art-container.vinyl.spinning::before, - .album-art-container.vinyl.paused::before { - opacity: 1; - } - - .album-art-container.vinyl .album-art-glow { - border-radius: 50%; - } - - /* Center spindle hole */ - .album-art-container::after { - content: ''; - position: absolute; - width: 14px; - height: 14px; - border-radius: 50%; - background: #0a0a0a; - border: 2px solid #444; - box-shadow: inset 0 1px 3px rgba(0,0,0,0.8); - z-index: 3; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - pointer-events: none; - opacity: 0; - transition: opacity 0.4s ease 0.3s; - } - - .album-art-container.vinyl::after { - opacity: 1; - } - - .album-art-container.vinyl.spinning #album-art { - animation: vinylSpin 12s linear infinite; - } - - .album-art-container.vinyl.paused #album-art { - animation: vinylSpin 12s linear infinite; - animation-play-state: paused; - } - - @keyframes vinylSpin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } - } - - .track-info { - text-align: center; - margin-bottom: 2rem; - } - - #track-title { - font-size: 1.75rem; - font-weight: 600; - margin-bottom: 0.5rem; - color: var(--text-primary); - } - - #artist { - font-size: 1.125rem; - color: var(--text-secondary); - margin-bottom: 0.25rem; - } - - #album { - font-size: 0.875rem; - color: var(--text-muted); - } - - .playback-state { - display: flex; - justify-content: center; - align-items: center; - gap: 0.5rem; - font-size: 0.875rem; - color: var(--text-secondary); - margin-top: 0.5rem; - } - - .state-icon { - width: 16px; - height: 16px; - } - - .progress-container { - margin-bottom: 2rem; - } - - .time-display { - display: flex; - justify-content: space-between; - font-size: 0.75rem; - color: var(--text-secondary); - margin-bottom: 0.5rem; - } - - .progress-bar { - width: 100%; - height: 6px; - background: var(--bg-tertiary); - border-radius: 3px; - cursor: pointer; - position: relative; - transition: transform 0.15s ease; - } - - .progress-bar:hover { - transform: scaleY(1.4); - } - - .progress-fill { - height: 100%; - background: var(--accent); - border-radius: 3px; - width: 0; - transition: width 0.1s linear; - position: relative; - } - - .progress-fill::after { - content: ''; - position: absolute; - right: -6px; - top: 50%; - transform: translateY(-50%) scale(0); - width: 12px; - height: 12px; - background: var(--accent); - border-radius: 50%; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); - transition: transform 0.15s ease; - } - - .progress-bar:hover .progress-fill::after { - transform: translateY(-50%) scale(1); - } - - .controls { - display: flex; - gap: 1rem; - justify-content: center; - align-items: center; - margin-bottom: 2rem; - } - - button { - background: var(--bg-tertiary); - border: none; - color: var(--text-primary); - cursor: pointer; - border-radius: 50%; - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - } - - button:hover:not(:disabled) { - background: var(--accent); - color: #fff; - transform: scale(1.05); - } - - button:disabled { - opacity: 0.3; - cursor: not-allowed; - } - - button.primary { - width: 56px; - height: 56px; - background: var(--accent); - } - - button.primary:hover:not(:disabled) { - background: var(--accent-hover); - color: #fff; - transform: scale(1.1); - } - - .volume-container { - display: flex; - align-items: center; - gap: 1rem; - padding: 1rem; - background: var(--bg-tertiary); - border-radius: 8px; - margin-bottom: 1rem; - } - - #volume-slider { - flex: 1; - height: 6px; - -webkit-appearance: none; - appearance: none; - background: var(--bg-primary); - border-radius: 3px; - outline: none; - } - - #volume-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 16px; - height: 16px; - background: var(--accent); - border-radius: 50%; - cursor: pointer; - transition: transform 0.15s ease, box-shadow 0.15s ease; - } - - #volume-slider:hover::-webkit-slider-thumb { - transform: scale(1.3); - box-shadow: 0 0 6px rgba(29, 185, 84, 0.4); - } - - #volume-slider::-moz-range-thumb { - width: 16px; - height: 16px; - background: var(--accent); - border-radius: 50%; - cursor: pointer; - border: none; - transition: transform 0.15s ease, box-shadow 0.15s ease; - } - - #volume-slider:hover::-moz-range-thumb { - transform: scale(1.3); - box-shadow: 0 0 6px rgba(29, 185, 84, 0.4); - } - - .volume-display { - font-size: 0.875rem; - color: var(--text-secondary); - min-width: 40px; - text-align: right; - } - - .mute-btn { - width: 40px; - height: 40px; - } - - .source-info { - text-align: center; - font-size: 0.75rem; - color: var(--text-muted); - padding-top: 1rem; - border-top: 1px solid var(--border); - display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; - } - - .vinyl-toggle-btn { - width: 28px; - height: 28px; - padding: 0; - background: transparent; - border: 1px solid var(--border); - border-radius: 50%; - color: var(--text-muted); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - margin-left: auto; - } - - .vinyl-toggle-btn:hover { - color: var(--accent); - border-color: var(--accent); - background: transparent; - transform: none; - } - - .vinyl-toggle-btn.active { - color: var(--accent); - border-color: var(--accent); - background: rgba(29, 185, 84, 0.1); - } - - .vinyl-toggle-btn svg { - width: 16px; - height: 16px; - } - - /* Scripts Section */ - .scripts-container { - background: var(--bg-secondary); - border-radius: 12px; - padding: 2rem; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); - } - - .scripts-container h2 { - font-size: 1.25rem; - margin-bottom: 1rem; - color: var(--text-primary); - } - - .scripts-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 1rem; - } - - .script-btn { - width: 100%; - height: auto; - min-height: 80px; - padding: 1rem; - border-radius: 8px; - background: var(--bg-tertiary); - border: 1px solid var(--border); - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.5rem; - } - - .script-btn:hover:not(:disabled) { - background: var(--accent); - border-color: var(--accent); - color: #fff; - transform: translateY(-2px); - } - - .script-btn:disabled { - opacity: 0.3; - cursor: not-allowed; - } - - .script-btn .script-label { - font-weight: 600; - font-size: 0.875rem; - } - - .script-btn .script-description { - font-size: 0.75rem; - color: var(--text-secondary); - text-align: center; - transition: color 0.2s; - } - - .script-btn:hover:not(:disabled) .script-description { - color: rgba(255, 255, 255, 0.85); - } - - .script-btn.executing { - opacity: 0.6; - pointer-events: none; - } - - /* Script Management Styles */ - .script-management { - background: var(--bg-secondary); - border-radius: 12px; - padding: 2rem; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); - overflow-x: auto; - } - - .script-management h2 { - font-size: 1.25rem; - margin-bottom: 1rem; - color: var(--text-primary); - } - - .add-card { - display: flex; - align-items: center; - justify-content: center; - border: 2px dashed var(--border); - border-radius: 8px; - padding: 1.5rem; - margin-top: 1rem; - cursor: pointer; - transition: border-color 0.2s, background 0.2s; - } - - .add-card:hover { - border-color: var(--text-muted); - background: var(--bg-tertiary); - } - - .add-card-icon { - font-size: 1.5rem; - color: var(--text-muted); - font-weight: 300; - line-height: 1; - } - - .add-card-grid { - border: 2px dashed var(--border); - background: transparent; - } - - .add-card-grid:hover:not(:disabled) { - border-color: var(--text-muted); - background: var(--bg-tertiary); - } - - .scripts-table { - width: 100%; - min-width: 500px; - border-collapse: collapse; - font-size: 0.875rem; - } - - .scripts-table th { - text-align: left; - padding: 0.75rem; - border-bottom: 2px solid var(--border); - color: var(--text-secondary); - font-weight: 600; - font-size: 0.75rem; - text-transform: uppercase; - } - - .scripts-table td { - padding: 0.75rem; - word-break: break-word; - border-bottom: 1px solid var(--border); - } - - .scripts-table tr:hover { - background: var(--bg-tertiary); - } - - .scripts-table code { - background: var(--bg-tertiary); - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - color: var(--accent); - } - - .action-btn { - padding: 0.5rem; - border-radius: 6px; - border: 1px solid var(--border); - background: var(--bg-tertiary); - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; - width: 32px; - height: 32px; - display: inline-flex; - align-items: center; - justify-content: center; - } - - .action-btn svg { - width: 16px; - height: 16px; - fill: currentColor; - } - - .action-btn:hover { - background: var(--accent); - border-color: var(--accent); - color: #fff; - transform: translateY(-1px); - } - - .action-btn.delete:hover { - background: var(--error); - border-color: var(--error); - color: #fff; - } - - .action-buttons { - display: flex; - gap: 0.5rem; - align-items: center; - } - - .action-btn.execute:hover { - background: #3b82f6; - border-color: #3b82f6; - color: #fff; - } - - /* Execution Result Dialog */ - .execution-result { - font-family: 'Consolas', 'Monaco', 'Courier New', monospace; - background: var(--bg-primary); - border-radius: 6px; - padding: 1rem; - margin: 0.5rem 0; - max-height: 400px; - overflow-y: auto; - } - - .execution-result pre { - margin: 0; - white-space: pre-wrap; - word-wrap: break-word; - font-size: 0.813rem; - line-height: 1.5; - } - - .execution-status { - display: flex; - gap: 1rem; - margin-bottom: 1rem; - flex-wrap: wrap; - } - - .status-item { - display: flex; - flex-direction: column; - gap: 0.25rem; - } - - .status-item label { - font-size: 0.75rem; - color: var(--text-muted); - text-transform: uppercase; - font-weight: 600; - } - - .status-item value { - font-size: 0.875rem; - color: var(--text-primary); - font-weight: 500; - } - - .status-item.success value { - color: var(--accent); - } - - .status-item.error value { - color: var(--error); - } - - .result-section { - margin-bottom: 1rem; - } - - .result-section h4 { - font-size: 0.875rem; - color: var(--text-secondary); - margin-bottom: 0.5rem; - font-weight: 600; - } - - .loading-spinner { - display: inline-block; - width: 20px; - height: 20px; - border: 3px solid var(--bg-tertiary); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - - @keyframes spin { - to { transform: rotate(360deg); } - } - - /* Dialog Styles */ - dialog { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border); - border-radius: 12px; - padding: 0; - max-width: 500px; - width: 90%; - margin: auto; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8); - } - - /* Ensure dialogs are hidden until explicitly opened */ - dialog:not([open]) { - display: none; - } - - dialog::backdrop { - background: rgba(0, 0, 0, 0.8); - } - - .dialog-header { - padding: 1.5rem; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border); - } - - .dialog-header h3 { - margin: 0; - font-size: 1.25rem; - } - - .dialog-body { - padding: 1.5rem; - } - - .dialog-body label { - display: block; - margin-bottom: 1rem; - color: var(--text-secondary); - font-size: 0.875rem; - } - - .dialog-body input, - .dialog-body textarea, - .dialog-body select { - display: block; - width: 100%; - padding: 0.5rem; - margin-top: 0.25rem; - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: 6px; - color: var(--text-primary); - font-family: inherit; - font-size: 0.875rem; - } - - .dialog-body textarea { - min-height: 80px; - resize: vertical; - } - - .dialog-body input:focus, - .dialog-body textarea:focus, - .dialog-body select:focus { - outline: none; - border-color: var(--accent); - } - - .dialog-footer { - padding: 1.5rem; - border-top: 1px solid var(--border); - display: flex; - justify-content: flex-end; - gap: 0.5rem; - } - - .dialog-footer button { - padding: 0.625rem 1.5rem; - border-radius: 6px; - border: none; - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - transition: background 0.2s; - min-width: 100px; - white-space: nowrap; - } - - .dialog-footer .btn-primary { - background: var(--accent); - color: var(--text-primary); - } - - .dialog-footer .btn-primary:hover { - background: var(--accent-hover); - } - - .dialog-footer .btn-secondary { - background: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border); - } - - .dialog-footer .btn-secondary:hover { - background: var(--border); - } - - .empty-state { - text-align: center; - padding: 2rem; - color: var(--text-muted); - } - - .scripts-empty { - text-align: center; - color: var(--text-muted); - padding: 2rem; - font-size: 0.875rem; - } - - .empty-state-illustration { - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - padding: 3rem 2rem; - } - - .empty-state-illustration svg { - width: 64px; - height: 64px; - fill: none; - stroke: var(--text-muted); - stroke-width: 1.5; - stroke-linecap: round; - stroke-linejoin: round; - opacity: 0.5; - } - - .empty-state-illustration p { - color: var(--text-muted); - font-size: 0.875rem; - margin: 0; - } - - .toast { - position: fixed; - bottom: 2rem; - right: 2rem; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem 1.5rem; - padding-left: 1.25rem; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); - opacity: 0; - transform: translateY(20px); - transition: all 0.3s; - pointer-events: none; - z-index: 1000; - border-left: 4px solid var(--border); - } - - .toast.show { - opacity: 1; - transform: translateY(0); - } - - .toast.success { - border-color: var(--accent); - border-left-color: var(--accent); - } - - .toast.error { - border-color: var(--error); - border-left-color: var(--error); - } - - /* Auth Modal */ - #auth-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.9); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - } - - #auth-overlay.hidden { - display: none; - } - - .auth-modal { - background: var(--bg-secondary); - padding: 2rem; - border-radius: 12px; - max-width: 400px; - width: 90%; - } - - .auth-modal h2 { - margin-bottom: 1rem; - font-size: 1.5rem; - } - - .auth-modal p { - margin-bottom: 1rem; - color: var(--text-secondary); - font-size: 0.875rem; - } - - #token-input { - width: 100%; - padding: 0.75rem; - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: 6px; - color: var(--text-primary); - font-size: 0.875rem; - margin-bottom: 1rem; - } - - #token-input:focus { - outline: none; - border-color: var(--accent); - } - - .btn-connect { - width: 100%; - height: auto; - padding: 0.75rem; - border-radius: 6px; - background: var(--accent); - font-weight: 600; - } - - .btn-connect:hover { - background: var(--accent-hover); - transform: none; - } - - .help-text { - background: var(--bg-tertiary); - padding: 0.75rem; - border-radius: 6px; - margin-top: 1rem; - } - - .help-text code { - background: var(--bg-primary); - padding: 0.25rem 0.5rem; - border-radius: 3px; - font-family: monospace; - font-size: 0.875rem; - } - - .error-message { - color: var(--error); - font-size: 0.875rem; - margin-top: 0.5rem; - display: none; - } - - .error-message.visible { - display: block; - } - - .clear-token-btn { - width: auto; - height: auto; - padding: 4px 10px; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - background: var(--bg-tertiary); - color: var(--text-secondary); - border: 1px solid var(--border); - cursor: pointer; - opacity: 0.7; - } - - .clear-token-btn:hover { - opacity: 1; - background: var(--error); - color: var(--text-primary); - border-color: var(--error); - } - - /* Mini Player (Sticky) */ - .mini-player { - position: fixed; - bottom: 0; - left: 0; - right: 0; - background: rgba(30, 30, 30, 0.8); - -webkit-backdrop-filter: blur(20px) saturate(1.5); - backdrop-filter: blur(20px) saturate(1.5); - border-top: 1px solid rgba(255, 255, 255, 0.08); - padding: 0.75rem 1rem; - padding-top: calc(0.75rem + 2px); - display: flex; - align-items: center; - gap: 1.5rem; - z-index: 1000; - transform: translateY(0); - transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; - box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.3); - } - - :root[data-theme="light"] .mini-player { - background: rgba(245, 245, 245, 0.75); - border-top: 1px solid rgba(0, 0, 0, 0.08); - box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.1); - } - - .mini-player::before { - content: ''; - position: absolute; - top: 0; - left: 0; - height: 2px; - width: var(--mini-progress, 0%); - background: var(--accent); - transition: width 0.1s linear; - } - - .mini-player.hidden { - transform: translateY(100%); - opacity: 0; - pointer-events: none; - } - - .mini-player-info { - display: flex; - align-items: center; - gap: 0.75rem; - min-width: 200px; - flex-shrink: 0; - } - - .mini-album-art { - width: 40px; - height: 40px; - border-radius: 4px; - object-fit: cover; - flex-shrink: 0; - } - - .mini-track-details { - display: flex; - flex-direction: column; - min-width: 0; - } - - .mini-track-title { - font-size: 0.875rem; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .mini-artist { - font-size: 0.75rem; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .mini-progress-container { - flex: 1; - display: flex; - align-items: center; - gap: 1rem; - min-width: 0; - } - - .mini-time-display { - display: flex; - gap: 0.5rem; - font-size: 0.75rem; - color: var(--text-secondary); - flex-shrink: 0; - } - - .mini-progress-bar { - flex: 1; - height: 4px; - background: var(--bg-tertiary); - border-radius: 2px; - cursor: pointer; - position: relative; - min-width: 100px; - } - - .mini-progress-bar:hover { - height: 6px; - } - - .mini-progress-fill { - height: 100%; - background: var(--accent); - border-radius: 2px; - width: 0%; - transition: width 0.1s linear; - position: relative; - } - - .mini-progress-fill::after { - content: ''; - position: absolute; - right: -5px; - top: 50%; - transform: translateY(-50%) scale(0); - width: 10px; - height: 10px; - background: var(--accent); - border-radius: 50%; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); - transition: transform 0.15s ease; - } - - .mini-progress-bar:hover .mini-progress-fill::after { - transform: translateY(-50%) scale(1); - } - - .mini-controls { - display: flex; - align-items: center; - gap: 0.5rem; - flex-shrink: 0; - } - - .mini-control-btn { - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: 50%; - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; - padding: 0; - } - - .mini-control-btn:hover { - background: var(--accent); - border-color: var(--accent); - color: #fff; - transform: scale(1.05); - } - - .mini-control-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .mini-control-btn svg { - width: 20px; - height: 20px; - fill: currentColor; - } - - .mini-volume-container { - display: flex; - align-items: center; - gap: 0.75rem; - flex-shrink: 0; - min-width: 180px; - } - - .mini-volume-slider { - flex: 1; - height: 4px; - -webkit-appearance: none; - appearance: none; - background: var(--bg-tertiary); - border-radius: 2px; - outline: none; - cursor: pointer; - min-width: 80px; - } - - .mini-volume-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 12px; - height: 12px; - background: var(--accent); - border-radius: 50%; - cursor: pointer; - } - - .mini-volume-slider::-moz-range-thumb { - width: 12px; - height: 12px; - background: var(--accent); - border-radius: 50%; - cursor: pointer; - border: none; - } - - .mini-volume-slider:hover::-webkit-slider-thumb { - transform: scale(1.2); - } - - .mini-volume-slider:hover::-moz-range-thumb { - transform: scale(1.2); - } - - .mini-volume-display { - font-size: 0.75rem; - color: var(--text-secondary); - min-width: 36px; - text-align: right; - } - - /* SVG Icons */ - svg { - width: 24px; - height: 24px; - fill: currentColor; - } - - button.primary svg { - width: 28px; - height: 28px; - } - - @media (max-width: 600px) { - .container { - padding: 1rem; - } - - #album-art { - width: 250px; - height: 250px; - } - - .album-art-container.vinyl #album-art { - width: 170px; - height: 170px; - margin: 40px; - box-shadow: - 0 0 0 3px #2a2a2a, - 0 0 0 5px #1a1a1a, - 0 0 0 6px rgba(255,255,255,0.05), - 0 0 0 12px #1a1a1a, - 0 0 0 13px rgba(255,255,255,0.03), - 0 0 0 20px #1a1a1a, - 0 0 0 21px rgba(255,255,255,0.05), - 0 0 0 28px #1a1a1a, - 0 0 0 29px rgba(255,255,255,0.03), - 0 0 0 36px #1a1a1a, - 0 0 0 37px rgba(255,255,255,0.04), - 0 0 0 38px #2a2a2a, - 0 0 0 40px #111, - 0 4px 12px 40px rgba(0,0,0,0.4); - } - - #track-title { - font-size: 1.5rem; - } - } - - /* Footer */ - footer { - text-align: center; - padding: 0.75rem 1rem; - margin-top: 0.5rem; - color: var(--text-muted); - font-size: 0.75rem; - transition: padding-bottom 0.3s ease-in-out; - } - - body.mini-player-visible footer { - padding-bottom: 70px; - } - - footer a { - color: var(--accent); - text-decoration: none; - transition: color 0.2s; - } - - footer a:hover { - color: var(--accent-hover); - text-decoration: underline; - } - - footer .separator { - margin: 0 0.5rem; - color: var(--text-muted); - } +:root { + --bg-primary: #121212; + --bg-secondary: #1e1e1e; + --bg-tertiary: #282828; + --text-primary: #ffffff; + --text-secondary: #b3b3b3; + --text-muted: #8a8a8a; + --accent: #1db954; + --accent-hover: #1ed760; + --border: #404040; + --error: #e74c3c; + --vinyl-ring: #1a1a1a; + --vinyl-groove: #2a2a2a; + --vinyl-highlight: rgba(255,255,255,0.05); + --vinyl-highlight-dim: rgba(255,255,255,0.03); + --vinyl-edge: #111; + --vinyl-spindle: #0a0a0a; + --shadow-elevation: rgba(0, 0, 0, 0.5); +} + +:root[data-theme="light"] { + --bg-primary: #ffffff; + --bg-secondary: #f5f5f5; + --bg-tertiary: #e8e8e8; + --text-primary: #1a1a1a; + --text-secondary: #4a4a4a; + --text-muted: #888888; + --accent: #1db954; + --accent-hover: #1ed760; + --border: #d0d0d0; + --error: #e74c3c; + --vinyl-ring: #c0c0c0; + --vinyl-groove: #b0b0b0; + --vinyl-highlight: rgba(255,255,255,0.3); + --vinyl-highlight-dim: rgba(255,255,255,0.15); + --vinyl-edge: #999; + --vinyl-spindle: #888; + --shadow-elevation: rgba(0, 0, 0, 0.15); +} + +:root[data-theme="light"] .player-container, +:root[data-theme="light"] .browser-container, +:root[data-theme="light"] .scripts-container, +:root[data-theme="light"] .script-management { + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); +} + +:root[data-theme="light"] .toast { + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); +} + +:root[data-theme="light"] dialog { + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15); +} + +:root[data-theme="light"] dialog::backdrop { + background: rgba(0, 0, 0, 0.4); +} + +:root[data-theme="light"] .mini-player { + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: var(--border) var(--bg-primary); +} + +html { + overflow-y: scroll; +} + +body { + font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', 'Arial', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; +} + +/* Prevent flash of untranslated content */ +body.loading-translations { + opacity: 0; + transition: opacity 0.1s ease-in; +} + +body.translations-loaded { + opacity: 1; +} + +/* Custom Scrollbars */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Focus-visible states for keyboard accessibility */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.2); +} + +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15); +} + +.tab-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +/* Prevent scrolling when dialog is open */ +body.dialog-open { + overflow: hidden; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 2rem 2rem 0.5rem; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; +} + +h1 { + font-size: 1.5rem; + font-weight: 600; +} + +.version-label { + font-size: 0.7rem; + color: var(--text-muted); + background: var(--bg-tertiary); + padding: 0.15rem 0.5rem; + border-radius: 1rem; + font-weight: 500; + align-self: center; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--error); + transition: background 0.3s; +} + +.status-dot.connected { + background: var(--accent); +} + +.theme-toggle { + background: transparent; + border: none; + padding: 0.3rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.3s; + width: 32px; + height: 32px; + opacity: 0.7; +} + +.theme-toggle:hover { + opacity: 1; +} + +.theme-toggle svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +/* Accent Color Picker */ +.accent-picker { + position: relative; +} + +.accent-picker-btn { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + opacity: 0.8; + transition: opacity 0.2s; +} + +.accent-picker-btn:hover { + opacity: 1; + transform: none; +} + +.accent-dot { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--border); + display: block; +} + +.accent-picker-dropdown { + display: none; + position: absolute; + right: 0; + top: calc(100% + 4px); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px; + gap: 6px; + z-index: 100; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + grid-template-columns: repeat(3, 24px); +} + +.accent-picker-dropdown.open { + display: grid; +} + +.accent-swatch { + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: transform 0.15s, border-color 0.15s; +} + +.accent-swatch:hover { + transform: scale(1.2); +} + +.accent-swatch.active { + border-color: var(--text-primary); +} + +#locale-select { + background: var(--bg-tertiary); + border: 1px solid var(--border); + color: var(--text-primary); + border-radius: 6px; + padding: 4px 8px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.3s ease; +} + +#locale-select:hover { + border-color: var(--accent); +} + +#locale-select:focus { + outline: none; + border-color: var(--accent); +} + +#locale-select option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +/* Tab Bar */ +.tab-bar { + display: flex; + gap: 0.25rem; + margin-bottom: 1.5rem; + padding: 0.25rem; + background: var(--bg-secondary); + border-radius: 10px; + border: 1px solid var(--border); + position: relative; +} + +.tab-indicator { + position: absolute; + top: 0.25rem; + bottom: 0.25rem; + left: 0; + background: var(--bg-tertiary); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 0; + pointer-events: none; +} + +.tab-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.6rem 0.5rem; + background: transparent; + border: none; + border-radius: 8px; + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: color 0.2s; + width: auto; + height: auto; + position: relative; + z-index: 1; +} + +.tab-btn:hover { + color: var(--text-primary); + transform: none !important; +} + +.tab-btn.active { + color: var(--accent); + background: transparent; +} + +.tab-btn svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +[data-tab-content] { + display: none; +} + +[data-tab-content].active { + display: block; +} + +@media (max-width: 600px) { + .tab-btn span { + display: none; + } + + .tab-btn { + padding: 0.6rem; + } +} + +.player-container { + background: var(--bg-secondary); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.player-layout { + display: flex; + flex-direction: column; +} + +.player-details { + flex: 1; + min-width: 0; +} + +.album-art-container { + display: flex; + justify-content: center; + margin-bottom: 2rem; + position: relative; +} + +.album-art-glow { + position: absolute; + width: 300px; + height: 300px; + object-fit: cover; + border-radius: 8px; + filter: blur(40px) saturate(1.5); + opacity: 0.5; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(1.1); + z-index: 0; + pointer-events: none; + transition: opacity 0.5s ease, border-radius 0.6s ease; +} + +#album-art { + width: 300px; + height: 300px; + object-fit: cover; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + background: var(--bg-tertiary); + position: relative; + z-index: 1; + margin: 0; + transition: border-radius 0.6s ease, width 0.6s ease, height 0.6s ease, box-shadow 0.6s ease, margin 0.6s ease, filter 0.6s ease; +} + +:root[data-theme="light"] .album-art-glow { + opacity: 0.35; + filter: blur(50px) saturate(1.8); +} + +/* Vinyl Record Mode */ +.album-art-container.vinyl #album-art { + border-radius: 50%; + width: 210px; + height: 210px; + margin: 45px; + filter: saturate(0.8) brightness(0.92) contrast(1.05); + box-shadow: + 0 0 0 3px var(--vinyl-groove), + 0 0 0 5px var(--vinyl-ring), + 0 0 0 6px var(--vinyl-highlight), + 0 0 0 12px var(--vinyl-ring), + 0 0 0 13px var(--vinyl-highlight-dim), + 0 0 0 20px var(--vinyl-ring), + 0 0 0 21px var(--vinyl-highlight), + 0 0 0 28px var(--vinyl-ring), + 0 0 0 29px var(--vinyl-highlight-dim), + 0 0 0 36px var(--vinyl-ring), + 0 0 0 37px var(--vinyl-highlight), + 0 0 0 42px var(--vinyl-ring), + 0 0 0 43px var(--vinyl-groove), + 0 0 0 45px var(--vinyl-edge), + 0 4px 15px 45px var(--shadow-elevation); +} + +/* Vinyl label vignette overlay */ +.album-art-container.vinyl::before { + content: ''; + position: absolute; + width: 210px; + height: 210px; + border-radius: 50%; + background: radial-gradient( + circle, + transparent 50%, + rgba(0,0,0,0.25) 100% + ); + z-index: 2; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + opacity: 0; + transition: opacity 0.6s ease; +} + +.album-art-container.vinyl.spinning::before, +.album-art-container.vinyl.paused::before { + opacity: 1; +} + +.album-art-container.vinyl .album-art-glow { + border-radius: 50%; +} + +/* Center spindle hole */ +.album-art-container::after { + content: ''; + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--vinyl-spindle); + border: 2px solid var(--border); + box-shadow: inset 0 1px 3px rgba(0,0,0,0.5); + z-index: 3; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + opacity: 0; + transition: opacity 0.4s ease 0.3s; +} + +.album-art-container.vinyl::after { + opacity: 1; +} + +.album-art-container.vinyl.spinning #album-art { + animation: vinylSpin 12s linear infinite; +} + +.album-art-container.vinyl.paused #album-art { + animation: vinylSpin 12s linear infinite; + animation-play-state: paused; +} + +@keyframes vinylSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.track-info { + text-align: center; + margin-bottom: 2rem; +} + +#track-title { + font-size: 1.75rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +#artist { + font-size: 1.125rem; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +#album { + font-size: 0.875rem; + color: var(--text-muted); +} + +.playback-state { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); + margin-top: 0.5rem; +} + +.state-icon { + width: 16px; + height: 16px; +} + +.progress-container { + margin-bottom: 2rem; +} + +.time-display { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.progress-bar { + width: 100%; + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + cursor: pointer; + position: relative; + transition: transform 0.15s ease; +} + +.progress-bar:hover { + transform: scaleY(1.4); +} + +.progress-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + width: 0; + transition: width 0.1s linear; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + right: -6px; + top: 50%; + transform: translateY(-50%) scale(0); + width: 12px; + height: 12px; + background: var(--accent); + border-radius: 50%; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); + transition: transform 0.15s ease; +} + +.progress-bar:hover .progress-fill::after, +.progress-bar.dragging .progress-fill::after { + transform: translateY(-50%) scale(1); +} + +.progress-bar.dragging { + transform: scaleY(1.4); +} + +.progress-bar.dragging .progress-fill { + transition: none; +} + +.controls { + display: flex; + gap: 1rem; + justify-content: center; + align-items: center; + margin-bottom: 2rem; +} + +button { + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + padding: 0; +} + +button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.controls button { + background: var(--bg-tertiary); + color: var(--text-primary); + border-radius: 50%; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.controls button:hover:not(:disabled) { + background: var(--accent); + color: #fff; + transform: scale(1.05); +} + +.controls button.primary { + width: 56px; + height: 56px; + background: var(--accent); +} + +.controls button.primary:hover:not(:disabled) { + background: var(--accent-hover); + color: #fff; + transform: scale(1.1); +} + +.volume-container { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--bg-tertiary); + border-radius: 8px; + margin-bottom: 1rem; +} + +#volume-slider { + flex: 1; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: var(--bg-primary); + border-radius: 3px; + outline: none; +} + +#volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +#volume-slider:hover::-webkit-slider-thumb { + transform: scale(1.3); + box-shadow: 0 0 6px rgba(29, 185, 84, 0.4); +} + +#volume-slider::-moz-range-thumb { + width: 16px; + height: 16px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; + border: none; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +#volume-slider:hover::-moz-range-thumb { + transform: scale(1.3); + box-shadow: 0 0 6px rgba(29, 185, 84, 0.4); +} + +.volume-display { + font-size: 0.875rem; + color: var(--text-secondary); + min-width: 40px; + text-align: right; +} + +.mute-btn { + width: 40px; + height: 40px; + background: var(--bg-tertiary); + border-radius: 50%; + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.mute-btn:hover:not(:disabled) { + background: var(--accent); + color: #fff; + transform: scale(1.05); +} + +.source-info { + text-align: center; + font-size: 0.75rem; + color: var(--text-muted); + padding-top: 1rem; + border-top: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; +} + +.vinyl-toggle-btn { + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 1px solid var(--border); + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + margin-left: auto; +} + +.vinyl-toggle-btn:hover { + color: var(--accent); + border-color: var(--accent); + background: transparent; + transform: none; +} + +.vinyl-toggle-btn.active { + color: var(--accent); + border-color: var(--accent); + background: rgba(29, 185, 84, 0.1); +} + +.vinyl-toggle-btn svg { + width: 16px; + height: 16px; +} + +/* Scripts Section */ +.scripts-container { + background: var(--bg-secondary); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.scripts-container h2 { + font-size: 1.25rem; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.scripts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; +} + +.script-btn { + width: 100%; + height: auto; + min-height: 80px; + padding: 1rem; + border-radius: 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.script-btn:hover:not(:disabled) { + background: var(--accent); + border-color: var(--accent); + color: #fff; + transform: translateY(-2px); +} + +.script-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.script-btn .script-label { + font-weight: 600; + font-size: 0.875rem; +} + +.script-btn .script-description { + font-size: 0.75rem; + color: var(--text-secondary); + text-align: center; + transition: color 0.2s; +} + +.script-btn:hover:not(:disabled) .script-description { + color: rgba(255, 255, 255, 0.85); +} + +.script-btn.executing { + opacity: 0.6; + pointer-events: none; +} + +/* Script Management Styles */ +.script-management { + background: var(--bg-secondary); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + overflow-x: auto; +} + +.script-management h2 { + font-size: 1.25rem; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.add-card { + display: flex; + align-items: center; + justify-content: center; + border: 2px dashed var(--border); + border-radius: 8px; + padding: 1.5rem; + margin-top: 1rem; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.add-card:hover { + border-color: var(--text-muted); + background: var(--bg-tertiary); +} + +.add-card-icon { + font-size: 1.5rem; + color: var(--text-muted); + font-weight: 300; + line-height: 1; +} + +.add-card-grid { + border: 2px dashed var(--border); + background: transparent; +} + +.add-card-grid:hover:not(:disabled) { + border-color: var(--text-muted); + background: var(--bg-tertiary); +} + +.scripts-table { + width: 100%; + min-width: 500px; + border-collapse: collapse; + font-size: 0.875rem; +} + +.scripts-table th { + text-align: left; + padding: 0.75rem; + border-bottom: 2px solid var(--border); + color: var(--text-secondary); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; +} + +.scripts-table td { + padding: 0.75rem; + word-break: break-word; + border-bottom: 1px solid var(--border); +} + +.scripts-table tr:hover { + background: var(--bg-tertiary); +} + +.scripts-table code { + background: var(--bg-tertiary); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + color: var(--accent); +} + +.action-btn { + padding: 0.5rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-tertiary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.action-btn svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.action-btn:hover { + background: var(--accent); + border-color: var(--accent); + color: #fff; + transform: translateY(-1px); +} + +.action-btn.delete:hover { + background: var(--error); + border-color: var(--error); + color: #fff; +} + +.action-buttons { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.action-btn.execute:hover { + background: #3b82f6; + border-color: #3b82f6; + color: #fff; +} + +/* Execution Result Dialog */ +.execution-result { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + background: var(--bg-primary); + border-radius: 6px; + padding: 1rem; + margin: 0.5rem 0; + max-height: 400px; + overflow-y: auto; +} + +.execution-result pre { + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; + font-size: 0.813rem; + line-height: 1.5; +} + +.execution-status { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.status-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.status-item label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + font-weight: 600; +} + +.status-item value { + font-size: 0.875rem; + color: var(--text-primary); + font-weight: 500; +} + +.status-item.success value { + color: var(--accent); +} + +.status-item.error value { + color: var(--error); +} + +.result-section { + margin-bottom: 1rem; +} + +.result-section h4 { + font-size: 0.875rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + font-weight: 600; +} + +.loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid var(--bg-tertiary); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Dialog Styles */ +dialog { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0; + max-width: 500px; + width: 90%; + margin: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8); +} + +/* Ensure dialogs are hidden until explicitly opened */ +dialog:not([open]) { + display: none; +} + +dialog::backdrop { + background: rgba(0, 0, 0, 0.8); +} + +.confirm-dialog { + max-width: 400px; + padding: 1.5rem; +} + +.confirm-dialog p { + margin-bottom: 1.25rem; + font-size: 0.95rem; + line-height: 1.5; + color: var(--text-primary); +} + +.confirm-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.confirm-dialog-actions button { + padding: 8px 20px; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.2s; +} + +.btn-cancel { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.btn-cancel:hover { + background: var(--border); +} + +.btn-danger { + background: var(--error); + color: #fff; +} + +.btn-danger:hover { + filter: brightness(1.15); +} + +.dialog-header { + padding: 1.5rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.dialog-header h3 { + margin: 0; + font-size: 1.25rem; +} + +.dialog-body { + padding: 1.5rem; +} + +.dialog-body label { + display: block; + margin-bottom: 1rem; + color: var(--text-secondary); + font-size: 0.875rem; +} + +.dialog-body input, +.dialog-body textarea, +.dialog-body select { + display: block; + width: 100%; + padding: 0.5rem; + margin-top: 0.25rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-family: inherit; + font-size: 0.875rem; +} + +.dialog-body textarea { + min-height: 80px; + resize: vertical; +} + +.dialog-body input:focus, +.dialog-body textarea:focus, +.dialog-body select:focus { + outline: none; + border-color: var(--accent); +} + +.dialog-footer { + padding: 1.5rem; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.dialog-footer button { + padding: 0.625rem 1.5rem; + border-radius: 6px; + border: none; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + min-width: 100px; + white-space: nowrap; +} + +.dialog-footer .btn-primary { + background: var(--accent); + color: var(--text-primary); +} + +.dialog-footer .btn-primary:hover { + background: var(--accent-hover); +} + +.dialog-footer .btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.dialog-footer .btn-secondary:hover { + background: var(--border); +} + +.empty-state { + text-align: center; + padding: 2rem; + color: var(--text-muted); +} + +.scripts-empty { + text-align: center; + color: var(--text-muted); + padding: 2rem; + font-size: 0.875rem; +} + +.empty-state-illustration { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 3rem 2rem; +} + +.empty-state-illustration svg { + width: 64px; + height: 64px; + fill: none; + stroke: var(--text-muted); + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.5; +} + +.empty-state-illustration p { + color: var(--text-muted); + font-size: 0.875rem; + margin: 0; +} + +.toast-container { + position: fixed; + bottom: 2rem; + right: 2rem; + display: flex; + flex-direction: column-reverse; + gap: 8px; + z-index: 1000; + pointer-events: none; +} + +.toast { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.5rem; + padding-left: 1.25rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + opacity: 0; + transform: translateY(20px); + transition: all 0.3s; + pointer-events: auto; + border-left: 4px solid var(--border); +} + +.toast.show { + opacity: 1; + transform: translateY(0); +} + +.toast.success { + border-color: var(--accent); + border-left-color: var(--accent); +} + +.toast.error { + border-color: var(--error); + border-left-color: var(--error); +} + +/* Auth Modal */ +#auth-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +#auth-overlay.hidden { + display: none; +} + +.auth-modal { + background: var(--bg-secondary); + padding: 2rem; + border-radius: 12px; + max-width: 400px; + width: 90%; +} + +.auth-modal h2 { + margin-bottom: 1rem; + font-size: 1.5rem; +} + +.auth-modal p { + margin-bottom: 1rem; + color: var(--text-secondary); + font-size: 0.875rem; +} + +#token-input { + width: 100%; + padding: 0.75rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-size: 0.875rem; + margin-bottom: 1rem; +} + +#token-input:focus { + outline: none; + border-color: var(--accent); +} + +.btn-connect { + width: 100%; + height: auto; + padding: 0.75rem; + border-radius: 6px; + background: var(--accent); + color: var(--text-primary); + font-weight: 600; + transition: background 0.2s; +} + +.btn-connect:hover { + background: var(--accent-hover); + transform: none; +} + +.help-text { + background: var(--bg-tertiary); + padding: 0.75rem; + border-radius: 6px; + margin-top: 1rem; +} + +.help-text code { + background: var(--bg-primary); + padding: 0.25rem 0.5rem; + border-radius: 3px; + font-family: monospace; + font-size: 0.875rem; +} + +.error-message { + color: var(--error); + font-size: 0.875rem; + margin-top: 0.5rem; + display: none; +} + +.error-message.visible { + display: block; +} + +.clear-token-btn { + width: auto; + height: auto; + padding: 4px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border); + cursor: pointer; + opacity: 0.7; +} + +.clear-token-btn:hover { + opacity: 1; + background: var(--error); + color: var(--text-primary); + border-color: var(--error); +} + +/* Mini Player (Sticky) */ +.mini-player { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: rgba(30, 30, 30, 0.8); + -webkit-backdrop-filter: blur(20px) saturate(1.5); + backdrop-filter: blur(20px) saturate(1.5); + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding: 0.75rem 1rem; + padding-top: calc(0.75rem + 2px); + display: flex; + align-items: center; + gap: 1.5rem; + z-index: 1000; + transform: translateY(0); + transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.3); +} + +:root[data-theme="light"] .mini-player { + background: rgba(245, 245, 245, 0.75); + border-top: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.1); +} + +.mini-player::before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 2px; + width: var(--mini-progress, 0%); + background: var(--accent); + transition: width 0.1s linear; +} + +.mini-player.hidden { + transform: translateY(100%); + opacity: 0; + pointer-events: none; +} + +.mini-player-info { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 200px; + flex-shrink: 0; +} + +.mini-album-art { + width: 40px; + height: 40px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; +} + +.mini-track-details { + display: flex; + flex-direction: column; + min-width: 0; +} + +.mini-track-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mini-artist { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mini-progress-container { + flex: 1; + display: flex; + align-items: center; + gap: 1rem; + min-width: 0; +} + +.mini-time-display { + display: flex; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--text-secondary); + flex-shrink: 0; +} + +.mini-progress-bar { + flex: 1; + height: 4px; + background: var(--bg-tertiary); + border-radius: 2px; + cursor: pointer; + position: relative; + min-width: 100px; +} + +.mini-progress-bar:hover { + height: 6px; +} + +.mini-progress-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + width: 0%; + transition: width 0.1s linear; + position: relative; +} + +.mini-progress-fill::after { + content: ''; + position: absolute; + right: -5px; + top: 50%; + transform: translateY(-50%) scale(0); + width: 10px; + height: 10px; + background: var(--accent); + border-radius: 50%; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); + transition: transform 0.15s ease; +} + +.mini-progress-bar:hover .mini-progress-fill::after { + transform: translateY(-50%) scale(1); +} + +.mini-controls { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.mini-control-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + padding: 0; +} + +.mini-control-btn:hover { + background: var(--accent); + border-color: var(--accent); + color: #fff; + transform: scale(1.05); +} + +.mini-control-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.mini-control-btn svg { + width: 20px; + height: 20px; + fill: currentColor; +} + +.mini-volume-container { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; + min-width: 180px; +} + +.mini-volume-slider { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--bg-tertiary); + border-radius: 2px; + outline: none; + cursor: pointer; + min-width: 80px; +} + +.mini-volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; +} + +.mini-volume-slider::-moz-range-thumb { + width: 12px; + height: 12px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; + border: none; +} + +.mini-volume-slider:hover::-webkit-slider-thumb { + transform: scale(1.2); +} + +.mini-volume-slider:hover::-moz-range-thumb { + transform: scale(1.2); +} + +.mini-volume-display { + font-size: 0.75rem; + color: var(--text-secondary); + min-width: 36px; + text-align: right; +} + +/* SVG Icons */ +svg { + width: 24px; + height: 24px; + fill: currentColor; +} + +button.primary svg { + width: 28px; + height: 28px; +} + +@media (max-width: 600px) { + .container { + padding: 1rem; + } + + #album-art { + width: 250px; + height: 250px; + } + + .album-art-container.vinyl #album-art { + width: 170px; + height: 170px; + margin: 40px; + box-shadow: + 0 0 0 3px #2a2a2a, + 0 0 0 5px #1a1a1a, + 0 0 0 6px rgba(255,255,255,0.05), + 0 0 0 12px #1a1a1a, + 0 0 0 13px rgba(255,255,255,0.03), + 0 0 0 20px #1a1a1a, + 0 0 0 21px rgba(255,255,255,0.05), + 0 0 0 28px #1a1a1a, + 0 0 0 29px rgba(255,255,255,0.03), + 0 0 0 36px #1a1a1a, + 0 0 0 37px rgba(255,255,255,0.04), + 0 0 0 38px #2a2a2a, + 0 0 0 40px #111, + 0 4px 12px 40px rgba(0,0,0,0.4); + } + + #track-title { + font-size: 1.5rem; + } +} + +/* Footer */ +footer { + text-align: center; + padding: 0.75rem 1rem; + margin-top: 0.5rem; + color: var(--text-muted); + font-size: 0.75rem; + transition: padding-bottom 0.3s ease-in-out; +} + +body.mini-player-visible footer { + padding-bottom: 70px; +} + +footer a { + color: var(--accent); + text-decoration: none; + transition: color 0.2s; +} + +footer a:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +footer .separator { + margin: 0 0.5rem; + color: var(--text-muted); +} /* ======================================== Media Browser Styles @@ -2412,6 +2527,45 @@ } } +/* Connection Banner */ +.connection-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 10px 16px; + background: var(--error); + color: #fff; + font-size: 0.85rem; + font-weight: 500; + text-align: center; + transition: transform 0.3s ease; +} + +.connection-banner.hidden { + transform: translateY(-100%); + pointer-events: none; +} + +.connection-banner-btn { + padding: 4px 14px; + background: rgba(255, 255, 255, 0.2); + color: #fff; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 600; + transition: background 0.2s; +} + +.connection-banner-btn:hover { + background: rgba(255, 255, 255, 0.35); +} + /* Wide screens - horizontal player layout */ @media (min-width: 900px) { .container { diff --git a/media_server/static/index.html b/media_server/static/index.html index 6a6e2b5..c6703d3 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -29,7 +29,7 @@ 0:00 0:00 -
+
@@ -39,7 +39,7 @@ - +
50%
@@ -62,7 +62,7 @@
- +
@@ -88,32 +88,38 @@
+ + + -
+
- - - - -
-
+
@@ -138,7 +144,7 @@ 0:00 0:00
-
+
@@ -167,7 +173,7 @@ - +
50%
@@ -182,7 +188,7 @@
-
+
@@ -249,7 +255,7 @@
-
+
@@ -262,7 +268,7 @@
-
+
@@ -290,7 +296,7 @@ -
+

Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)

@@ -480,8 +486,17 @@ - -
+ + +

+
+ + +
+
+ + +
diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js index 5b6041a..dde3184 100644 --- a/media_server/static/js/app.js +++ b/media_server/static/js/app.js @@ -55,7 +55,9 @@ const POSITION_INTERPOLATION_MS = 100; const SEARCH_DEBOUNCE_MS = 200; const TOAST_DURATION_MS = 3000; - const WS_RECONNECT_MS = 3000; + const WS_BACKOFF_BASE_MS = 3000; + const WS_BACKOFF_MAX_MS = 30000; + const WS_MAX_RECONNECT_ATTEMPTS = 20; const WS_PING_INTERVAL_MS = 30000; const VOLUME_RELEASE_DELAY_MS = 500; @@ -105,11 +107,17 @@ target.classList.add('active'); } - // Update tab buttons - document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); + // Update tab buttons and ARIA state + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.remove('active'); + btn.setAttribute('aria-selected', 'false'); + btn.setAttribute('tabindex', '-1'); + }); const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`); if (activeBtn) { activeBtn.classList.add('active'); + activeBtn.setAttribute('aria-selected', 'true'); + activeBtn.setAttribute('tabindex', '0'); updateTabIndicator(activeBtn); } @@ -413,6 +421,7 @@ let ws = null; let reconnectTimeout = null; let pingInterval = null; + let wsReconnectAttempts = 0; let currentState = 'idle'; let currentDuration = 0; let currentPosition = 0; @@ -421,6 +430,7 @@ let scripts = []; let lastStatus = null; // Store last status for locale switching let lastArtworkKey = null; // Track artwork identity to skip redundant loads + let currentArtworkBlobUrl = null; // Track current blob URL for safe revocation // Dialog dirty state tracking let scriptFormDirty = false; @@ -431,6 +441,61 @@ let lastPositionValue = 0; let interpolationInterval = null; + function setupProgressDrag(bar, fill) { + let dragging = false; + + function getPercent(clientX) { + const rect = bar.getBoundingClientRect(); + return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + } + + function updatePreview(percent) { + fill.style.width = (percent * 100) + '%'; + } + + function handleStart(clientX) { + if (currentDuration <= 0) return; + dragging = true; + bar.classList.add('dragging'); + updatePreview(getPercent(clientX)); + } + + function handleMove(clientX) { + if (!dragging) return; + updatePreview(getPercent(clientX)); + } + + function handleEnd(clientX) { + if (!dragging) return; + dragging = false; + bar.classList.remove('dragging'); + const percent = getPercent(clientX); + seek(percent * currentDuration); + } + + // Mouse events + bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); }); + document.addEventListener('mousemove', (e) => { handleMove(e.clientX); }); + document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); }); + + // Touch events + bar.addEventListener('touchstart', (e) => { handleStart(e.touches[0].clientX); }, { passive: true }); + document.addEventListener('touchmove', (e) => { if (dragging) handleMove(e.touches[0].clientX); }); + document.addEventListener('touchend', (e) => { + if (dragging) { + const touch = e.changedTouches[0]; + handleEnd(touch.clientX); + } + }); + + // Simple click (mousedown + mouseup without move) + bar.addEventListener('click', (e) => { + if (currentDuration > 0) { + seek(getPercent(e.clientX) * currentDuration); + } + }); + } + // Initialize on page load window.addEventListener('DOMContentLoaded', async () => { // Cache DOM references @@ -516,26 +581,15 @@ }, { threshold: 0.1 }); observer.observe(playerContainer); - // Mini player progress bar click to seek - const miniProgressBar = document.getElementById('mini-progress-bar'); - miniProgressBar.addEventListener('click', (e) => { - const rect = miniProgressBar.getBoundingClientRect(); - const percent = (e.clientX - rect.left) / rect.width; - const position = percent * currentDuration; - seek(position); - }); - - // Progress bar click to seek - const progressBar = document.getElementById('progress-bar'); - progressBar.addEventListener('click', (e) => { - if (currentDuration > 0) { - const rect = progressBar.getBoundingClientRect(); - const x = e.clientX - rect.left; - const percent = x / rect.width; - const seekPos = percent * currentDuration; - seek(seekPos); - } - }); + // Drag-to-seek for progress bars + setupProgressDrag( + document.getElementById('mini-progress-bar'), + document.getElementById('mini-progress-fill') + ); + setupProgressDrag( + document.getElementById('progress-bar'), + document.getElementById('progress-fill') + ); // Enter key in token input document.getElementById('token-input').addEventListener('keypress', (e) => { @@ -579,6 +633,99 @@ closeCallbackDialog(); } }); + + // Delegated click handlers for script table actions (XSS-safe) + document.getElementById('scriptsTableBody').addEventListener('click', (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + const name = btn.dataset.scriptName; + if (action === 'execute') executeScriptDebug(name); + else if (action === 'edit') showEditScriptDialog(name); + else if (action === 'delete') deleteScriptConfirm(name); + }); + + // Delegated click handlers for callback table actions (XSS-safe) + document.getElementById('callbacksTableBody').addEventListener('click', (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + const name = btn.dataset.callbackName; + if (action === 'execute') executeCallbackDebug(name); + else if (action === 'edit') showEditCallbackDialog(name); + else if (action === 'delete') deleteCallbackConfirm(name); + }); + + // Initialize browser toolbar and load folders + initBrowserToolbar(); + if (token) { + loadMediaFolders(); + } + + // Cleanup blob URLs on page unload + window.addEventListener('beforeunload', () => { + thumbnailCache.forEach(url => URL.revokeObjectURL(url)); + thumbnailCache.clear(); + }); + + // Tab bar keyboard navigation (WAI-ARIA Tabs pattern) + document.getElementById('tabBar').addEventListener('keydown', (e) => { + const tabs = Array.from(document.querySelectorAll('.tab-btn')); + const currentIdx = tabs.indexOf(document.activeElement); + if (currentIdx === -1) return; + + let newIdx; + if (e.key === 'ArrowRight') { + newIdx = (currentIdx + 1) % tabs.length; + } else if (e.key === 'ArrowLeft') { + newIdx = (currentIdx - 1 + tabs.length) % tabs.length; + } else if (e.key === 'Home') { + newIdx = 0; + } else if (e.key === 'End') { + newIdx = tabs.length - 1; + } else { + return; + } + + e.preventDefault(); + tabs[newIdx].focus(); + switchTab(tabs[newIdx].dataset.tab); + }); + + // Global keyboard shortcuts + document.addEventListener('keydown', (e) => { + // Skip when typing in inputs, textareas, selects, or when a dialog is open + const tag = e.target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (document.querySelector('dialog[open]')) return; + + switch (e.key) { + case ' ': + e.preventDefault(); + togglePlayPause(); + break; + case 'ArrowLeft': + e.preventDefault(); + if (currentDuration > 0) seek(Math.max(0, currentPosition - 5)); + break; + case 'ArrowRight': + e.preventDefault(); + if (currentDuration > 0) seek(Math.min(currentDuration, currentPosition + 5)); + break; + case 'ArrowUp': + e.preventDefault(); + setVolume(Math.min(100, parseInt(dom.volumeSlider.value) + 5)); + break; + case 'ArrowDown': + e.preventDefault(); + setVolume(Math.max(0, parseInt(dom.volumeSlider.value) - 5)); + break; + case 'm': + case 'M': + toggleMute(); + break; + } + }); }); function showAuthForm(errorMessage = '') { @@ -631,7 +778,9 @@ ws.onopen = () => { console.log('WebSocket connected'); + wsReconnectAttempts = 0; updateConnectionStatus(true); + hideConnectionBanner(); hideAuthForm(); loadScripts(); loadScriptsTable(); @@ -667,14 +816,30 @@ localStorage.removeItem('media_server_token'); showAuthForm(t('auth.invalid')); } else if (event.code !== 1000) { - // Abnormal closure - attempt reconnect - reconnectTimeout = setTimeout(() => { - const savedToken = localStorage.getItem('media_server_token'); - if (savedToken) { - console.log('Attempting to reconnect...'); - connectWebSocket(savedToken); + // Abnormal closure - attempt reconnect with exponential backoff + wsReconnectAttempts++; + + if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) { + const delay = Math.min( + WS_BACKOFF_BASE_MS * Math.pow(1.5, wsReconnectAttempts - 1), + WS_BACKOFF_MAX_MS + ); + console.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${wsReconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...`); + + if (wsReconnectAttempts >= 3) { + showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false); } - }, WS_RECONNECT_MS); + + reconnectTimeout = setTimeout(() => { + const savedToken = localStorage.getItem('media_server_token'); + if (savedToken) { + connectWebSocket(savedToken); + } + }, delay); + } else { + // Exhausted retries - show manual reconnect + showConnectionBanner(t('connection.lost'), true); + } } }; @@ -694,6 +859,29 @@ } } + function showConnectionBanner(message, showButton) { + const banner = document.getElementById('connectionBanner'); + const text = document.getElementById('connectionBannerText'); + const btn = document.getElementById('connectionBannerBtn'); + text.textContent = message; + btn.style.display = showButton ? '' : 'none'; + banner.classList.remove('hidden'); + } + + function hideConnectionBanner() { + const banner = document.getElementById('connectionBanner'); + banner.classList.add('hidden'); + } + + function manualReconnect() { + const savedToken = localStorage.getItem('media_server_token'); + if (savedToken) { + wsReconnectAttempts = 0; + hideConnectionBanner(); + connectWebSocket(savedToken); + } + } + function updateUI(status) { // Store status for locale switching lastStatus = status; @@ -719,15 +907,35 @@ if (artworkKey !== lastArtworkKey) { lastArtworkKey = artworkKey; - const artworkUrl = artworkSource - ? `/api/media/artwork?token=${encodeURIComponent(localStorage.getItem('media_server_token'))}&_=${Date.now()}` - : "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"; - dom.albumArt.src = artworkUrl; - dom.miniAlbumArt.src = artworkUrl; - if (dom.albumArtGlow) { - dom.albumArtGlow.src = artworkSource - ? artworkUrl - : "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"; + const placeholderArt = "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"; + const placeholderGlow = "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"; + if (artworkSource) { + // Fetch artwork with Authorization header (avoid token in URL) + const token = localStorage.getItem('media_server_token'); + fetch(`/api/media/artwork?_=${Date.now()}`, { + headers: { 'Authorization': `Bearer ${token}` } + }) + .then(r => r.ok ? r.blob() : null) + .then(blob => { + if (!blob) return; + const oldBlobUrl = currentArtworkBlobUrl; + const url = URL.createObjectURL(blob); + currentArtworkBlobUrl = url; + dom.albumArt.src = url; + dom.miniAlbumArt.src = url; + if (dom.albumArtGlow) dom.albumArtGlow.src = url; + // Revoke old blob URL after a delay to let pending loads finish + if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000); + }) + .catch(err => console.error('Artwork fetch failed:', err)); + } else { + if (currentArtworkBlobUrl) { + URL.revokeObjectURL(currentArtworkBlobUrl); + currentArtworkBlobUrl = null; + } + dom.albumArt.src = placeholderArt; + dom.miniAlbumArt.src = placeholderArt; + if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow; } } @@ -807,16 +1015,23 @@ const widthStr = `${percent}%`; const currentStr = formatTime(position); const totalStr = formatTime(duration); + const posRound = Math.round(position); + const durRound = Math.round(duration); dom.progressFill.style.width = widthStr; dom.currentTime.textContent = currentStr; dom.totalTime.textContent = totalStr; dom.progressBar.dataset.duration = duration; + dom.progressBar.setAttribute('aria-valuenow', posRound); + dom.progressBar.setAttribute('aria-valuemax', durRound); dom.miniProgressFill.style.width = widthStr; dom.miniCurrentTime.textContent = currentStr; dom.miniTotalTime.textContent = totalStr; if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr); + const miniBar = document.getElementById('mini-progress-bar'); + miniBar.setAttribute('aria-valuenow', posRound); + miniBar.setAttribute('aria-valuemax', durRound); } function startPositionInterpolation() { @@ -941,7 +1156,7 @@ } function displayScripts() { - const container = document.getElementById('scripts-container'); + const container = document.getElementById('panel-quick-actions'); const grid = document.getElementById('scripts-grid'); grid.innerHTML = ''; @@ -1012,15 +1227,53 @@ } function showToast(message, type = 'success') { - const toast = document.getElementById('toast'); + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; toast.textContent = message; - toast.className = `toast ${type} show`; + container.appendChild(toast); + + // Trigger reflow then show + requestAnimationFrame(() => { + toast.classList.add('show'); + }); setTimeout(() => { toast.classList.remove('show'); + toast.addEventListener('transitionend', () => toast.remove(), { once: true }); + // Fallback removal if transitionend doesn't fire + setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500); }, TOAST_DURATION_MS); } + function showConfirm(message) { + return new Promise((resolve) => { + const dialog = document.getElementById('confirmDialog'); + const msg = document.getElementById('confirmDialogMessage'); + const btnCancel = document.getElementById('confirmDialogCancel'); + const btnConfirm = document.getElementById('confirmDialogConfirm'); + + msg.textContent = message; + + function cleanup() { + btnCancel.removeEventListener('click', onCancel); + btnConfirm.removeEventListener('click', onConfirm); + dialog.removeEventListener('close', onClose); + dialog.close(); + } + + function onCancel() { cleanup(); resolve(false); } + function onConfirm() { cleanup(); resolve(true); } + function onClose() { cleanup(); resolve(false); } + + btnCancel.addEventListener('click', onCancel); + btnConfirm.addEventListener('click', onConfirm); + dialog.addEventListener('close', onClose); + + dialog.showModal(); + }); + } + // Script Management Functions let _loadScriptsPromise = null; @@ -1053,20 +1306,20 @@ tbody.innerHTML = scriptsList.map(script => `
- - + + - +
${script.name}${script.label || script.name}${escapeHtml(script.name)}${escapeHtml(script.label || script.name)} ${escapeHtml(script.command || 'N/A')} ${script.timeout}s
- - -
@@ -1151,10 +1404,10 @@ } } - function closeScriptDialog() { + async function closeScriptDialog() { // Check if form has unsaved changes if (scriptFormDirty) { - if (!confirm(t('scripts.confirm.unsaved'))) { + if (!await showConfirm(t('scripts.confirm.unsaved'))) { return; // User cancelled, don't close } } @@ -1220,7 +1473,7 @@ } async function deleteScriptConfirm(scriptName) { - if (!confirm(`Are you sure you want to delete the script "${scriptName}"?`)) { + if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) { return; } @@ -1280,19 +1533,19 @@ tbody.innerHTML = callbacksList.map(callback => `
${callback.name}${escapeHtml(callback.name)} ${escapeHtml(callback.command)} ${callback.timeout}s
- - -
@@ -1367,10 +1620,10 @@ } } - function closeCallbackDialog() { + async function closeCallbackDialog() { // Check if form has unsaved changes if (callbackFormDirty) { - if (!confirm(t('callbacks.confirm.unsaved'))) { + if (!await showConfirm(t('callbacks.confirm.unsaved'))) { return; // User cancelled, don't close } } @@ -1433,7 +1686,7 @@ } async function deleteCallbackConfirm(callbackName) { - if (!confirm(`Are you sure you want to delete the callback "${callbackName}"?`)) { + if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) { return; } @@ -1650,6 +1903,8 @@ let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid'; let cachedItems = null; let browserSearchTerm = ''; let browserSearchTimer = null; +const thumbnailCache = new Map(); +const THUMBNAIL_CACHE_MAX = 200; // Load media folders on page load async function loadMediaFolders() { @@ -1852,8 +2107,12 @@ function renderBreadcrumbs(currentPath, parentPath) { } function revokeBlobUrls(container) { + const cachedUrls = new Set(thumbnailCache.values()); container.querySelectorAll('img[src^="blob:"]').forEach(img => { - URL.revokeObjectURL(img.src); + // Don't revoke URLs managed by the thumbnail cache + if (!cachedUrls.has(img.src)) { + URL.revokeObjectURL(img.src); + } }); } @@ -2117,12 +2376,20 @@ async function loadThumbnail(imgElement, fileName) { return; } - const fullPath = currentPath === '/' - ? '/' + fileName - : currentPath + '/' + fileName; - const encodedPath = encodeURIComponent( - mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\') - ); + const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); + + // Check cache first + if (thumbnailCache.has(absolutePath)) { + const cachedUrl = thumbnailCache.get(absolutePath); + imgElement.onload = () => { + imgElement.classList.remove('loading'); + imgElement.classList.add('loaded'); + }; + imgElement.src = cachedUrl; + return; + } + + const encodedPath = encodeURIComponent(absolutePath); const response = await fetch( `/api/browser/thumbnail?path=${encodedPath}&size=medium`, @@ -2132,6 +2399,14 @@ async function loadThumbnail(imgElement, fileName) { if (response.status === 200) { const blob = await response.blob(); const url = URL.createObjectURL(blob); + thumbnailCache.set(absolutePath, url); + + // Evict oldest entries when cache exceeds limit + if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) { + const oldest = thumbnailCache.keys().next().value; + URL.revokeObjectURL(thumbnailCache.get(oldest)); + thumbnailCache.delete(oldest); + } // Wait for image to actually load before showing it imgElement.onload = () => { @@ -2139,9 +2414,14 @@ async function loadThumbnail(imgElement, fileName) { imgElement.classList.add('loaded'); }; - // Revoke previous blob URL if any + // Revoke previous blob URL if not managed by cache + // (Cache is keyed by path, so check values) if (imgElement.src && imgElement.src.startsWith('blob:')) { - URL.revokeObjectURL(imgElement.src); + let isCached = false; + for (const url of thumbnailCache.values()) { + if (url === imgElement.src) { isCached = true; break; } + } + if (!isCached) URL.revokeObjectURL(imgElement.src); } imgElement.src = url; } else { @@ -2164,6 +2444,16 @@ async function loadThumbnail(imgElement, fileName) { } } +function buildAbsolutePath(folderId, relativePath, fileName) { + const folderPath = mediaFolders[folderId].path; + // Detect separator from folder path + const sep = folderPath.includes('/') ? '/' : '\\'; + const fullRelative = relativePath === '/' + ? sep + fileName + : relativePath.replace(/[/\\]/g, sep) + sep + fileName; + return folderPath + fullRelative; +} + let playInProgress = false; async function playMediaFile(fileName) { @@ -2176,10 +2466,7 @@ async function playMediaFile(fileName) { return; } - const fullPath = currentPath === '/' - ? '/' + fileName - : currentPath + '/' + fileName; - const absolutePath = mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\'); + const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); const response = await fetch('/api/browser/play', { method: 'POST', @@ -2235,7 +2522,7 @@ async function playAllFolder() { } } -function downloadFile(fileName, event) { +async function downloadFile(fileName, event) { if (event) event.stopPropagation(); const token = localStorage.getItem('media_server_token'); if (!token) return; @@ -2244,14 +2531,27 @@ function downloadFile(fileName, event) { ? '/' + fileName : currentPath + '/' + fileName; const encodedPath = encodeURIComponent(fullPath); - const url = `/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}&token=${token}`; - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + try { + const response = await fetch( + `/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}`, + { headers: { 'Authorization': `Bearer ${token}` } } + ); + if (!response.ok) throw new Error('Download failed'); + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Download error:', error); + showToast(t('browser.download_error'), 'error'); + } } function createDownloadBtn(fileName, cssClass) { @@ -2341,7 +2641,8 @@ function applyBrowserSearch() { } const filtered = cachedItems.filter(item => - item.name.toLowerCase().includes(browserSearchTerm) + item.name.toLowerCase().includes(browserSearchTerm) || + (item.title && item.title.toLowerCase().includes(browserSearchTerm)) ); renderBrowserItems(filtered); } @@ -2468,13 +2769,3 @@ async function saveFolder(event) { closeFolderDialog(); } -// Initialize browser on page load -window.addEventListener('DOMContentLoaded', () => { - initBrowserToolbar(); - - // Load media folders after authentication - const token = localStorage.getItem('media_server_token'); - if (token) { - loadMediaFolders(); - } -}); diff --git a/media_server/static/locales/en.json b/media_server/static/locales/en.json index e341697..e72b373 100644 --- a/media_server/static/locales/en.json +++ b/media_server/static/locales/en.json @@ -154,6 +154,12 @@ "browser.folder_dialog.enabled": "Enabled", "browser.folder_dialog.cancel": "Cancel", "browser.folder_dialog.save": "Save", + "browser.download_error": "Failed to download file", + "connection.reconnecting": "Connection lost. Reconnecting (attempt {attempt})...", + "connection.lost": "Connection lost. Server may be unavailable.", + "connection.reconnect": "Reconnect", + "dialog.cancel": "Cancel", + "dialog.confirm": "Confirm", "footer.created_by": "Created by", "footer.source_code": "Source Code" } diff --git a/media_server/static/locales/ru.json b/media_server/static/locales/ru.json index 0b545e2..bed6387 100644 --- a/media_server/static/locales/ru.json +++ b/media_server/static/locales/ru.json @@ -154,6 +154,12 @@ "browser.folder_dialog.enabled": "Включено", "browser.folder_dialog.cancel": "Отмена", "browser.folder_dialog.save": "Сохранить", + "browser.download_error": "Не удалось скачать файл", + "connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...", + "connection.lost": "Соединение потеряно. Сервер может быть недоступен.", + "connection.reconnect": "Переподключиться", + "dialog.cancel": "Отмена", + "dialog.confirm": "Подтвердить", "footer.created_by": "Создано", "footer.source_code": "Исходный код" }