themes!
This commit is contained in:
parent
f990cb1fcc
commit
2a708e2b99
9 changed files with 1339 additions and 417 deletions
240
index.html
240
index.html
|
|
@ -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">
|
||||
|
|
|
|||
27
js/api.js
27
js/api.js
|
|
@ -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
373
js/app.js
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
//js/cache.js
|
||||
export class APICache {
|
||||
constructor(options = {}) {
|
||||
this.memoryCache = new Map();
|
||||
|
|
|
|||
210
js/metadata.js
Normal file
210
js/metadata.js
Normal 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, '\\"');
|
||||
}
|
||||
}
|
||||
146
js/player.js
146
js/player.js
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
245
js/storage.js
245
js/storage.js
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
58
js/ui.js
58
js/ui.js
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
456
styles.css
456
styles.css
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue