This commit is contained in:
Eduard Prigoana 2025-10-19 18:33:41 +03:00
parent f990cb1fcc
commit 2a708e2b99
9 changed files with 1339 additions and 417 deletions

View file

@ -17,8 +17,6 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.jsdelivr.net/npm/@ffmpeg/ffmpeg@0.12.10/dist/umd/ffmpeg.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@ffmpeg/util@0.12.1/dist/umd/index.min.js"></script>
</head>
<body>
<audio id="audio-player"></audio>
@ -37,6 +35,11 @@
<div id="queue-list"></div>
</div>
</div>
<div class="queue-track-menu" id="queue-track-menu">
<ul>
<li data-action="remove">Remove from Queue</li>
</ul>
</div>
<div id="sidebar-overlay"></div>
<div class="app-container">
@ -54,33 +57,34 @@
<ul>
<li class="nav-item">
<a href="#home">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-house-icon lucide-house"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<span>Home</span>
</a>
</li>
<li class="nav-item">
<a href="#settings">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings-icon lucide-settings"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span>Settings</span>
</a>
</li>
<li class="nav-item">
<a href="#about">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info-icon lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
<span>About</span>
</a>
</li>
<li class="nav-item">
<a href="https://github.com/eduardprigoana/monochrome" target="_blank" rel="noopener noreferrer">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-github-icon lucide-github"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>
<span>GitHub</span>
</a>
</li>
<li class="nav-item">
<a href="https://status.monochrome.tf" target="_blank" rel="noopener noreferrer">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-activity-icon lucide-activity"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>
<span>Status</span>
</a>
</li>
@ -143,14 +147,22 @@
<div class="type">Album</div>
<h1 class="title" id="album-detail-title"></h1>
<div class="meta" id="album-detail-meta"></div>
<button id="download-album-btn" class="btn-download" style="margin-top: 1rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>Download Album</span>
</button>
<div class="detail-header-actions">
<button id="play-album-btn" class="btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
<span>Play Album</span>
</button>
<button id="download-album-btn" class="btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>Download Album</span>
</button>
</div>
</div>
</header>
<div class="track-list" id="album-detail-tracklist"></div>
@ -163,14 +175,16 @@
<div class="type">Artist</div>
<h1 class="title" id="artist-detail-name"></h1>
<div class="meta" id="artist-detail-meta"></div>
<button id="download-discography-btn" class="btn-download" style="margin-top: 1rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>Download Discography</span>
</button>
<div class="detail-header-actions">
<button id="download-discography-btn" class="btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>Download Discography</span>
</button>
</div>
</div>
</header>
<section class="content-section">
@ -186,10 +200,33 @@
<div id="page-settings" class="page">
<h2 class="section-title">Settings</h2>
<div class="settings-list">
<div class="setting-item">
<div class="info">
<span class="label">Theme</span>
<span class="description">Choose your preferred color scheme</span>
</div>
</div>
<div class="theme-picker" id="theme-picker">
<div class="theme-option" data-theme="monochrome">Black</div>
<div class="theme-option" data-theme="dark">Dark</div>
<div class="theme-option" data-theme="ocean">Ocean</div>
<div class="theme-option" data-theme="purple">Purple</div>
<div class="theme-option" data-theme="forest">Forest</div>
<div class="theme-option" data-theme="custom">Custom</div>
</div>
<div class="custom-theme-editor" id="custom-theme-editor">
<h4>Custom Theme</h4>
<div class="theme-color-grid" id="theme-color-grid"></div>
<div class="theme-actions">
<button class="btn-secondary" id="apply-custom-theme">Apply Theme</button>
<button class="btn-secondary" id="reset-custom-theme">Reset</button>
</div>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Audio Quality</span>
<span class="description">Quality for streaming and downloads.</span>
<span class="description">Quality for streaming and downloads</span>
</div>
<select id="quality-setting">
<option value="LOSSLESS">FLAC (Lossless)</option>
@ -200,17 +237,24 @@
<div class="setting-item">
<div class="info">
<span class="label">Crossfade</span>
<span class="description">Allow songs to fade into each other.</span>
<span class="description">Fade between tracks smoothly</span>
</div>
<label class="toggle-switch">
<input type="checkbox" checked>
<input type="checkbox" id="crossfade-toggle">
<span class="slider"></span>
</label>
</div>
<div class="setting-item" id="crossfade-duration-setting" style="display: none;">
<div class="info">
<span class="label">Crossfade Duration</span>
<span class="description">Duration in seconds</span>
</div>
<input type="number" id="crossfade-duration" min="1" max="12" value="5">
</div>
<div class="setting-item">
<div class="info">
<span class="label">Gapless Playback</span>
<span class="description">Play audio without interruption between tracks.</span>
<span class="description">Play audio without interruption between tracks</span>
</div>
<label class="toggle-switch">
<input type="checkbox" checked>
@ -220,7 +264,7 @@
<div class="setting-item">
<div class="info">
<span class="label">Normalize Volume</span>
<span class="description">Set the same volume level for all tracks.</span>
<span class="description">Set the same volume level for all tracks</span>
</div>
<label class="toggle-switch">
<input type="checkbox">
@ -230,57 +274,57 @@
<div class="setting-item">
<div class="info">
<span class="label">Cache</span>
<span class="description" id="cache-info">Stores API responses to reduce requests.</span>
<span class="description" id="cache-info">Stores API responses to reduce requests</span>
</div>
<button id="clear-cache-btn" class="btn-secondary">Clear Cache</button>
</div>
<div id="api-instance-manager">
<div class="setting-item" style="padding-bottom: 1rem; border: none;">
<div class="info">
<span class="label">API Instances</span>
<span class="description">Manage and prioritize API instances. The app will try them in order if one fails.</span>
</div>
</div>
<ul id="api-instance-list"></ul>
<form id="add-instance-form">
<input type="url" id="custom-instance-input" placeholder="https://custom.instance.xyz" required>
<button type="submit">Add Instance</button>
</form>
<div class="setting-item" style="padding-bottom: 1rem; border: none;">
<div class="info">
<span class="label">API Instances</span>
<span class="description">Manage and prioritize API instances. Automatically sorted by speed.</span>
</div>
<button id="refresh-speed-test-btn" class="btn-secondary">Refresh Speed Test</button>
</div>
<ul id="api-instance-list"></ul>
</div>
</div>
</div>
<div id="page-about" class="page">
<h2 class="section-title">About Monochrome</h2>
<div class="about-content">
<p class="about-description">
Monochrome is a lightweight, privacy-focused music streaming client designed for high-fidelity audio playback.
Built with modern web technologies, it provides a clean, distraction-free listening experience.
</p>
<div class="about-features">
<h4>Features</h4>
<ul>
<li>High-quality lossless audio streaming</li>
<li>Intelligent API caching for improved performance</li>
<li>Offline-capable Progressive Web App (PWA)</li>
<li>Media Session API integration for system controls</li>
<li>Queue management with shuffle and repeat modes</li>
<li>Track downloads with automatic metadata embedding</li>
<li>Multiple API instance support with failover</li>
<li>Dark, minimalist interface optimized for focus</li>
<li>Customizable themes and crossfade support</li>
</ul>
</div>
<div id="about-section">
<div class="setting-item" style="padding-bottom: 1rem; border: none; margin-top: 2rem;">
<div class="info">
<span class="label">About Monochrome</span>
<span class="description">A minimalist, open-source music streaming application</span>
</div>
</div>
<div class="about-content">
<p class="about-description">
Monochrome is a lightweight, privacy-focused music streaming client designed for high-fidelity audio playback.
Built with modern web technologies, it provides a clean, distraction-free listening experience.
</p>
<div class="about-features">
<h4>Features</h4>
<ul>
<li>High-quality lossless audio streaming</li>
<li>Intelligent API caching for improved performance</li>
<li>Offline-capable Progressive Web App (PWA)</li>
<li>Media Session API integration for system controls</li>
<li>Queue management with shuffle and repeat modes</li>
<li>Track downloads with automatic metadata embedding</li>
<li>Multiple API instance support with failover</li>
<li>Dark, minimalist interface optimized for focus</li>
</ul>
</div>
<div class="about-tech">
<h4>Technology Stack</h4>
<p>Vanilla JavaScript • ES6 Modules • IndexedDB • Service Workers • Media Session API</p>
</div>
<div class="about-footer">
<p class="version">Version 1.0.0</p>
<p class="disclaimer">This is an independent client and is not affiliated with or endorsed by TIDAL or any music streaming service.</p>
</div>
</div>
<div class="about-tech">
<h4>Technology Stack</h4>
<p>Vanilla JavaScript • ES6 Modules • IndexedDB • Service Workers • Media Session API</p>
</div>
<div class="about-links">
<a href="https://github.com/eduardprigoana/monochrome" target="_blank" rel="noopener noreferrer" class="github-link">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-github-icon lucide-github"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>
View on GitHub
</a>
</div>
<div class="about-footer">
<p class="version">Version 1.1.0</p>
<p class="disclaimer">This is an independent client and is not affiliated with or endorsed by TIDAL or any music streaming service.</p>
</div>
</div>
</div>
@ -297,34 +341,15 @@
<div class="player-controls">
<div class="buttons">
<button id="shuffle-btn" title="Shuffle">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 3 21 3 21 8"></polyline>
<line x1="4" y1="20" x2="21" y2="3"></line>
<polyline points="16 16 21 16 21 21"></polyline>
<line x1="15" y1="15" x2="21" y2="21"></line>
<line x1="4" y1="4" x2="9" y2="9"></line>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shuffle-icon lucide-shuffle"><path d="m18 14 4 4-4 4"/><path d="m18 2 4 4-4 4"/><path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22"/><path d="M2 6h1.972a4 4 0 0 1 3.6 2.2"/><path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45"/></svg>
</button>
<button id="prev-btn" title="Previous">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="19 20 9 12 19 4 19 20"></polygon>
<line x1="5" y1="19" x2="5" y2="5"></line>
</svg>
</button>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-to-line-icon lucide-arrow-left-to-line"><path d="M3 19V5"/><path d="m13 6-6 6 6 6"/><path d="M7 12h14"/></svg> </button>
<button class="play-pause-btn" title="Play"></button>
<button id="next-btn" title="Next">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 4 15 12 5 20 5 4"></polygon>
<line x1="19" y1="5" x2="19" y2="19"></line>
</svg>
</button>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right-to-line-icon lucide-arrow-right-to-line"><path d="M17 12H3"/><path d="m11 18 6-6-6-6"/><path d="M21 5v14"/></svg> </button>
<button id="repeat-btn" title="Repeat">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"></polyline>
<path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline>
<path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-repeat-icon lucide-repeat"><path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/></svg>
</button>
</div>
<div class="progress-container">
@ -337,14 +362,7 @@
</div>
<div class="volume-controls">
<button id="queue-btn" title="Queue">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-icon lucide-list"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
</button>
<button id="volume-btn" title="Mute"></button>
<div id="volume-bar" class="volume-bar">

View file

@ -1,6 +1,5 @@
import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js';
import { APICache } from './cache.js';
import { MetadataEmbedder } from './metadata.js';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
@ -12,7 +11,6 @@ export class LosslessAPI {
ttl: 1000 * 60 * 30
});
this.streamCache = new Map();
this.metadataEmbedder = new MetadataEmbedder();
setInterval(() => {
this.cache.clearExpired();
@ -29,7 +27,7 @@ export class LosslessAPI {
}
async fetchWithRetry(relativePath, options = {}) {
const instances = this.settings.getInstances();
const instances = await this.settings.getInstances();
if (instances.length === 0) {
throw new Error("No API instances configured.");
}
@ -207,7 +205,7 @@ export class LosslessAPI {
return parsed.urls[0];
}
} catch {
const match = decoded.match(/https?:\/\/[\w\-.~:?#[```@!$&'()*+,;=%/]+/);
const match = decoded.match(/https?:\/\/[\w\-.~:?#[@!$&'()*+,;=%/]+/);
return match ? match[0] : null;
}
} catch (error) {
@ -401,7 +399,7 @@ export class LosslessAPI {
}
async downloadTrack(id, quality = 'LOSSLESS', filename, options = {}) {
const { onProgress, embedMetadata = true, track, coverUrl } = options;
const { onProgress } = options;
try {
const lookup = await this.getTrack(id, quality);
@ -450,24 +448,7 @@ export class LosslessAPI {
}
}
let blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' });
if (embedMetadata && track && quality === 'LOSSLESS' && coverUrl) {
if (onProgress) {
onProgress({ stage: 'metadata', progress: 0 });
}
try {
blob = await this.metadataEmbedder.embedMetadata(blob, track, coverUrl, (progress) => {
if (onProgress) {
onProgress({ stage: 'metadata', progress });
}
});
} catch (metaError) {
console.warn('Metadata embedding failed, downloading without metadata:', metaError);
}
}
const blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' });
this.triggerDownload(blob, filename);
} else {
const blob = await response.blob();

373
js/app.js
View file

@ -1,5 +1,5 @@
import { LosslessAPI } from './api.js';
import { apiSettings } from './storage.js';
import { apiSettings, themeManager } from './storage.js';
import { UIRenderer } from './ui.js';
import { Player } from './player.js';
import {
@ -26,16 +26,6 @@ function createDownloadNotification() {
if (!downloadNotificationContainer) {
downloadNotificationContainer = document.createElement('div');
downloadNotificationContainer.id = 'download-notifications';
downloadNotificationContainer.style.cssText = `
position: fixed;
bottom: 120px;
right: 20px;
z-index: 9999;
max-width: 350px;
display: flex;
flex-direction: column;
gap: 0.5rem;
`;
document.body.appendChild(downloadNotificationContainer);
}
return downloadNotificationContainer;
@ -47,14 +37,6 @@ function addDownloadTask(trackId, track, filename, api) {
const taskEl = document.createElement('div');
taskEl.className = 'download-task';
taskEl.dataset.trackId = trackId;
taskEl.style.cssText = `
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
animation: slideIn 0.3s ease;
`;
taskEl.innerHTML = `
<div style="display: flex; align-items: start; gap: 0.75rem;">
@ -111,11 +93,6 @@ function updateDownloadProgress(trackId, progress) {
: '?';
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
} else if (progress.stage === 'metadata') {
const percent = Math.round(progress.progress * 100);
progressFill.style.width = `${percent}%`;
progressFill.style.background = '#a855f7';
statusEl.textContent = `Embedding metadata: ${percent}%`;
}
}
@ -170,10 +147,7 @@ function removeDownloadTask(trackId) {
}, 300);
}
async function downloadTrackBlob(track, quality, api, coverUrl = null) {
console.log('[Download] Starting download for:', track.title, 'Quality:', quality);
console.log('[Download] Cover URL:', coverUrl);
async function downloadTrackBlob(track, quality, api) {
const lookup = await api.getTrack(track.id, quality);
let streamUrl;
@ -186,28 +160,12 @@ async function downloadTrackBlob(track, quality, api, coverUrl = null) {
}
}
console.log('[Download] Fetching from:', streamUrl);
const response = await fetch(streamUrl);
if (!response.ok) {
throw new Error(`Failed to fetch track: ${response.status}`);
}
let blob = await response.blob();
console.log('[Download] Downloaded blob size:', blob.size, 'type:', blob.type);
if (quality === 'LOSSLESS' && coverUrl) {
console.log('[Download] Attempting to embed metadata...');
try {
const processedBlob = await api.metadataEmbedder.embedMetadata(blob, track, coverUrl, null);
console.log('[Download] Metadata embedded. New size:', processedBlob.size);
blob = processedBlob;
} catch (error) {
console.error('[Download] Metadata embedding failed:', error);
}
} else {
console.log('[Download] Skipping metadata - Quality:', quality, 'Has cover:', !!coverUrl);
}
const blob = await response.blob();
return blob;
}
@ -219,8 +177,6 @@ async function downloadAlbumAsZip(album, tracks, api, quality) {
const albumTitle = sanitizeForFilename(album.title || 'Unknown Album');
const folderName = `${albumTitle} - ${artistName} - monochrome.tf`;
const coverUrl = album.cover ? api.getCoverUrl(album.cover, '1280') : null;
const notification = createBulkDownloadNotification('album', album.title, tracks.length);
try {
@ -230,7 +186,7 @@ async function downloadAlbumAsZip(album, tracks, api, quality) {
updateBulkDownloadProgress(notification, i, tracks.length, track.title);
const blob = await downloadTrackBlob(track, quality, api, coverUrl);
const blob = await downloadTrackBlob(track, quality, api);
zip.file(`${folderName}/${filename}`, blob);
}
@ -279,11 +235,9 @@ async function downloadDiscography(artist, api, quality) {
const albumTitle = sanitizeForFilename(fullAlbum.title || 'Unknown Album');
const albumFolder = `${rootFolder}/${albumTitle}`;
const coverUrl = fullAlbum.cover ? api.getCoverUrl(fullAlbum.cover, '1280') : null;
for (const track of tracks) {
const filename = buildTrackFilename(track, quality);
const blob = await downloadTrackBlob(track, quality, api, coverUrl);
const blob = await downloadTrackBlob(track, quality, api);
zip.file(`${albumFolder}/${filename}`, blob);
}
} catch (error) {
@ -320,14 +274,6 @@ function createBulkDownloadNotification(type, name, totalItems) {
const notifEl = document.createElement('div');
notifEl.className = 'download-task bulk-download';
notifEl.style.cssText = `
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
animation: slideIn 0.3s ease;
`;
notifEl.innerHTML = `
<div style="display: flex; align-items: start; gap: 0.75rem;">
@ -383,59 +329,7 @@ function completeBulkDownload(notifEl, success = true, message = null) {
}
}
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.download-cancel:hover {
background: var(--secondary) !important;
color: var(--foreground) !important;
}
.now-playing-bar .title,
.now-playing-bar .artist {
cursor: pointer;
transition: color 0.2s;
}
.now-playing-bar .title:hover,
.now-playing-bar .artist:hover {
color: var(--highlight);
text-decoration: underline;
}
`;
document.head.appendChild(style);
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
const api = new LosslessAPI(apiSettings);
const ui = new UIRenderer(api);
@ -443,6 +337,13 @@ document.addEventListener('DOMContentLoaded', () => {
const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
const player = new Player(audioPlayer, api, currentQuality);
const savedCrossfade = localStorage.getItem('crossfade-enabled') === 'true';
const savedCrossfadeDuration = parseInt(localStorage.getItem('crossfade-duration') || '5');
player.setCrossfade(savedCrossfade, savedCrossfadeDuration);
const currentTheme = themeManager.getTheme();
themeManager.setTheme(currentTheme);
const mainContent = document.querySelector('.main-content');
const playPauseBtn = document.querySelector('.play-pause-btn');
const nextBtn = document.getElementById('next-btn');
@ -468,6 +369,104 @@ document.addEventListener('DOMContentLoaded', () => {
const hamburgerBtn = document.getElementById('hamburger-btn');
let contextTrack = null;
let draggedQueueIndex = null;
const themePicker = document.getElementById('theme-picker');
themePicker.querySelectorAll('.theme-option').forEach(option => {
if (option.dataset.theme === currentTheme) {
option.classList.add('active');
}
option.addEventListener('click', () => {
const theme = option.dataset.theme;
themePicker.querySelectorAll('.theme-option').forEach(opt => opt.classList.remove('active'));
option.classList.add('active');
if (theme === 'custom') {
document.getElementById('custom-theme-editor').classList.add('show');
renderCustomThemeEditor();
} else {
document.getElementById('custom-theme-editor').classList.remove('show');
themeManager.setTheme(theme);
}
});
});
document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('refresh-speed-test-btn');
const originalText = btn.textContent;
btn.textContent = 'Testing...';
btn.disabled = true;
try {
await apiSettings.refreshSpeedTests();
ui.renderApiSettings();
btn.textContent = 'Done!';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 1500);
} catch (error) {
console.error('Failed to refresh speed tests:', error);
btn.textContent = 'Error';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 1500);
}
});
function renderCustomThemeEditor() {
const grid = document.getElementById('theme-color-grid');
const customTheme = themeManager.getCustomTheme() || {
background: '#000000',
foreground: '#fafafa',
primary: '#ffffff',
secondary: '#27272a',
muted: '#27272a',
border: '#27272a',
highlight: '#ffffff'
};
grid.innerHTML = Object.entries(customTheme).map(([key, value]) => `
<div class="theme-color-input">
<label>${key}</label>
<input type="color" data-color="${key}" value="${value}">
</div>
`).join('');
}
document.getElementById('apply-custom-theme')?.addEventListener('click', () => {
const colors = {};
document.querySelectorAll('#theme-color-grid input[type="color"]').forEach(input => {
colors[input.dataset.color] = input.value;
});
themeManager.setCustomTheme(colors);
});
document.getElementById('reset-custom-theme')?.addEventListener('click', () => {
renderCustomThemeEditor();
});
const crossfadeToggle = document.getElementById('crossfade-toggle');
const crossfadeDurationSetting = document.getElementById('crossfade-duration-setting');
const crossfadeDurationInput = document.getElementById('crossfade-duration');
crossfadeToggle.checked = savedCrossfade;
crossfadeDurationSetting.style.display = savedCrossfade ? 'flex' : 'none';
crossfadeDurationInput.value = savedCrossfadeDuration;
crossfadeToggle.addEventListener('change', (e) => {
const enabled = e.target.checked;
localStorage.setItem('crossfade-enabled', enabled);
crossfadeDurationSetting.style.display = enabled ? 'flex' : 'none';
player.setCrossfade(enabled, parseInt(crossfadeDurationInput.value));
});
crossfadeDurationInput.addEventListener('change', (e) => {
const duration = parseInt(e.target.value);
localStorage.setItem('crossfade-duration', duration);
player.setCrossfade(crossfadeToggle.checked, duration);
});
const qualitySetting = document.getElementById('quality-setting');
if (qualitySetting) {
@ -497,6 +496,26 @@ document.addEventListener('DOMContentLoaded', () => {
});
document.addEventListener('click', async (e) => {
if (e.target.closest('#play-album-btn')) {
const btn = e.target.closest('#play-album-btn');
if (btn.disabled) return;
const albumId = window.location.hash.split('/')[1];
if (!albumId) return;
try {
const { tracks } = await api.getAlbum(albumId);
if (tracks.length > 0) {
player.setQueue(tracks, 0);
shuffleBtn.classList.remove('active');
player.playTrackFromQueue();
}
} catch (error) {
console.error('Failed to play album:', error);
alert('Failed to play album: ' + error.message);
}
}
if (e.target.closest('#download-album-btn')) {
const btn = e.target.closest('#download-album-btn');
if (btn.disabled) return;
@ -590,12 +609,16 @@ document.addEventListener('DOMContentLoaded', () => {
}
const html = currentQueue.map((track, index) => {
const isPlaying = index === player.currentQueueIndex &&
track.id === (currentQueue[player.currentQueueIndex] || {}).id;
const isPlaying = index === player.currentQueueIndex;
return `
<div class="track-item ${isPlaying ? 'playing' : ''}" data-queue-index="${index}" data-track-id="${track.id}">
<div class="track-number">${index + 1}</div>
<div class="queue-track-item ${isPlaying ? 'playing' : ''}" data-queue-index="${index}" data-track-id="${track.id}" draggable="true">
<div class="drag-handle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="8" x2="19" y2="8"></line>
<line x1="5" y1="16" x2="19" y2="16"></line>
</svg>
</div>
<div class="track-item-info">
<img src="${api.getCoverUrl(track.album?.cover, '80')}"
class="track-item-cover" loading="lazy">
@ -605,23 +628,89 @@ document.addEventListener('DOMContentLoaded', () => {
</div>
</div>
<div class="track-item-duration">${formatTime(track.duration)}</div>
<button class="track-menu-btn" data-track-index="${index}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</svg>
</button>
</div>
`;
}).join('');
queueList.innerHTML = html;
queueList.querySelectorAll('.track-item').forEach((item, index) => {
item.addEventListener('click', () => {
queueList.querySelectorAll('.queue-track-item').forEach((item) => {
const index = parseInt(item.dataset.queueIndex);
item.addEventListener('click', (e) => {
if (e.target.closest('.track-menu-btn')) return;
player.playAtIndex(index);
player.updatePlayingTrackIndicator();
renderQueue();
});
item.addEventListener('dragstart', (e) => {
draggedQueueIndex = index;
item.style.opacity = '0.5';
});
item.addEventListener('dragend', () => {
item.style.opacity = '1';
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
});
item.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedQueueIndex !== null && draggedQueueIndex !== index) {
player.moveInQueue(draggedQueueIndex, index);
renderQueue();
}
});
});
player.updatePlayingTrackIndicator();
queueList.querySelectorAll('.track-menu-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const index = parseInt(btn.dataset.trackIndex);
showQueueTrackMenu(e, index);
});
});
};
function showQueueTrackMenu(e, trackIndex) {
const menu = document.getElementById('queue-track-menu');
menu.style.top = `${e.pageY}px`;
menu.style.left = `${e.pageX}px`;
menu.classList.add('show');
menu.dataset.trackIndex = trackIndex;
document.addEventListener('click', hideQueueTrackMenu);
}
function hideQueueTrackMenu() {
const menu = document.getElementById('queue-track-menu');
menu.classList.remove('show');
document.removeEventListener('click', hideQueueTrackMenu);
}
document.getElementById('queue-track-menu').addEventListener('click', (e) => {
e.stopPropagation();
const action = e.target.dataset.action;
const menu = document.getElementById('queue-track-menu');
const trackIndex = parseInt(menu.dataset.trackIndex);
if (action === 'remove') {
player.removeFromQueue(trackIndex);
renderQueue();
}
hideQueueTrackMenu();
});
mainContent.addEventListener('click', e => {
const trackItem = e.target.closest('.track-item');
if (trackItem && !trackItem.dataset.queueIndex) {
@ -642,7 +731,7 @@ document.addEventListener('DOMContentLoaded', () => {
mainContent.addEventListener('contextmenu', e => {
const trackItem = e.target.closest('.track-item');
if (trackItem) {
if (trackItem && !trackItem.dataset.queueIndex) {
e.preventDefault();
contextTrack = trackDataStore.get(trackItem);
@ -677,15 +766,8 @@ document.addEventListener('DOMContentLoaded', () => {
api
);
const coverUrl = contextTrack.album?.cover
? api.getCoverUrl(contextTrack.album.cover, '1280')
: null;
await api.downloadTrack(contextTrack.id, quality, filename, {
signal: abortController.signal,
track: contextTrack,
coverUrl: coverUrl,
embedMetadata: true,
onProgress: (progress) => {
updateDownloadProgress(contextTrack.id, progress);
}
@ -869,55 +951,24 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
document.getElementById('api-instance-list').addEventListener('click', e => {
document.getElementById('api-instance-list').addEventListener('click', async e => {
const button = e.target.closest('button');
if (!button) return;
const li = button.closest('li');
const index = parseInt(li.dataset.index, 10);
const instances = apiSettings.getInstances();
const instances = await apiSettings.getInstances();
if (button.classList.contains('move-up') && index > 0) {
[instances[index], instances[index - 1]] = [instances[index - 1], instances[index]];
} else if (button.classList.contains('move-down') && index < instances.length - 1) {
[instances[index], instances[index + 1]] = [instances[index + 1], instances[index]];
} else if (button.classList.contains('delete-instance')) {
instances.splice(index, 1);
}
apiSettings.saveInstances(instances);
ui.renderApiSettings();
});
document.getElementById('add-instance-form').addEventListener('submit', e => {
e.preventDefault();
const input = document.getElementById('custom-instance-input');
const newUrl = input.value.trim();
if (newUrl) {
try {
const url = new URL(newUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Invalid protocol');
}
const instances = apiSettings.getInstances();
const formattedUrl = newUrl.endsWith('/') ? newUrl.slice(0, -1) : newUrl;
if (!instances.includes(formattedUrl)) {
instances.push(formattedUrl);
apiSettings.saveInstances(instances);
ui.renderApiSettings();
input.value = '';
} else {
alert('This instance is already in the list.');
}
} catch (error) {
alert('Please enter a valid URL (e.g., https://example.com)');
}
}
});
document.getElementById('clear-cache-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('clear-cache-btn');
const originalText = btn.textContent;

View file

@ -1,4 +1,3 @@
//js/cache.js
export class APICache {
constructor(options = {}) {
this.memoryCache = new Map();

210
js/metadata.js Normal file
View file

@ -0,0 +1,210 @@
export class MetadataEmbedder {
constructor() {
this.ffmpegLoaded = false;
this.ffmpeg = null;
this.fetchFile = null;
}
async loadFFmpeg() {
if (this.ffmpegLoaded) return;
try {
console.log('[FFmpeg] Loading FFmpeg...');
if (typeof FFmpegWASM === 'undefined' || typeof FFmpegUtil === 'undefined') {
throw new Error('FFmpeg libraries not loaded. Please check your internet connection.');
}
const { FFmpeg } = FFmpegWASM;
const { fetchFile } = FFmpegUtil;
this.ffmpeg = new FFmpeg();
this.fetchFile = fetchFile;
this.ffmpeg.on('log', ({ message }) => {
console.log('[FFmpeg]', message);
});
const baseURL = window.location.origin + '/ffmpeg';
await this.ffmpeg.load({
coreURL: `${baseURL}/ffmpeg-core.js`,
wasmURL: `${baseURL}/ffmpeg-core.wasm`
});
this.ffmpegLoaded = true;
console.log('[FFmpeg] Loaded successfully');
} catch (error) {
console.error('[FFmpeg] Failed to load:', error);
throw error;
}
}
async embedMetadata(audioBlob, track, coverImageUrl, onProgress) {
console.log('[Metadata] Starting embedding for:', track.title);
if (!this.ffmpegLoaded) {
try {
await this.loadFFmpeg();
} catch (error) {
console.error('[Metadata] Cannot load FFmpeg, skipping metadata:', error);
return audioBlob;
}
}
if (!this.ffmpeg || !this.fetchFile) {
console.error('[Metadata] FFmpeg not properly initialized');
return audioBlob;
}
const inputName = 'input.flac';
const coverName = 'cover.jpg';
const outputName = 'output.flac';
try {
const arrayBuffer = await audioBlob.arrayBuffer();
await this.ffmpeg.writeFile(inputName, new Uint8Array(arrayBuffer));
console.log('[Metadata] Wrote input file:', inputName, 'size:', arrayBuffer.byteLength);
let hasCover = false;
if (coverImageUrl) {
try {
console.log('[Metadata] Fetching cover from:', coverImageUrl);
const coverData = await this.fetchFile(coverImageUrl);
await this.ffmpeg.writeFile(coverName, coverData);
hasCover = true;
console.log('[Metadata] Cover image written successfully, size:', coverData.length);
} catch (coverError) {
console.warn('[Metadata] Failed to fetch cover image:', coverError);
}
}
const metadata = this.buildMetadataArgs(track);
console.log('[Metadata] Building metadata with', metadata.length / 2, 'fields');
let args;
if (hasCover) {
args = [
'-i', inputName,
'-i', coverName,
'-map', '0:a',
'-map', '1',
'-c:a', 'copy',
'-c:v', 'copy',
...metadata,
'-metadata:s:v', 'title=Album cover',
'-metadata:s:v', 'comment=Cover (front)',
'-disposition:v', 'attached_pic',
outputName
];
} else {
args = [
'-i', inputName,
...metadata,
'-c:a', 'copy',
outputName
];
}
console.log('[Metadata] Executing FFmpeg...');
if (onProgress) {
this.ffmpeg.on('progress', ({ progress }) => {
onProgress(progress);
});
}
await this.ffmpeg.exec(args);
console.log('[Metadata] FFmpeg exec completed successfully');
const outputData = await this.ffmpeg.readFile(outputName);
const outputBlob = new Blob([outputData], { type: 'audio/flac' });
console.log('[Metadata] ✓ Success! Input:', arrayBuffer.byteLength, 'bytes → Output:', outputBlob.size, 'bytes');
await this.ffmpeg.deleteFile(inputName);
await this.ffmpeg.deleteFile(outputName);
if (hasCover) {
await this.ffmpeg.deleteFile(coverName);
}
console.log('[Metadata] Cleanup complete');
return outputBlob;
} catch (error) {
console.error('[Metadata] ✗ Embedding failed:', error);
console.error('[Metadata] Error details:', {
name: error.name,
message: error.message,
stack: error.stack
});
return audioBlob;
}
}
buildMetadataArgs(track) {
const args = [];
if (track.title) {
args.push('-metadata', `title=${this.escapeMetadata(track.title)}`);
}
if (track.artist?.name) {
args.push('-metadata', `artist=${this.escapeMetadata(track.artist.name)}`);
}
if (track.album?.title) {
args.push('-metadata', `album=${this.escapeMetadata(track.album.title)}`);
}
if (track.album?.artist?.name) {
args.push('-metadata', `album_artist=${this.escapeMetadata(track.album.artist.name)}`);
}
if (track.trackNumber) {
const trackNum = Number(track.trackNumber);
if (Number.isFinite(trackNum) && trackNum > 0) {
const totalTracks = track.album?.numberOfTracks;
if (totalTracks && Number.isFinite(totalTracks) && totalTracks > 0) {
args.push('-metadata', `track=${trackNum}/${totalTracks}`);
} else {
args.push('-metadata', `track=${trackNum}`);
}
}
}
if (track.volumeNumber) {
const discNum = Number(track.volumeNumber);
if (Number.isFinite(discNum) && discNum > 0) {
const totalDiscs = track.album?.numberOfVolumes;
if (totalDiscs && Number.isFinite(totalDiscs) && totalDiscs > 0) {
args.push('-metadata', `disc=${discNum}/${totalDiscs}`);
} else {
args.push('-metadata', `disc=${discNum}`);
}
}
}
if (track.album?.releaseDate) {
const year = new Date(track.album.releaseDate).getFullYear();
if (!isNaN(year)) {
args.push('-metadata', `date=${year}`);
args.push('-metadata', `year=${year}`);
}
}
if (track.album?.upc) {
args.push('-metadata', `barcode=${track.album.upc}`);
}
if (track.isrc) {
args.push('-metadata', `isrc=${track.isrc}`);
}
args.push('-metadata', 'comment=https://monochrome.tf/');
return args;
}
escapeMetadata(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
}

View file

@ -14,8 +14,23 @@ export class Player {
this.preloadCache = new Map();
this.preloadAbortController = null;
this.currentTrack = null;
this.crossfadeEnabled = false;
this.crossfadeDuration = 5;
this.nextAudioElement = null;
this.isCrossfading = false;
this.setupMediaSession();
this.setupCrossfade();
}
setupCrossfade() {
this.nextAudioElement = document.createElement('audio');
this.nextAudioElement.preload = 'auto';
}
setCrossfade(enabled, duration = 5) {
this.crossfadeEnabled = enabled;
this.crossfadeDuration = Math.max(1, Math.min(12, duration));
}
setupMediaSession() {
@ -81,7 +96,7 @@ export class Player {
}
}
for (const { track } of tracksToPreload) {
for (const { track, index } of tracksToPreload) {
if (this.preloadCache.has(track.id)) continue;
try {
@ -89,18 +104,11 @@ export class Player {
if (this.preloadAbortController.signal.aborted) break;
fetch(streamUrl, {
signal: this.preloadAbortController.signal,
method: 'HEAD',
mode: 'cors',
cache: 'default'
}).then(() => {
this.preloadCache.set(track.id, streamUrl);
}).catch(err => {
if (err.name !== 'AbortError') {
console.debug('Preload failed for:', track.title);
}
});
this.preloadCache.set(track.id, streamUrl);
if (index === this.currentQueueIndex + 1 && this.crossfadeEnabled) {
this.nextAudioElement.src = streamUrl;
}
} catch (error) {
if (error.name !== 'AbortError') {
@ -137,11 +145,23 @@ export class Player {
streamUrl = await this.api.getStreamUrl(track.id, this.quality);
}
this.audio.src = streamUrl;
if (this.isCrossfading && this.nextAudioElement.src === streamUrl) {
const temp = this.audio;
this.audio = this.nextAudioElement;
this.nextAudioElement = temp;
this.nextAudioElement.pause();
this.nextAudioElement.currentTime = 0;
} else {
this.audio.src = streamUrl;
}
await this.audio.play();
this.isCrossfading = false;
this.updateMediaSessionPlaybackState();
this.preloadNextTracks();
this.setupCrossfadeListener();
} catch (error) {
console.error(`Could not play track: ${track.title}`, error);
@ -150,6 +170,66 @@ export class Player {
}
}
setupCrossfadeListener() {
if (!this.crossfadeEnabled) return;
const checkCrossfade = () => {
const timeRemaining = this.audio.duration - this.audio.currentTime;
if (timeRemaining <= this.crossfadeDuration && timeRemaining > 0 && !this.isCrossfading) {
this.startCrossfade();
}
};
this.audio.removeEventListener('timeupdate', this.crossfadeCheck);
this.crossfadeCheck = checkCrossfade;
this.audio.addEventListener('timeupdate', this.crossfadeCheck);
}
async startCrossfade() {
if (this.repeatMode === REPEAT_MODE.ONE) return;
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
const nextIndex = this.currentQueueIndex + 1;
if (nextIndex >= currentQueue.length && this.repeatMode !== REPEAT_MODE.ALL) return;
this.isCrossfading = true;
const targetIndex = nextIndex >= currentQueue.length ? 0 : nextIndex;
const nextTrack = currentQueue[targetIndex];
if (this.nextAudioElement.src && this.preloadCache.has(nextTrack.id)) {
try {
await this.nextAudioElement.play();
this.nextAudioElement.volume = 0;
const fadeSteps = 20;
const fadeInterval = (this.crossfadeDuration * 1000) / fadeSteps;
let step = 0;
const fadeTimer = setInterval(() => {
step++;
const progress = step / fadeSteps;
this.audio.volume = Math.max(0, 1 - progress);
this.nextAudioElement.volume = Math.min(1, progress);
if (step >= fadeSteps) {
clearInterval(fadeTimer);
this.audio.pause();
this.audio.volume = 1;
this.currentQueueIndex = targetIndex;
this.playTrackFromQueue();
}
}, fadeInterval);
} catch (error) {
console.error('Crossfade failed:', error);
this.isCrossfading = false;
}
}
}
playAtIndex(index) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (index >= 0 && index < currentQueue.length) {
@ -256,6 +336,44 @@ export class Player {
}
}
removeFromQueue(index) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (index < 0 || index >= currentQueue.length) return;
if (this.shuffleActive) {
this.shuffledQueue.splice(index, 1);
} else {
this.queue.splice(index, 1);
}
if (index < this.currentQueueIndex) {
this.currentQueueIndex--;
} else if (index === this.currentQueueIndex) {
if (currentQueue.length > 0) {
this.playTrackFromQueue();
}
}
}
moveInQueue(fromIndex, toIndex) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (fromIndex < 0 || fromIndex >= currentQueue.length) return;
if (toIndex < 0 || toIndex >= currentQueue.length) return;
const [track] = currentQueue.splice(fromIndex, 1);
currentQueue.splice(toIndex, 0, track);
if (this.currentQueueIndex === fromIndex) {
this.currentQueueIndex = toIndex;
} else if (fromIndex < this.currentQueueIndex && toIndex >= this.currentQueueIndex) {
this.currentQueueIndex--;
} else if (fromIndex > this.currentQueueIndex && toIndex <= this.currentQueueIndex) {
this.currentQueueIndex++;
}
}
getCurrentQueue() {
return this.shuffleActive ? this.shuffledQueue : this.queue;
}

View file

@ -1,29 +1,193 @@
export const apiSettings = {
STORAGE_KEY: 'monochrome-api-instances',
defaultInstances: [
'https://frankfurt.monochrome.tf/',
'https://ohio.monochrome.tf/',
'https://oregon.monochrome.tf/',
'https://virginia.monochrome.tf/',
'https://singapore.monochrome.tf/',
'https://tokyo.monochrome.tf/',
'https://hund.qqdl.site',
'https://katze.qqdl.site',
'https://maus.qqdl.site',
'https://vogel.qqdl.site',
'https://wolf.qqdl.site',
'https://tidal.401658.xyz'
],
INSTANCES_URL: 'https://raw.githubusercontent.com/EduardPrigoana/hifi-instances/refs/heads/main/instances.json',
SPEED_TEST_CACHE_KEY: 'monochrome-instance-speeds',
SPEED_TEST_CACHE_DURATION: 1000 * 60 * 60,
defaultInstances: [],
instancesLoaded: false,
getInstances() {
async loadInstancesFromGitHub() {
if (this.instancesLoaded) {
return this.defaultInstances;
}
try {
const response = await fetch(this.INSTANCES_URL);
if (!response.ok) throw new Error('Failed to fetch instances');
const data = await response.json();
const allInstances = [];
for (const [provider, config] of Object.entries(data.api)) {
if (config.cors === false && Array.isArray(config.urls)) {
allInstances.push(...config.urls);
}
}
this.defaultInstances = allInstances;
this.instancesLoaded = true;
return allInstances;
} catch (error) {
console.error('Failed to load instances from GitHub:', error);
this.defaultInstances = [
'https://ohio.monochrome.tf/',
'https://virginia.monochrome.tf/',
'https://oregon.monochrome.tf/',
'https://california.monochrome.tf/',
'https://frankfurt.monochrome.tf/',
'https://singapore.monochrome.tf/',
'https://tokyo.monochrome.tf/',
'https://jakarta.monochrome.tf/',
'https://wolf.qqdl.site',
'https://maus.qqdl.site',
'https://vogel.qqdl.site',
'https://katze.qqdl.site',
'https://hund.qqdl.site',
'https://tidal.401658.xyz'
];
this.instancesLoaded = true;
return this.defaultInstances;
}
},
async speedTestInstance(url) {
const testUrl = url.endsWith('/')
? `${url}search/?s=kanye`
: `${url}/search/?s=kanye`;
const startTime = performance.now();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(testUrl, {
signal: controller.signal,
cache: 'no-store'
});
clearTimeout(timeout);
if (!response.ok) {
return { url, speed: Infinity, error: `HTTP ${response.status}` };
}
const endTime = performance.now();
const speed = endTime - startTime;
return { url, speed, error: null };
} catch (error) {
return { url, speed: Infinity, error: error.message };
}
},
async runSpeedTests(instances) {
console.log('[SpeedTest] Testing', instances.length, 'instances...');
const results = await Promise.all(
instances.map(url => this.speedTestInstance(url))
);
const validResults = results.filter(r => r.speed !== Infinity);
const failedResults = results.filter(r => r.speed === Infinity);
if (failedResults.length > 0) {
console.log('[SpeedTest] Failed instances:', failedResults.map(r => `${r.url} (${r.error})`));
}
validResults.sort((a, b) => a.speed - b.speed);
console.log('[SpeedTest] Results:', validResults.map(r => `${r.url}: ${r.speed.toFixed(0)}ms`));
const sortedInstances = [
...validResults.map(r => r.url),
...failedResults.map(r => r.url)
];
const cacheData = {
timestamp: Date.now(),
speeds: results.reduce((acc, r) => {
acc[r.url] = { speed: r.speed, error: r.error };
return acc;
}, {})
};
try {
localStorage.setItem(this.SPEED_TEST_CACHE_KEY, JSON.stringify(cacheData));
} catch (e) {
console.warn('[SpeedTest] Failed to cache results');
}
return sortedInstances;
},
getCachedSpeedTests() {
try {
const cached = localStorage.getItem(this.SPEED_TEST_CACHE_KEY);
if (!cached) return null;
const data = JSON.parse(cached);
if (Date.now() - data.timestamp > this.SPEED_TEST_CACHE_DURATION) {
return null;
}
return data;
} catch (e) {
return null;
}
},
sortInstancesByCache(instances, cachedData) {
const speeds = cachedData.speeds;
const sorted = [...instances].sort((a, b) => {
const speedA = speeds[a]?.speed ?? Infinity;
const speedB = speeds[b]?.speed ?? Infinity;
return speedA - speedB;
});
console.log('[SpeedTest] Using cached results (age:',
Math.round((Date.now() - cachedData.timestamp) / 1000 / 60), 'minutes)');
return sorted;
},
async getInstances() {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
return stored ? JSON.parse(stored) : [...this.defaultInstances];
if (stored) {
return JSON.parse(stored);
}
const instances = await this.loadInstancesFromGitHub();
const cachedSpeedTests = this.getCachedSpeedTests();
let sortedInstances;
if (cachedSpeedTests) {
sortedInstances = this.sortInstancesByCache(instances, cachedSpeedTests);
} else {
sortedInstances = await this.runSpeedTests(instances);
}
this.saveInstances(sortedInstances);
return sortedInstances;
} catch (e) {
return [...this.defaultInstances];
const instances = await this.loadInstancesFromGitHub();
return instances;
}
},
async refreshSpeedTests() {
const instances = await this.loadInstancesFromGitHub();
const sortedInstances = await this.runSpeedTests(instances);
this.saveInstances(sortedInstances);
return sortedInstances;
},
saveInstances(instances) {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(instances));
}
@ -66,3 +230,50 @@ export const recentActivityManager = {
this._add('albums', album);
}
};
export const themeManager = {
STORAGE_KEY: 'monochrome-theme',
CUSTOM_THEME_KEY: 'monochrome-custom-theme',
defaultThemes: {
monochrome: {},
dark: {},
ocean: {},
purple: {},
forest: {}
},
getTheme() {
try {
return localStorage.getItem(this.STORAGE_KEY) || 'monochrome';
} catch (e) {
return 'monochrome';
}
},
setTheme(theme) {
localStorage.setItem(this.STORAGE_KEY, theme);
document.documentElement.setAttribute('data-theme', theme);
},
getCustomTheme() {
try {
const stored = localStorage.getItem(this.CUSTOM_THEME_KEY);
return stored ? JSON.parse(stored) : null;
} catch (e) {
return null;
}
},
setCustomTheme(colors) {
localStorage.setItem(this.CUSTOM_THEME_KEY, JSON.stringify(colors));
this.applyCustomTheme(colors);
},
applyCustomTheme(colors) {
const root = document.documentElement;
for (const [key, value] of Object.entries(colors)) {
root.style.setProperty(`--${key}`, value);
}
}
};

View file

@ -304,40 +304,46 @@ export class UIRenderer {
}
renderApiSettings() {
const container = document.getElementById('api-instance-list');
const instances = this.api.settings.getInstances();
const defaultInstancesSet = new Set(this.api.settings.defaultInstances);
const container = document.getElementById('api-instance-list');
this.api.settings.getInstances().then(instances => {
const cachedData = this.api.settings.getCachedSpeedTests();
const speeds = cachedData?.speeds || {};
container.innerHTML = instances.map((url, index) => `
<li data-index="${index}">
<span class="instance-url">${url}</span>
<div class="controls">
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</button>
<button class="move-down" title="Move Down" ${index === instances.length - 1 ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M19 12l-7 7-7-7"/>
</svg>
</button>
${!defaultInstancesSet.has(url) ? `
<button class="delete-instance" title="Delete">
container.innerHTML = instances.map((url, index) => {
const speedInfo = speeds[url];
const speedText = speedInfo
? (speedInfo.speed === Infinity
? `<span style="color: var(--muted-foreground); font-size: 0.8rem;">Failed</span>`
: `<span style="color: var(--muted-foreground); font-size: 0.8rem;">${speedInfo.speed.toFixed(0)}ms</span>`)
: '';
return `
<li data-index="${index}">
<div style="flex: 1; min-width: 0;">
<div class="instance-url">${url}</div>
${speedText}
</div>
<div class="controls">
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</button>
` : ''}
</div>
</li>
`).join('');
<button class="move-down" title="Move Down" ${index === instances.length - 1 ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M19 12l-7 7-7-7"/>
</svg>
</button>
</div>
</li>
`;
}).join('');
const stats = this.api.getCacheStats();
const cacheInfo = document.getElementById('cache-info');
if (cacheInfo) {
cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`;
}
}
});
}
}

View file

@ -1,4 +1,4 @@
:root {
:root[data-theme="monochrome"] {
--background: #000;
--foreground: #fafafa;
--card: #111;
@ -24,6 +24,110 @@
--spacing-2xl: 3rem;
}
:root[data-theme="dark"] {
--background: #0a0a0a;
--foreground: #ededed;
--card: #1a1a1a;
--card-foreground: #ededed;
--primary: #3b82f6;
--primary-foreground: #ffffff;
--secondary: #2a2a2a;
--secondary-foreground: #ededed;
--muted: #2a2a2a;
--muted-foreground: #a0a0a0;
--border: #2a2a2a;
--input: #2a2a2a;
--ring: #3b82f6;
--radius: .5rem;
--highlight: #3b82f6;
--active-highlight: #3b82f6;
--explicit-badge: #ef4444;
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
}
:root[data-theme="ocean"] {
--background: #0c1821;
--foreground: #e0f4ff;
--card: #1b2838;
--card-foreground: #e0f4ff;
--primary: #06b6d4;
--primary-foreground: #0c1821;
--secondary: #1e3a52;
--secondary-foreground: #e0f4ff;
--muted: #1e3a52;
--muted-foreground: #94c5e0;
--border: #1e3a52;
--input: #1e3a52;
--ring: #06b6d4;
--radius: .5rem;
--highlight: #06b6d4;
--active-highlight: #06b6d4;
--explicit-badge: #f43f5e;
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
}
:root[data-theme="purple"] {
--background: #0f0514;
--foreground: #f3e8ff;
--card: #1e0a2e;
--card-foreground: #f3e8ff;
--primary: #a855f7;
--primary-foreground: #ffffff;
--secondary: #2d1545;
--secondary-foreground: #f3e8ff;
--muted: #2d1545;
--muted-foreground: #c4b5fd;
--border: #2d1545;
--input: #2d1545;
--ring: #a855f7;
--radius: .5rem;
--highlight: #a855f7;
--active-highlight: #a855f7;
--explicit-badge: #ec4899;
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
}
:root[data-theme="forest"] {
--background: #0a1409;
--foreground: #e8f5e9;
--card: #1a2e1a;
--card-foreground: #e8f5e9;
--primary: #22c55e;
--primary-foreground: #0a1409;
--secondary: #2d4a2d;
--secondary-foreground: #e8f5e9;
--muted: #2d4a2d;
--muted-foreground: #86efac;
--border: #2d4a2d;
--input: #2d4a2d;
--ring: #22c55e;
--radius: .5rem;
--highlight: #22c55e;
--active-highlight: #22c55e;
--explicit-badge: #f59e0b;
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
@ -40,6 +144,7 @@ body {
color: var(--foreground);
font-family: 'Inter', sans-serif;
overflow: hidden;
transition: background-color 0.3s ease, color 0.3s ease;
}
img {
@ -370,6 +475,7 @@ a {
border-radius: var(--radius);
cursor: pointer;
transition: all .2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.track-item:hover {
@ -479,6 +585,45 @@ a {
margin-top: 1rem;
}
.detail-header-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.75rem;
background-color: var(--primary);
color: var(--primary-foreground);
border: none;
border-radius: 2rem;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn-primary:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-primary svg {
flex-shrink: 0;
}
.settings-list {
max-width: 800px;
}
@ -514,6 +659,15 @@ a {
padding: 0.5rem;
}
.setting-item input[type="number"] {
background-color: var(--input);
color: var(--foreground);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem;
width: 100px;
}
.toggle-switch {
position: relative;
display: inline-block;
@ -605,6 +759,12 @@ input:checked + .slider:before {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: color 0.2s;
}
.track-info .details .title:hover {
color: var(--highlight);
}
.track-info .details .artist {
@ -613,6 +773,12 @@ input:checked + .slider:before {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: color 0.2s;
}
.track-info .details .artist:hover {
color: var(--highlight);
}
.player-controls {
@ -879,6 +1045,88 @@ input:checked + .slider:before {
padding: .5rem;
}
.queue-track-item {
display: grid;
grid-template-columns: 32px 1fr auto auto;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm);
border-radius: var(--radius);
cursor: grab;
transition: all .2s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 2px;
}
.queue-track-item:active {
cursor: grabbing;
}
.queue-track-item:hover {
background-color: var(--secondary);
}
.queue-track-item.playing {
background-color: var(--secondary);
}
.queue-track-item .drag-handle {
color: var(--muted-foreground);
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
}
.queue-track-item .track-menu-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all .2s;
display: flex;
align-items: center;
justify-content: center;
}
.queue-track-item .track-menu-btn:hover {
background-color: var(--muted);
color: var(--foreground);
}
.queue-track-menu {
display: none;
position: absolute;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: .5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, .5);
z-index: 1001;
min-width: 120px;
}
.queue-track-menu.show {
display: block;
}
.queue-track-menu ul {
list-style: none;
}
.queue-track-menu li {
padding: .5rem .75rem;
cursor: pointer;
border-radius: 4px;
transition: background-color .2s;
font-size: 0.9rem;
}
.queue-track-menu li:hover {
background-color: var(--secondary);
}
.placeholder-text {
padding: 2rem 1rem;
color: var(--muted-foreground);
@ -951,35 +1199,6 @@ input:checked + .slider:before {
cursor: not-allowed;
}
#add-instance-form {
display: flex;
gap: .75rem;
}
#add-instance-form input {
flex-grow: 1;
padding: .5rem .75rem;
background-color: var(--input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--foreground);
}
#add-instance-form button {
padding: .5rem 1rem;
background-color: var(--primary);
color: var(--primary-foreground);
border: none;
border-radius: var(--radius);
cursor: pointer;
font-weight: 500;
transition: opacity .2s;
}
#add-instance-form button:hover {
opacity: .9;
}
#sidebar-overlay {
display: none;
position: fixed;
@ -992,12 +1211,6 @@ input:checked + .slider:before {
backdrop-filter: blur(2px);
}
#about-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--border);
}
.about-content {
padding: 1rem 0;
}
@ -1217,6 +1430,134 @@ input:checked + .slider:before {
width: 100%;
}
.theme-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.theme-option {
padding: 1rem;
border: 2px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.2s;
text-align: center;
font-weight: 500;
}
.theme-option:hover {
border-color: var(--highlight);
background-color: var(--secondary);
}
.theme-option.active {
border-color: var(--primary);
background-color: var(--primary);
color: var(--primary-foreground);
}
.custom-theme-editor {
margin-top: 1rem;
padding: 1rem;
background-color: var(--secondary);
border-radius: var(--radius);
display: none;
}
.custom-theme-editor.show {
display: block;
}
.theme-color-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.theme-color-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.theme-color-input label {
font-size: 0.9rem;
color: var(--muted-foreground);
}
.theme-color-input input[type="color"] {
width: 100%;
height: 40px;
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
}
.theme-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
#download-notifications {
position: fixed;
bottom: 120px;
right: 20px;
z-index: 9999;
max-width: 350px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.download-task {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
animation: slideIn 0.3s ease;
}
.download-cancel:hover {
background: var(--secondary) !important;
color: var(--foreground) !important;
}
@media (max-width: 1024px) {
.app-container {
grid-template-columns: 240px 1fr;
@ -1372,6 +1713,21 @@ input:checked + .slider:before {
.setting-item .info {
width: 100%;
}
.detail-header-actions {
width: 100%;
}
.btn-primary {
width: 100%;
}
#download-notifications {
bottom: 160px;
right: 10px;
left: 10px;
max-width: none;
}
}
@media (max-width: 480px) {
@ -1392,32 +1748,4 @@ input:checked + .slider:before {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.9rem;
}
}
.btn-download {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background-color: var(--primary);
color: var(--primary-foreground);
border: none;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-download:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-download:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-download svg {
flex-shrink: 0;
}