style: auto-fix linting issues
This commit is contained in:
parent
caea2fc707
commit
dc3ae80d9f
35 changed files with 5193 additions and 4240 deletions
|
|
@ -1,22 +1,25 @@
|
|||
|
||||
# Development
|
||||
|
||||
This project uses [Vite](https://vitejs.dev/) for local development and optimized builds.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) (Version 20+ or 22+ recommended)
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
2. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
The app will be available at `http://localhost:5173/`.
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
The app will be available at `http://localhost:5173/`.
|
||||
|
||||
### Why Vite?
|
||||
|
||||
- **Instant Updates**: Support for Hot Module Replacement (HMR) means changes to JS/CSS are reflected instantly in the browser.
|
||||
- **Dependency Management**: No more manual path tracking or broken internal imports.
|
||||
- **Automated PWA**: Service Worker generation and asset hashing are handled automatically.
|
||||
|
|
@ -25,28 +28,30 @@ This project uses [Vite](https://vitejs.dev/) for local development and optimize
|
|||
|
||||
We use a standard stack to ensure code quality and consistency:
|
||||
|
||||
- **JS**: [ESLint](https://eslint.org/)
|
||||
- **CSS**: [Stylelint](https://stylelint.io/)
|
||||
- **HTML**: [HTMLHint](https://htmlhint.com/)
|
||||
- **Formatting**: [Prettier](https://prettier.io/)
|
||||
- **JS**: [ESLint](https://eslint.org/)
|
||||
- **CSS**: [Stylelint](https://stylelint.io/)
|
||||
- **HTML**: [HTMLHint](https://htmlhint.com/)
|
||||
- **Formatting**: [Prettier](https://prettier.io/)
|
||||
|
||||
### Commands
|
||||
|
||||
- **Check everything:** `npm run lint`
|
||||
- **Auto-format code:** `npm run format` (Runs Prettier)
|
||||
- **Fix JS issues:** `npm run lint:js -- --fix`
|
||||
- **Fix CSS issues:** `npm run lint:css -- --fix`
|
||||
- **Check everything:** `npm run lint`
|
||||
- **Auto-format code:** `npm run format` (Runs Prettier)
|
||||
- **Fix JS issues:** `npm run lint:js -- --fix`
|
||||
- **Fix CSS issues:** `npm run lint:css -- --fix`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> A GitHub Action automatically runs these checks on every push and pull request. Please ensure `npm run lint` passes before committing.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `/js`: Application source code.
|
||||
- `/public`: Static assets (images, manifest, instances.json) that are copied directly to the build folder.
|
||||
- `index.html`: The entry point of the application.
|
||||
- `vite.config.js`: Build and PWA configuration.
|
||||
|
||||
## Deployment
|
||||
|
||||
Deployment is automated via **GitHub Actions**.
|
||||
|
||||
> [!NOTE]
|
||||
|
|
|
|||
36
INSTANCES.md
36
INSTANCES.md
|
|
@ -1,24 +1,22 @@
|
|||
API:
|
||||
| Provider | Instance URL |
|
||||
| Provider | Instance URL |
|
||||
|----------------|---------------------------------|
|
||||
| Monochrome | https://monochrome-api.samidy.com ([NOTE](https://rentry.co/monochromeapi)) |
|
||||
| squid.wtf | https://triton.squid.wtf |
|
||||
| Lucida (QQDL) | https://wolf.qqdl.site |
|
||||
| | https://maus.qqdl.site |
|
||||
| | https://vogel.qqdl.site |
|
||||
| | https://katze.qqdl.site |
|
||||
| | https://hund.qqdl.site |
|
||||
| Kinoplus | https://tidal.kinoplus.online |
|
||||
| Binimum | https://tidal-api.binimum.org |
|
||||
| Monochrome | https://monochrome-api.samidy.com ([NOTE](https://rentry.co/monochromeapi)) |
|
||||
| squid.wtf | https://triton.squid.wtf |
|
||||
| Lucida (QQDL) | https://wolf.qqdl.site |
|
||||
| | https://maus.qqdl.site |
|
||||
| | https://vogel.qqdl.site |
|
||||
| | https://katze.qqdl.site |
|
||||
| | https://hund.qqdl.site |
|
||||
| Kinoplus | https://tidal.kinoplus.online |
|
||||
| Binimum | https://tidal-api.binimum.org |
|
||||
|
||||
UI:
|
||||
| Provider | Instance URL |
|
||||
| Provider | Instance URL |
|
||||
|-------------------|------------------------------------|
|
||||
| Monochrome | https://monochrome.samidy.com |
|
||||
| tidal-ui (bini) | https://music.binimum.org |
|
||||
| squid.wtf | https://tidal.squid.wtf |
|
||||
| QQDL | https://tidal.qqdl.site/ |
|
||||
| Arjix | https://music.arjix.dev/ |
|
||||
| Spofree | https://spo.free.nf |
|
||||
|
||||
|
||||
| Monochrome | https://monochrome.samidy.com |
|
||||
| tidal-ui (bini) | https://music.binimum.org |
|
||||
| squid.wtf | https://tidal.squid.wtf |
|
||||
| QQDL | https://tidal.qqdl.site/ |
|
||||
| Arjix | https://music.arjix.dev/ |
|
||||
| Spofree | https://spo.free.nf |
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
<h1 align="center">Monochrome</h1>
|
||||
|
||||
### **Monochrome** is an open-source, privacy-respecting, ad-free [TIDAL](https://tidal.com) web UI, built on top of [Hi-Fi](https://github.com/sachinsenal0x64/hifi).
|
||||
|
|
@ -14,6 +13,7 @@
|
|||
[<img src="https://files.catbox.moe/94f3pq.png" alt="Monochrome UI" width="800">](https://monochrome.samidy.com/#album/378149557)
|
||||
|
||||
### Features
|
||||
|
||||
<ul>
|
||||
<li>High-quality lossless audio streaming</li>
|
||||
<li>Lyrics support with karaoke mode</li>
|
||||
|
|
@ -30,13 +30,12 @@
|
|||
<li>Keyboard shortcuts for power users</li>
|
||||
</ul>
|
||||
|
||||
|
||||
### Check it out live at: [**monochrome.samidy.com**](https://monochrome.samidy.com)
|
||||
### Check it out live at: [**monochrome.samidy.com**](https://monochrome.samidy.com)
|
||||
|
||||
<br>
|
||||
|
||||
[](https://github.com/SamidyFR/monochrome/stargazers)
|
||||
[](https://github.com/SamidyFR/monochrome/forks)
|
||||
[](https://github.com/SamidyFR/monochrome/issues)
|
||||
|
||||
|
||||
[<img src="https://github.com/monochrome-music/monochrome/blob/main/assets/asseenonfmhy880x310.png?raw=true" alt="As seen on FMHY" height="50">](https://fmhy.net/audio#streaming-sites)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/", "node_modules/", "legacy/", "sw.js"]
|
||||
ignores: ['dist/', 'node_modules/', 'legacy/', 'sw.js'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
prettierConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
}
|
||||
}
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
Follow these steps to enable cross-device synchronization for your library, history, and settings using your own Firebase project.
|
||||
|
||||
## 1. Create a Firebase Project
|
||||
|
||||
1. Go to the [Firebase Console](https://console.firebase.google.com/).
|
||||
2. Click **Add project** and give it a name (e.g., "Monochrome Sync").
|
||||
3. (Optional) Disable Gemini and Google Analytics if you want to keep it simple.
|
||||
4. Click **Create project**.
|
||||
|
||||
## 2. Enable Authentication
|
||||
|
||||
1. In the left sidebar, click **Build** > **Authentication**.
|
||||
2. Click **Get Started**.
|
||||
3. Go to the **Sign-in method** tab.
|
||||
|
|
@ -16,63 +18,71 @@ Follow these steps to enable cross-device synchronization for your library, hist
|
|||
5. Set your project support email and click **Save**.
|
||||
|
||||
### 2.1 Authorized Domains (CRITICAL)
|
||||
|
||||
Firebase will block login attempts from unknown domains.
|
||||
|
||||
1. In the **Authentication** section, go to the **Settings** tab.
|
||||
2. Click **Authorized domains** in the left sub-menu.
|
||||
3. Click **Add domain**.
|
||||
4. Add your hosting domain (e.g., `julienmaille.github.io`).
|
||||
* *Note: `localhost` and `127.0.0.1` are usually added by default for local testing.*
|
||||
- _Note: `localhost` and `127.0.0.1` are usually added by default for local testing._
|
||||
|
||||
## 3. Enable Realtime Database
|
||||
|
||||
1. In the left sidebar, click **Build** > **Realtime Database**.
|
||||
2. Click **Create Database**.
|
||||
3. Choose a location near you and click **Next**.
|
||||
4. Select **Start in test mode** (we will change the rules in the next step) and click **Enable**.
|
||||
|
||||
## 4. Set Security Rules
|
||||
|
||||
1. In the Realtime Database section, go to the **Rules** tab.
|
||||
2. Replace the existing rules with the following to ensure users can only see their own data:
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": {
|
||||
"users": {
|
||||
"$uid": {
|
||||
".read": "$uid === auth.uid",
|
||||
".write": "$uid === auth.uid"
|
||||
}
|
||||
},
|
||||
"public_playlists": {
|
||||
".read": true,
|
||||
"$playlistId": {
|
||||
".write": "auth != null && (!data.exists() || data.child('uid').val() === auth.uid)"
|
||||
}
|
||||
"rules": {
|
||||
"users": {
|
||||
"$uid": {
|
||||
".read": "$uid === auth.uid",
|
||||
".write": "$uid === auth.uid"
|
||||
}
|
||||
},
|
||||
"public_playlists": {
|
||||
".read": true,
|
||||
"$playlistId": {
|
||||
".write": "auth != null && (!data.exists() || data.child('uid').val() === auth.uid)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
* **Note:** The `public_playlists` rule allows anyone to read the playlists. The write rule ensures that only authenticated users can publish, and only the owner (creator) of a playlist can modify or delete it.
|
||||
|
||||
- **Note:** The `public_playlists` rule allows anyone to read the playlists. The write rule ensures that only authenticated users can publish, and only the owner (creator) of a playlist can modify or delete it.
|
||||
|
||||
3. Click **Publish**.
|
||||
|
||||
## 5. Get Your Configuration
|
||||
|
||||
1. Click the gear icon (⚙️) next to "Project Overview" and select **Project settings**.
|
||||
2. In the **General** tab, scroll down to "Your apps" and click the **Web icon (`</>`)**.
|
||||
3. Register the app (e.g., "Monochrome App").
|
||||
4. You will see a `firebaseConfig` object. It looks like this:
|
||||
```javascript
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSy...",
|
||||
authDomain: "your-project.firebaseapp.com",
|
||||
databaseURL: "https://your-project.firebaseio.com",
|
||||
projectId: "your-project",
|
||||
storageBucket: "your-project.appspot.com",
|
||||
messagingSenderId: "...",
|
||||
appId: "..."
|
||||
apiKey: 'AIzaSy...',
|
||||
authDomain: 'your-project.firebaseapp.com',
|
||||
databaseURL: 'https://your-project.firebaseio.com',
|
||||
projectId: 'your-project',
|
||||
storageBucket: 'your-project.appspot.com',
|
||||
messagingSenderId: '...',
|
||||
appId: '...',
|
||||
};
|
||||
```
|
||||
5. **Copy only the part with the curly braces `{ ... }`**.
|
||||
|
||||
## 6. Configure Monochrome
|
||||
|
||||
1. Open the Monochrome app and go to **Settings**.
|
||||
2. Find the **Firebase Configuration** section.
|
||||
3. Paste the JSON object you copied into the textarea.
|
||||
|
|
|
|||
2718
index.html
2718
index.html
File diff suppressed because it is too large
Load diff
204
js/api.js
204
js/api.js
|
|
@ -10,14 +10,17 @@ export class LosslessAPI {
|
|||
this.settings = settings;
|
||||
this.cache = new APICache({
|
||||
maxSize: 200,
|
||||
ttl: 1000 * 60 * 30
|
||||
ttl: 1000 * 60 * 30,
|
||||
});
|
||||
this.streamCache = new Map();
|
||||
|
||||
setInterval(() => {
|
||||
this.cache.clearExpired();
|
||||
this.pruneStreamCache();
|
||||
}, 1000 * 60 * 5);
|
||||
setInterval(
|
||||
() => {
|
||||
this.cache.clearExpired();
|
||||
this.pruneStreamCache();
|
||||
},
|
||||
1000 * 60 * 5
|
||||
);
|
||||
}
|
||||
|
||||
pruneStreamCache() {
|
||||
|
|
@ -39,28 +42,26 @@ export class LosslessAPI {
|
|||
let lastError = null;
|
||||
|
||||
for (const baseUrl of instances) {
|
||||
const url = baseUrl.endsWith('/')
|
||||
? `${baseUrl}${relativePath.substring(1)}`
|
||||
: `${baseUrl}${relativePath}`;
|
||||
const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: options.signal });
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
let waitTime = 2000 * attempt; // Default exponential backoff
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
let waitTime = 2000 * attempt; // Default exponential backoff
|
||||
|
||||
if (retryAfter) {
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds)) {
|
||||
waitTime = seconds * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`Rate limit hit. Waiting ${waitTime}ms before retry ${attempt}/${maxRetries}...`);
|
||||
await delay(waitTime);
|
||||
continue;
|
||||
if (retryAfter) {
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds)) {
|
||||
waitTime = seconds * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`Rate limit hit. Waiting ${waitTime}ms before retry ${attempt}/${maxRetries}...`);
|
||||
await delay(waitTime);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -89,7 +90,6 @@ export class LosslessAPI {
|
|||
|
||||
lastError = new Error(`Request failed with status ${response.status}`);
|
||||
break;
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw error;
|
||||
|
|
@ -140,7 +140,7 @@ export class LosslessAPI {
|
|||
items,
|
||||
limit: section?.limit ?? items.length,
|
||||
offset: section?.offset ?? 0,
|
||||
totalNumberOfItems: section?.totalNumberOfItems ?? items.length
|
||||
totalNumberOfItems: section?.totalNumberOfItems ?? items.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -239,10 +239,10 @@ export class LosslessAPI {
|
|||
for (const album of albums) {
|
||||
// Key based on title and numberOfTracks (excluding duration and explicit)
|
||||
const key = JSON.stringify([album.title, album.numberOfTracks || 0]);
|
||||
|
||||
|
||||
if (unique.has(key)) {
|
||||
const existing = unique.get(key);
|
||||
|
||||
|
||||
// Priority 1: Explicit
|
||||
if (album.explicit && !existing.explicit) {
|
||||
unique.set(key, album);
|
||||
|
|
@ -263,7 +263,7 @@ export class LosslessAPI {
|
|||
unique.set(key, album);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
|
|
@ -277,7 +277,7 @@ export class LosslessAPI {
|
|||
const normalized = this.normalizeSearchResponse(data, 'tracks');
|
||||
const result = {
|
||||
...normalized,
|
||||
items: normalized.items.map(t => this.prepareTrack(t))
|
||||
items: normalized.items.map((t) => this.prepareTrack(t)),
|
||||
};
|
||||
|
||||
await this.cache.set('search_tracks', query, result);
|
||||
|
|
@ -299,7 +299,7 @@ export class LosslessAPI {
|
|||
const normalized = this.normalizeSearchResponse(data, 'artists');
|
||||
const result = {
|
||||
...normalized,
|
||||
items: normalized.items.map(a => this.prepareArtist(a))
|
||||
items: normalized.items.map((a) => this.prepareArtist(a)),
|
||||
};
|
||||
|
||||
await this.cache.set('search_artists', query, result);
|
||||
|
|
@ -319,10 +319,10 @@ export class LosslessAPI {
|
|||
const response = await this.fetchWithRetry(`/search/?al=${encodeURIComponent(query)}`, options);
|
||||
const data = await response.json();
|
||||
const normalized = this.normalizeSearchResponse(data, 'albums');
|
||||
const preparedItems = normalized.items.map(a => this.prepareAlbum(a));
|
||||
const preparedItems = normalized.items.map((a) => this.prepareAlbum(a));
|
||||
const result = {
|
||||
...normalized,
|
||||
items: this.deduplicateAlbums(preparedItems)
|
||||
items: this.deduplicateAlbums(preparedItems),
|
||||
};
|
||||
|
||||
await this.cache.set('search_albums', query, result);
|
||||
|
|
@ -344,7 +344,7 @@ export class LosslessAPI {
|
|||
const normalized = this.normalizeSearchResponse(data, 'playlists');
|
||||
const result = {
|
||||
...normalized,
|
||||
items: normalized.items.map(p => this.preparePlaylist(p))
|
||||
items: normalized.items.map((p) => this.preparePlaylist(p)),
|
||||
};
|
||||
|
||||
await this.cache.set('search_playlists', query, result);
|
||||
|
|
@ -362,27 +362,27 @@ export class LosslessAPI {
|
|||
|
||||
const response = await this.fetchWithRetry(`/album/?id=${id}`);
|
||||
const jsonData = await response.json();
|
||||
|
||||
|
||||
// Unwrap the data property if it exists
|
||||
const data = jsonData.data || jsonData;
|
||||
|
||||
|
||||
let album, tracksSection;
|
||||
|
||||
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
// Check for album metadata at root level
|
||||
if ('numberOfTracks' in data || 'title' in data) {
|
||||
album = this.prepareAlbum(data);
|
||||
}
|
||||
|
||||
|
||||
// Set tracksSection if items exist
|
||||
if ('items' in data) {
|
||||
tracksSection = data;
|
||||
|
||||
|
||||
// If we still don't have album but have items with tracks, try to extract album from first track
|
||||
if (!album && data.items && data.items.length > 0) {
|
||||
const firstItem = data.items[0];
|
||||
const track = firstItem.item || firstItem;
|
||||
|
||||
|
||||
// Check if track has album property
|
||||
if (track && track.album) {
|
||||
album = this.prepareAlbum(track.album);
|
||||
|
|
@ -390,7 +390,7 @@ export class LosslessAPI {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!album) throw new Error('Album not found');
|
||||
|
||||
// If album exists but has no artist, try to extract from tracks
|
||||
|
|
@ -416,7 +416,7 @@ export class LosslessAPI {
|
|||
}
|
||||
}
|
||||
|
||||
const tracks = (tracksSection?.items || []).map(i => this.prepareTrack(i.item || i));
|
||||
const tracks = (tracksSection?.items || []).map((i) => this.prepareTrack(i.item || i));
|
||||
const result = { album, tracks };
|
||||
|
||||
await this.cache.set('album', id, result);
|
||||
|
|
@ -429,10 +429,10 @@ export class LosslessAPI {
|
|||
|
||||
const response = await this.fetchWithRetry(`/playlist/?id=${id}`);
|
||||
const jsonData = await response.json();
|
||||
|
||||
|
||||
// Unwrap the data property if it exists
|
||||
const data = jsonData.data || jsonData;
|
||||
|
||||
|
||||
let playlist = null;
|
||||
let tracksSection = null;
|
||||
|
||||
|
|
@ -452,7 +452,10 @@ export class LosslessAPI {
|
|||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
|
||||
if (!playlist && ('uuid' in entry || 'numberOfTracks' in entry || ('title' in entry && 'id' in entry))) {
|
||||
if (
|
||||
!playlist &&
|
||||
('uuid' in entry || 'numberOfTracks' in entry || ('title' in entry && 'id' in entry))
|
||||
) {
|
||||
playlist = entry;
|
||||
}
|
||||
|
||||
|
|
@ -461,10 +464,10 @@ export class LosslessAPI {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fallback 2: If we have a list of entries but no explicit playlist object, try to find one that looks like a playlist
|
||||
if (!playlist && Array.isArray(data)) {
|
||||
for (const entry of data) {
|
||||
for (const entry of data) {
|
||||
if (entry && typeof entry === 'object' && ('uuid' in entry || 'numberOfTracks' in entry)) {
|
||||
playlist = entry;
|
||||
break;
|
||||
|
|
@ -474,25 +477,25 @@ export class LosslessAPI {
|
|||
|
||||
if (!playlist) throw new Error('Playlist not found');
|
||||
|
||||
let tracks = (tracksSection?.items || []).map(i => this.prepareTrack(i.item || i));
|
||||
let tracks = (tracksSection?.items || []).map((i) => this.prepareTrack(i.item || i));
|
||||
|
||||
// Handle pagination if there are more tracks
|
||||
if (playlist.numberOfTracks > tracks.length) {
|
||||
let offset = tracks.length;
|
||||
const SAFE_MAX_TRACKS = 10000;
|
||||
const SAFE_MAX_TRACKS = 10000;
|
||||
|
||||
while (tracks.length < playlist.numberOfTracks && tracks.length < SAFE_MAX_TRACKS) {
|
||||
try {
|
||||
const nextResponse = await this.fetchWithRetry(`/playlist/?id=${id}&offset=${offset}`);
|
||||
const nextJson = await nextResponse.json();
|
||||
const nextData = nextJson.data || nextJson;
|
||||
|
||||
|
||||
let nextItems = [];
|
||||
|
||||
if (nextData.items) {
|
||||
nextItems = nextData.items;
|
||||
} else if (Array.isArray(nextData)) {
|
||||
for (const entry of nextData) {
|
||||
for (const entry of nextData) {
|
||||
if (entry && typeof entry === 'object' && 'items' in entry && Array.isArray(entry.items)) {
|
||||
nextItems = entry.items;
|
||||
break;
|
||||
|
|
@ -502,7 +505,7 @@ export class LosslessAPI {
|
|||
|
||||
if (!nextItems || nextItems.length === 0) break;
|
||||
|
||||
const preparedItems = nextItems.map(i => this.prepareTrack(i.item || i));
|
||||
const preparedItems = nextItems.map((i) => this.prepareTrack(i.item || i));
|
||||
if (preparedItems.length === 0) break;
|
||||
|
||||
// Safeguard: If API ignores offset, it returns the first page again.
|
||||
|
|
@ -513,7 +516,6 @@ export class LosslessAPI {
|
|||
|
||||
tracks = tracks.concat(preparedItems);
|
||||
offset += preparedItems.length;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error fetching playlist tracks at offset ${offset}:`, error);
|
||||
break;
|
||||
|
|
@ -533,23 +535,23 @@ export class LosslessAPI {
|
|||
|
||||
const response = await this.fetchWithRetry(`/mix/?id=${id}`, { type: 'api' });
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
const mixData = data.mix;
|
||||
const items = data.items || [];
|
||||
|
||||
|
||||
if (!mixData) {
|
||||
throw new Error('Mix metadata not found');
|
||||
}
|
||||
|
||||
const tracks = items.map(i => this.prepareTrack(i.item || i));
|
||||
|
||||
const tracks = items.map((i) => this.prepareTrack(i.item || i));
|
||||
|
||||
const mix = {
|
||||
id: mixData.id,
|
||||
title: mixData.title,
|
||||
subTitle: mixData.subTitle,
|
||||
description: mixData.description,
|
||||
mixType: mixData.mixType,
|
||||
cover: mixData.images?.LARGE?.url || mixData.images?.MEDIUM?.url || mixData.images?.SMALL?.url || null
|
||||
cover: mixData.images?.LARGE?.url || mixData.images?.MEDIUM?.url || mixData.images?.SMALL?.url || null,
|
||||
};
|
||||
|
||||
const result = { mix, tracks };
|
||||
|
|
@ -563,11 +565,11 @@ export class LosslessAPI {
|
|||
|
||||
const [primaryResponse, contentResponse] = await Promise.all([
|
||||
this.fetchWithRetry(`/artist/?id=${artistId}`),
|
||||
this.fetchWithRetry(`/artist/?f=${artistId}&skip_tracks=true`)
|
||||
this.fetchWithRetry(`/artist/?f=${artistId}&skip_tracks=true`),
|
||||
]);
|
||||
|
||||
const primaryJsonData = await primaryResponse.json();
|
||||
|
||||
|
||||
// Unwrap data property if it exists, then unwrap artist property if it exists
|
||||
let primaryData = primaryJsonData.data || primaryJsonData;
|
||||
const rawArtist = primaryData.artist || (Array.isArray(primaryData) ? primaryData[0] : primaryData);
|
||||
|
|
@ -577,7 +579,7 @@ export class LosslessAPI {
|
|||
const artist = {
|
||||
...this.prepareArtist(rawArtist),
|
||||
picture: rawArtist.picture || primaryData.cover || null,
|
||||
name: rawArtist.name || 'Unknown Artist'
|
||||
name: rawArtist.name || 'Unknown Artist',
|
||||
};
|
||||
|
||||
const contentJsonData = await contentResponse.json();
|
||||
|
|
@ -588,15 +590,15 @@ export class LosslessAPI {
|
|||
const albumMap = new Map();
|
||||
const trackMap = new Map();
|
||||
|
||||
const isTrack = v => v?.id && v.duration && v.album;
|
||||
const isAlbum = v => v?.id && 'numberOfTracks' in v;
|
||||
const isTrack = (v) => v?.id && v.duration && v.album;
|
||||
const isAlbum = (v) => v?.id && 'numberOfTracks' in v;
|
||||
|
||||
const scan = (value, visited = new Set()) => {
|
||||
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
||||
visited.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => scan(item, visited));
|
||||
value.forEach((item) => scan(item, visited));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -604,21 +606,22 @@ export class LosslessAPI {
|
|||
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
|
||||
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
|
||||
|
||||
Object.values(value).forEach(nested => scan(nested, visited));
|
||||
Object.values(value).forEach((nested) => scan(nested, visited));
|
||||
};
|
||||
|
||||
entries.forEach(entry => scan(entry));
|
||||
entries.forEach((entry) => scan(entry));
|
||||
|
||||
// Attempt to find more albums/EPs via search since the direct feed might be limited
|
||||
try {
|
||||
const searchResults = await this.searchAlbums(artist.name);
|
||||
if (searchResults && searchResults.items) {
|
||||
const numericArtistId = Number(artistId);
|
||||
|
||||
|
||||
for (const item of searchResults.items) {
|
||||
const itemArtistId = item.artist?.id;
|
||||
const matchesArtist = itemArtistId === numericArtistId ||
|
||||
(Array.isArray(item.artists) && item.artists.some(a => a.id === numericArtistId));
|
||||
const matchesArtist =
|
||||
itemArtistId === numericArtistId ||
|
||||
(Array.isArray(item.artists) && item.artists.some((a) => a.id === numericArtistId));
|
||||
|
||||
if (matchesArtist && !albumMap.has(item.id)) {
|
||||
albumMap.set(item.id, item);
|
||||
|
|
@ -630,15 +633,12 @@ export class LosslessAPI {
|
|||
}
|
||||
|
||||
const rawReleases = Array.from(albumMap.values());
|
||||
const allReleases = this.deduplicateAlbums(rawReleases).sort((a, b) =>
|
||||
new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
|
||||
const allReleases = this.deduplicateAlbums(rawReleases).sort(
|
||||
(a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
|
||||
);
|
||||
|
||||
const eps = allReleases.filter(a =>
|
||||
a.type === 'EP' ||
|
||||
a.type === 'SINGLE'
|
||||
);
|
||||
const albums = allReleases.filter(a => !eps.includes(a));
|
||||
const eps = allReleases.filter((a) => a.type === 'EP' || a.type === 'SINGLE');
|
||||
const albums = allReleases.filter((a) => !eps.includes(a));
|
||||
|
||||
const tracks = Array.from(trackMap.values())
|
||||
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
|
||||
|
|
@ -650,8 +650,6 @@ export class LosslessAPI {
|
|||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
async getSimilarArtists(artistId) {
|
||||
const cached = await this.cache.get('similar_artists', artistId);
|
||||
if (cached) return cached;
|
||||
|
|
@ -659,12 +657,12 @@ export class LosslessAPI {
|
|||
try {
|
||||
const response = await this.fetchWithRetry(`/artist/similar/?id=${artistId}`, { type: 'api' });
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
// Handle various response structures
|
||||
const items = data.artists || data.items || data.data || (Array.isArray(data) ? data : []);
|
||||
|
||||
const result = items.map(artist => this.prepareArtist(artist));
|
||||
|
||||
|
||||
const result = items.map((artist) => this.prepareArtist(artist));
|
||||
|
||||
await this.cache.set('similar_artists', artistId, result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
|
|
@ -680,11 +678,11 @@ export class LosslessAPI {
|
|||
try {
|
||||
const response = await this.fetchWithRetry(`/album/similar/?id=${albumId}`, { type: 'api' });
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
const items = data.items || data.albums || data.data || (Array.isArray(data) ? data : []);
|
||||
|
||||
const result = items.map(album => this.prepareAlbum(album));
|
||||
|
||||
|
||||
const result = items.map((album) => this.prepareAlbum(album));
|
||||
|
||||
await this.cache.set('similar_albums', albumId, result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
|
|
@ -694,23 +692,23 @@ export class LosslessAPI {
|
|||
}
|
||||
|
||||
normalizeTrackResponse(apiResponse) {
|
||||
if (!apiResponse || typeof apiResponse !== 'object') {
|
||||
return apiResponse;
|
||||
if (!apiResponse || typeof apiResponse !== 'object') {
|
||||
return apiResponse;
|
||||
}
|
||||
|
||||
// unwrap { version, data } if present
|
||||
const raw = apiResponse.data ?? apiResponse;
|
||||
|
||||
// fabricate the track object expected by parseTrackLookup
|
||||
const trackStub = {
|
||||
duration: raw.duration ?? 0,
|
||||
id: raw.trackId ?? null,
|
||||
};
|
||||
|
||||
// return exactly what parseTrackLookup expects
|
||||
return [trackStub, raw];
|
||||
}
|
||||
|
||||
// unwrap { version, data } if present
|
||||
const raw = apiResponse.data ?? apiResponse;
|
||||
|
||||
// fabricate the track object expected by parseTrackLookup
|
||||
const trackStub = {
|
||||
duration: raw.duration ?? 0,
|
||||
id: raw.trackId ?? null
|
||||
};
|
||||
|
||||
// return exactly what parseTrackLookup expects
|
||||
return [trackStub, raw];
|
||||
}
|
||||
|
||||
async getTrack(id, quality = 'LOSSLESS') {
|
||||
const cacheKey = `${id}_${quality}`;
|
||||
const cached = await this.cache.get('track', cacheKey);
|
||||
|
|
@ -765,7 +763,7 @@ export class LosslessAPI {
|
|||
|
||||
const response = await fetch(streamUrl, {
|
||||
cache: 'no-store',
|
||||
signal: options.signal
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -793,7 +791,7 @@ export class LosslessAPI {
|
|||
onProgress({
|
||||
stage: 'downloading',
|
||||
receivedBytes,
|
||||
totalBytes: totalBytes || undefined
|
||||
totalBytes: totalBytes || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -805,7 +803,7 @@ export class LosslessAPI {
|
|||
onProgress({
|
||||
stage: 'downloading',
|
||||
receivedBytes: blob.size,
|
||||
totalBytes: blob.size
|
||||
totalBytes: blob.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -815,7 +813,7 @@ export class LosslessAPI {
|
|||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'processing',
|
||||
message: 'Adding metadata...'
|
||||
message: 'Adding metadata...',
|
||||
});
|
||||
}
|
||||
blob = await addMetadataToAudio(blob, track, this, quality);
|
||||
|
|
@ -826,7 +824,7 @@ export class LosslessAPI {
|
|||
if (error.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
console.error("Download failed:", error);
|
||||
console.error('Download failed:', error);
|
||||
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -871,7 +869,7 @@ export class LosslessAPI {
|
|||
getCacheStats() {
|
||||
return {
|
||||
...this.cache.getCacheStats(),
|
||||
streamUrls: this.streamCache.size
|
||||
streamUrls: this.streamCache.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
js/cache.js
10
js/cache.js
|
|
@ -34,9 +34,7 @@ export class APICache {
|
|||
}
|
||||
|
||||
generateKey(type, params) {
|
||||
const paramString = typeof params === 'object'
|
||||
? JSON.stringify(params)
|
||||
: String(params);
|
||||
const paramString = typeof params === 'object' ? JSON.stringify(params) : String(params);
|
||||
return `${type}:${paramString}`;
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +69,7 @@ export class APICache {
|
|||
const entry = {
|
||||
key,
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.memoryCache.set(key, entry);
|
||||
|
|
@ -147,7 +145,7 @@ export class APICache {
|
|||
}
|
||||
}
|
||||
|
||||
expired.forEach(key => this.memoryCache.delete(key));
|
||||
expired.forEach((key) => this.memoryCache.delete(key));
|
||||
|
||||
if (this.db) {
|
||||
try {
|
||||
|
|
@ -174,7 +172,7 @@ export class APICache {
|
|||
return {
|
||||
memoryEntries: this.memoryCache.size,
|
||||
maxSize: this.maxSize,
|
||||
ttl: this.ttl
|
||||
ttl: this.ttl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
js/db.js
61
js/db.js
|
|
@ -12,7 +12,7 @@ export class MusicDatabase {
|
|||
const request = indexedDB.open(this.dbName, this.version);
|
||||
|
||||
request.onerror = (event) => {
|
||||
console.error("Database error:", event.target.error);
|
||||
console.error('Database error:', event.target.error);
|
||||
reject(event.target.error);
|
||||
};
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ export class MusicDatabase {
|
|||
const transaction = db.transaction(storeName, 'readonly');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const index = store.index('timestamp');
|
||||
const request = index.getAll();
|
||||
const request = index.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
// Return reversed (newest first)
|
||||
|
|
@ -162,7 +162,7 @@ export class MusicDatabase {
|
|||
// Base properties to keep
|
||||
const base = {
|
||||
id: item.id,
|
||||
addedAt: item.addedAt || null
|
||||
addedAt: item.addedAt || null,
|
||||
};
|
||||
|
||||
if (type === 'track') {
|
||||
|
|
@ -172,18 +172,20 @@ export class MusicDatabase {
|
|||
duration: item.duration,
|
||||
explicit: item.explicit,
|
||||
// Keep minimal artist info
|
||||
artists: item.artists?.map(a => ({ id: a.id, name: a.name })) || [],
|
||||
artists: item.artists?.map((a) => ({ id: a.id, name: a.name })) || [],
|
||||
// Keep minimal album info
|
||||
album: item.album ? {
|
||||
id: item.album.id,
|
||||
cover: item.album.cover,
|
||||
releaseDate: item.album.releaseDate || null,
|
||||
vibrantColor: item.album.vibrantColor || null
|
||||
} : null,
|
||||
album: item.album
|
||||
? {
|
||||
id: item.album.id,
|
||||
cover: item.album.cover,
|
||||
releaseDate: item.album.releaseDate || null,
|
||||
vibrantColor: item.album.vibrantColor || null,
|
||||
}
|
||||
: null,
|
||||
// Fallback date
|
||||
streamStartDate: item.streamStartDate || null,
|
||||
// Keep version if exists
|
||||
version: item.version || null
|
||||
version: item.version || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -195,10 +197,14 @@ export class MusicDatabase {
|
|||
releaseDate: item.releaseDate || null,
|
||||
explicit: item.explicit,
|
||||
// UI uses singular 'artist'
|
||||
artist: item.artist ? { name: item.artist.name, id: item.artist.id } : (item.artists?.[0] ? { name: item.artists[0].name, id: item.artists[0].id } : null),
|
||||
artist: item.artist
|
||||
? { name: item.artist.name, id: item.artist.id }
|
||||
: item.artists?.[0]
|
||||
? { name: item.artists[0].name, id: item.artists[0].id }
|
||||
: null,
|
||||
// Keep type and track count for UI labels
|
||||
type: item.type || null,
|
||||
numberOfTracks: item.numberOfTracks
|
||||
numberOfTracks: item.numberOfTracks,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +212,7 @@ export class MusicDatabase {
|
|||
return {
|
||||
...base,
|
||||
name: item.name,
|
||||
picture: item.picture || item.image || null // Handle both just in case
|
||||
picture: item.picture || item.image || null, // Handle both just in case
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +224,7 @@ export class MusicDatabase {
|
|||
// UI checks squareImage || image || uuid
|
||||
image: item.image || item.squareImage || item.cover || null,
|
||||
numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0),
|
||||
user: item.user ? { name: item.user.name } : null
|
||||
user: item.user ? { name: item.user.name } : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -230,7 +236,7 @@ export class MusicDatabase {
|
|||
subTitle: item.subTitle,
|
||||
description: item.description,
|
||||
mixType: item.mixType,
|
||||
cover: item.cover
|
||||
cover: item.cover,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -247,13 +253,13 @@ export class MusicDatabase {
|
|||
|
||||
const userPlaylists = await this.getPlaylists();
|
||||
const data = {
|
||||
favorites_tracks: tracks.map(t => this._minifyItem('track', t)),
|
||||
favorites_albums: albums.map(a => this._minifyItem('album', a)),
|
||||
favorites_artists: artists.map(a => this._minifyItem('artist', a)),
|
||||
favorites_playlists: playlists.map(p => this._minifyItem('playlist', p)),
|
||||
favorites_mixes: mixes.map(m => this._minifyItem('mix', m)),
|
||||
history_tracks: history.map(t => this._minifyItem('track', t)),
|
||||
user_playlists: userPlaylists
|
||||
favorites_tracks: tracks.map((t) => this._minifyItem('track', t)),
|
||||
favorites_albums: albums.map((a) => this._minifyItem('album', a)),
|
||||
favorites_artists: artists.map((a) => this._minifyItem('artist', a)),
|
||||
favorites_playlists: playlists.map((p) => this._minifyItem('playlist', p)),
|
||||
favorites_mixes: mixes.map((m) => this._minifyItem('mix', m)),
|
||||
history_tracks: history.map((t) => this._minifyItem('track', t)),
|
||||
user_playlists: userPlaylists,
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
|
@ -267,7 +273,6 @@ export class MusicDatabase {
|
|||
// This allows partial updates (e.g. library only)
|
||||
if (items === undefined) return;
|
||||
|
||||
|
||||
let itemsArray = Array.isArray(items) ? items : Object.values(items || {});
|
||||
|
||||
const transaction = db.transaction(storeName, 'readwrite');
|
||||
|
|
@ -302,9 +307,9 @@ export class MusicDatabase {
|
|||
const playlist = {
|
||||
id: id,
|
||||
name: name,
|
||||
tracks: tracks.map(t => this._minifyItem('track', t)),
|
||||
tracks: tracks.map((t) => this._minifyItem('track', t)),
|
||||
cover: cover,
|
||||
createdAt: Date.now()
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
||||
return playlist;
|
||||
|
|
@ -315,7 +320,7 @@ export class MusicDatabase {
|
|||
if (!playlist) throw new Error('Playlist not found');
|
||||
playlist.tracks = playlist.tracks || [];
|
||||
const minifiedTrack = this._minifyItem('track', track);
|
||||
if (playlist.tracks.some(t => t.id === track.id)) return;
|
||||
if (playlist.tracks.some((t) => t.id === track.id)) return;
|
||||
playlist.tracks.push(minifiedTrack);
|
||||
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
||||
return playlist;
|
||||
|
|
@ -325,7 +330,7 @@ export class MusicDatabase {
|
|||
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
|
||||
if (!playlist) throw new Error('Playlist not found');
|
||||
playlist.tracks = playlist.tracks || [];
|
||||
playlist.tracks = playlist.tracks.filter(t => t.id !== trackId);
|
||||
playlist.tracks = playlist.tracks.filter((t) => t.id !== trackId);
|
||||
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
||||
return playlist;
|
||||
}
|
||||
|
|
|
|||
148
js/downloads.js
148
js/downloads.js
|
|
@ -1,5 +1,14 @@
|
|||
//js/downloads.js
|
||||
import { buildTrackFilename, sanitizeForFilename, RATE_LIMIT_ERROR_MESSAGE, getTrackArtists, getTrackTitle, formatTemplate, SVG_CLOSE, getCoverBlob } from './utils.js';
|
||||
import {
|
||||
buildTrackFilename,
|
||||
sanitizeForFilename,
|
||||
RATE_LIMIT_ERROR_MESSAGE,
|
||||
getTrackArtists,
|
||||
getTrackTitle,
|
||||
formatTemplate,
|
||||
SVG_CLOSE,
|
||||
getCoverBlob,
|
||||
} from './utils.js';
|
||||
import { lyricsSettings } from './storage.js';
|
||||
import { addMetadataToAudio } from './metadata.js';
|
||||
|
||||
|
|
@ -105,16 +114,12 @@ export function updateDownloadProgress(trackId, progress) {
|
|||
const statusEl = taskEl.querySelector('.download-status');
|
||||
|
||||
if (progress.stage === 'downloading') {
|
||||
const percent = progress.totalBytes
|
||||
? Math.round((progress.receivedBytes / progress.totalBytes) * 100)
|
||||
: 0;
|
||||
const percent = progress.totalBytes ? Math.round((progress.receivedBytes / progress.totalBytes) * 100) : 0;
|
||||
|
||||
progressFill.style.width = `${percent}%`;
|
||||
|
||||
const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
|
||||
const totalMB = progress.totalBytes
|
||||
? (progress.totalBytes / (1024 * 1024)).toFixed(1)
|
||||
: '?';
|
||||
const totalMB = progress.totalBytes ? (progress.totalBytes / (1024 * 1024)).toFixed(1) : '?';
|
||||
|
||||
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
|
||||
}
|
||||
|
|
@ -161,7 +166,7 @@ function removeDownloadTask(trackId) {
|
|||
taskEl.remove();
|
||||
downloadTasks.delete(trackId);
|
||||
|
||||
if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) {
|
||||
if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) {
|
||||
downloadNotificationContainer.remove();
|
||||
downloadNotificationContainer = null;
|
||||
}
|
||||
|
|
@ -202,12 +207,12 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
|||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch track: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
let blob = await response.blob();
|
||||
|
||||
|
||||
// Add metadata to the blob
|
||||
blob = await addMetadataToAudio(blob, track, api, quality);
|
||||
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
|
|
@ -218,32 +223,32 @@ async function generateAndDownloadZip(zip, filename, notification, progressTotal
|
|||
// Use the pre-acquired file handle for streaming (Chrome/Edge/Opera)
|
||||
if (fileHandle) {
|
||||
const writable = await fileHandle.createWritable();
|
||||
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
zip.generateInternalStream({
|
||||
type: 'uint8array',
|
||||
compression: 'STORE',
|
||||
streamFiles: true
|
||||
streamFiles: true,
|
||||
})
|
||||
.on('data', (chunk, metadata) => {
|
||||
writable.write(chunk);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
writable.close();
|
||||
reject(err);
|
||||
})
|
||||
.on('end', () => {
|
||||
writable.close();
|
||||
resolve();
|
||||
})
|
||||
.resume();
|
||||
.on('data', (chunk, metadata) => {
|
||||
writable.write(chunk);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
writable.close();
|
||||
reject(err);
|
||||
})
|
||||
.on('end', () => {
|
||||
writable.close();
|
||||
resolve();
|
||||
})
|
||||
.resume();
|
||||
});
|
||||
} else {
|
||||
// Fallback for Firefox/Safari or if user cancelled/API not available
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'STORE',
|
||||
streamFiles: true
|
||||
streamFiles: true,
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(zipBlob);
|
||||
|
|
@ -272,10 +277,12 @@ async function initializeZipDownload(defaultName, useFilePicker = false) {
|
|||
try {
|
||||
fileHandle = await window.showSaveFilePicker({
|
||||
suggestedName: `${defaultName}.zip`,
|
||||
types: [{
|
||||
description: 'ZIP Archive',
|
||||
accept: { 'application/zip': ['.zip'] }
|
||||
}]
|
||||
types: [
|
||||
{
|
||||
description: 'ZIP Archive',
|
||||
accept: { 'application/zip': ['.zip'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return null; // User cancelled
|
||||
|
|
@ -285,7 +292,17 @@ async function initializeZipDownload(defaultName, useFilePicker = false) {
|
|||
return { zip, fileHandle };
|
||||
}
|
||||
|
||||
async function downloadTracksToZip(zip, tracks, folderName, api, quality, lyricsManager, notification, startProgressIndex = 0, totalTracks = tracks.length) {
|
||||
async function downloadTracksToZip(
|
||||
zip,
|
||||
tracks,
|
||||
folderName,
|
||||
api,
|
||||
quality,
|
||||
lyricsManager,
|
||||
notification,
|
||||
startProgressIndex = 0,
|
||||
totalTracks = tracks.length
|
||||
) {
|
||||
const { abortController } = bulkDownloadTasks.get(notification);
|
||||
const signal = abortController.signal;
|
||||
|
||||
|
|
@ -325,14 +342,15 @@ async function downloadTracksToZip(zip, tracks, folderName, api, quality, lyrics
|
|||
}
|
||||
|
||||
export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) {
|
||||
const releaseDateStr = album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
||||
const releaseDateStr =
|
||||
album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
||||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||
const year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : '';
|
||||
const year = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
||||
|
||||
const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
|
||||
albumTitle: album.title,
|
||||
albumArtist: album.artist?.name,
|
||||
year: year
|
||||
year: year,
|
||||
});
|
||||
|
||||
// Only prompt for save location if we have >= 20 tracks (to capture user gesture early)
|
||||
|
|
@ -361,7 +379,7 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
|
|||
const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
|
||||
albumTitle: playlist.title,
|
||||
albumArtist: 'Playlist',
|
||||
year: new Date().getFullYear()
|
||||
year: new Date().getFullYear(),
|
||||
});
|
||||
|
||||
const initResult = await initializeZipDownload(folderName, tracks.length >= 20);
|
||||
|
|
@ -372,7 +390,7 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
|
|||
|
||||
try {
|
||||
// Find a representative cover for the playlist (first track with cover)
|
||||
const representativeTrack = tracks.find(t => t.album?.cover);
|
||||
const representativeTrack = tracks.find((t) => t.album?.cover);
|
||||
const coverBlob = await getCoverBlob(api, representativeTrack?.album?.cover);
|
||||
addCoverBlobToZip(zip, folderName, coverBlob);
|
||||
|
||||
|
|
@ -408,23 +426,28 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
try {
|
||||
const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
|
||||
const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover);
|
||||
|
||||
const releaseDateStr = fullAlbum.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
||||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||
const year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : '';
|
||||
|
||||
const albumFolder = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
|
||||
albumTitle: fullAlbum.title,
|
||||
albumArtist: fullAlbum.artist?.name,
|
||||
year: year
|
||||
});
|
||||
const releaseDateStr =
|
||||
fullAlbum.releaseDate ||
|
||||
(tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
||||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||
const year = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
||||
|
||||
const albumFolder = formatTemplate(
|
||||
localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}',
|
||||
{
|
||||
albumTitle: fullAlbum.title,
|
||||
albumArtist: fullAlbum.artist?.name,
|
||||
year: year,
|
||||
}
|
||||
);
|
||||
|
||||
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
||||
addCoverBlobToZip(zip, fullFolderPath, coverBlob);
|
||||
|
||||
for (const track of tracks) {
|
||||
const filename = buildTrackFilename(track, quality);
|
||||
try {
|
||||
const filename = buildTrackFilename(track, quality);
|
||||
try {
|
||||
const blob = await downloadTrackBlob(track, quality, api, null, signal);
|
||||
zip.file(`${fullFolderPath}/${filename}`, blob);
|
||||
|
||||
|
|
@ -442,14 +465,13 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
// Silent fail for lyrics in bulk
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw err;
|
||||
}
|
||||
console.error(`Failed to download track ${track.title}:`, err);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw err;
|
||||
}
|
||||
console.error(`Failed to download track ${track.title}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw error;
|
||||
|
|
@ -545,7 +567,6 @@ function completeBulkDownload(notifEl, success = true, message = null) {
|
|||
}
|
||||
|
||||
export async function downloadTrackWithMetadata(track, quality, api, lyricsManager = null, abortController = null) {
|
||||
|
||||
if (!track) {
|
||||
alert('No track is currently playing');
|
||||
return;
|
||||
|
|
@ -556,20 +577,14 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
|||
const controller = abortController || new AbortController();
|
||||
|
||||
try {
|
||||
const { taskEl } = addDownloadTask(
|
||||
track.id,
|
||||
track,
|
||||
filename,
|
||||
api,
|
||||
controller
|
||||
);
|
||||
const { taskEl } = addDownloadTask(track.id, track, filename, api, controller);
|
||||
|
||||
await api.downloadTrack(track.id, quality, filename, {
|
||||
signal: controller.signal,
|
||||
track: track,
|
||||
onProgress: (progress) => {
|
||||
updateDownloadProgress(track.id, progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
completeDownloadTask(track.id, true);
|
||||
|
|
@ -586,10 +601,9 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
|||
}
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
|
||||
? error.message
|
||||
: 'Download failed. Please try again.';
|
||||
const errorMsg =
|
||||
error.message === RATE_LIMIT_ERROR_MESSAGE ? error.message : 'Download failed. Please try again.';
|
||||
completeDownloadTask(track.id, false, errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
267
js/events.js
267
js/events.js
|
|
@ -1,5 +1,16 @@
|
|||
//js/events.js
|
||||
import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename, getTrackTitle, formatTime } from './utils.js';
|
||||
import {
|
||||
SVG_PLAY,
|
||||
SVG_PAUSE,
|
||||
SVG_VOLUME,
|
||||
SVG_MUTE,
|
||||
REPEAT_MODE,
|
||||
trackDataStore,
|
||||
RATE_LIMIT_ERROR_MESSAGE,
|
||||
buildTrackFilename,
|
||||
getTrackTitle,
|
||||
formatTime,
|
||||
} from './utils.js';
|
||||
import { lastFMStorage, waveformSettings } from './storage.js';
|
||||
import { showNotification, downloadTrackWithMetadata } from './downloads.js';
|
||||
import { lyricsSettings, downloadQualitySettings } from './storage.js';
|
||||
|
|
@ -30,7 +41,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
|
||||
if (player.repeatMode !== REPEAT_MODE.OFF) {
|
||||
repeatBtn.classList.add('active');
|
||||
if (player.repeatMode === REPEAT_MODE.ONE) {
|
||||
if (player.repeatMode === REPEAT_MODE.ONE) {
|
||||
repeatBtn.classList.add('repeat-one');
|
||||
}
|
||||
repeatBtn.title = player.repeatMode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One';
|
||||
|
|
@ -44,12 +55,12 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled()) {
|
||||
scrobbler.updateNowPlaying(player.currentTrack);
|
||||
}
|
||||
|
||||
|
||||
// Resume AudioContext for waveform on mobile (iOS)
|
||||
if (waveformGenerator.audioContext.state === 'suspended') {
|
||||
waveformGenerator.audioContext.resume();
|
||||
}
|
||||
|
||||
|
||||
updateWaveform();
|
||||
}
|
||||
|
||||
|
|
@ -121,9 +132,8 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
const mode = player.toggleRepeat();
|
||||
repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
|
||||
repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE);
|
||||
repeatBtn.title = mode === REPEAT_MODE.OFF
|
||||
? 'Repeat'
|
||||
: (mode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One');
|
||||
repeatBtn.title =
|
||||
mode === REPEAT_MODE.OFF ? 'Repeat' : mode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One';
|
||||
});
|
||||
|
||||
// Sleep Timer for desktop
|
||||
|
|
@ -180,7 +190,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
if (playerControls) {
|
||||
playerControls.classList.remove('waveform-loaded');
|
||||
}
|
||||
|
||||
|
||||
// Clear current mask while loading
|
||||
progressBar.style.webkitMaskImage = '';
|
||||
progressBar.style.maskImage = '';
|
||||
|
|
@ -188,7 +198,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
try {
|
||||
const streamUrl = await player.api.getStreamUrl(player.currentTrack.id, 'LOW');
|
||||
const waveformData = await waveformGenerator.getWaveform(streamUrl, player.currentTrack.id);
|
||||
|
||||
|
||||
if (waveformData && currentTrackIdForWaveform === player.currentTrack.id) {
|
||||
let { peaks, duration } = waveformData;
|
||||
const trackDuration = player.currentTrack.duration;
|
||||
|
|
@ -196,13 +206,14 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
// Padding logic for sync
|
||||
if (trackDuration && duration && duration < trackDuration) {
|
||||
const diff = trackDuration - duration;
|
||||
if (diff > 0.5) { // If difference is significant (> 500ms)
|
||||
if (diff > 0.5) {
|
||||
// If difference is significant (> 500ms)
|
||||
// Calculate how many peaks represent the missing time
|
||||
// peaks.length represents 'duration'
|
||||
// X peaks represent 'diff'
|
||||
const peaksPerSecond = peaks.length / duration;
|
||||
const paddingPeaksCount = Math.floor(diff * peaksPerSecond);
|
||||
|
||||
|
||||
if (paddingPeaksCount > 0) {
|
||||
const newPeaks = new Float32Array(peaks.length + paddingPeaksCount);
|
||||
// Fill start with 0s (implied by new Float32Array)
|
||||
|
|
@ -227,7 +238,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
progressBar.style.maskImage = `url(${dataUrl})`;
|
||||
progressBar.style.maskSize = '100% 100%';
|
||||
progressBar.style.maskRepeat = 'no-repeat';
|
||||
|
||||
|
||||
progressBar.classList.add('waveform-loaded');
|
||||
if (playerControls) {
|
||||
playerControls.classList.add('waveform-loaded');
|
||||
|
|
@ -258,7 +269,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
const updateVolumeUI = () => {
|
||||
const { muted } = audioPlayer;
|
||||
const volume = player.userVolume;
|
||||
volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME;
|
||||
volumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE : SVG_VOLUME;
|
||||
const effectiveVolume = muted ? 0 : volume * 100;
|
||||
volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
|
||||
volumeFill.style.width = `${effectiveVolume}%`;
|
||||
|
|
@ -308,7 +319,7 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
wasPlaying = !audioPlayer.paused;
|
||||
if (wasPlaying) audioPlayer.pause();
|
||||
|
||||
seek(progressBar, e, position => {
|
||||
seek(progressBar, e, (position) => {
|
||||
if (!isNaN(audioPlayer.duration)) {
|
||||
audioPlayer.currentTime = position * audioPlayer.duration;
|
||||
progressFill.style.width = `${position * 100}%`;
|
||||
|
|
@ -334,7 +345,7 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (isSeeking) {
|
||||
seek(progressBar, e, position => {
|
||||
seek(progressBar, e, (position) => {
|
||||
if (!isNaN(audioPlayer.duration)) {
|
||||
audioPlayer.currentTime = position * audioPlayer.duration;
|
||||
progressFill.style.width = `${position * 100}%`;
|
||||
|
|
@ -343,7 +354,7 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
}
|
||||
|
||||
if (isAdjustingVolume) {
|
||||
seek(volumeBar, e, position => {
|
||||
seek(volumeBar, e, (position) => {
|
||||
player.setVolume(position);
|
||||
volumeFill.style.width = `${position * 100}%`;
|
||||
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
|
||||
|
|
@ -374,7 +385,7 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
|
||||
document.addEventListener('mouseup', (e) => {
|
||||
if (isSeeking) {
|
||||
seek(progressBar, e, position => {
|
||||
seek(progressBar, e, (position) => {
|
||||
if (!isNaN(audioPlayer.duration)) {
|
||||
audioPlayer.currentTime = position * audioPlayer.duration;
|
||||
player.updateMediaSessionPositionState();
|
||||
|
|
@ -403,9 +414,9 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
}
|
||||
});
|
||||
|
||||
progressBar.addEventListener('click', e => {
|
||||
progressBar.addEventListener('click', (e) => {
|
||||
if (!isSeeking) {
|
||||
seek(progressBar, e, position => {
|
||||
seek(progressBar, e, (position) => {
|
||||
if (!isNaN(audioPlayer.duration) && audioPlayer.duration > 0 && audioPlayer.duration !== Infinity) {
|
||||
audioPlayer.currentTime = position * audioPlayer.duration;
|
||||
player.updateMediaSessionPositionState();
|
||||
|
|
@ -421,7 +432,7 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
|
||||
volumeBar.addEventListener('mousedown', (e) => {
|
||||
isAdjustingVolume = true;
|
||||
seek(volumeBar, e, position => {
|
||||
seek(volumeBar, e, (position) => {
|
||||
player.setVolume(position);
|
||||
volumeFill.style.width = `${position * 100}%`;
|
||||
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
|
||||
|
|
@ -439,47 +450,64 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
|
||||
});
|
||||
|
||||
volumeBar.addEventListener('click', e => {
|
||||
volumeBar.addEventListener('click', (e) => {
|
||||
if (!isAdjustingVolume) {
|
||||
seek(volumeBar, e, position => {
|
||||
seek(volumeBar, e, (position) => {
|
||||
player.setVolume(position);
|
||||
volumeFill.style.width = `${position * 100}%`;
|
||||
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
|
||||
});
|
||||
}
|
||||
});
|
||||
volumeBar.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
|
||||
volumeBar.addEventListener(
|
||||
'wheel',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
|
||||
|
||||
if (delta > 0 && audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
}
|
||||
if (delta > 0 && audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
}
|
||||
|
||||
player.setVolume(newVolume);
|
||||
volumeFill.style.width = `${newVolume * 100}%`;
|
||||
volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`);
|
||||
}, { passive: false });
|
||||
player.setVolume(newVolume);
|
||||
volumeFill.style.width = `${newVolume * 100}%`;
|
||||
volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`);
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
volumeBtn?.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
|
||||
volumeBtn?.addEventListener(
|
||||
'wheel',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
|
||||
|
||||
if (delta > 0 && audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
}
|
||||
if (delta > 0 && audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
}
|
||||
|
||||
player.setVolume(newVolume);
|
||||
volumeFill.style.width = `${newVolume * 100}%`;
|
||||
volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`);
|
||||
}, { passive: false });
|
||||
player.setVolume(newVolume);
|
||||
volumeFill.style.width = `${newVolume * 100}%`;
|
||||
volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`);
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleTrackAction(action, item, player, api, lyricsManager, type = 'track', ui = null, scrobbler = null) {
|
||||
export async function handleTrackAction(
|
||||
action,
|
||||
item,
|
||||
player,
|
||||
api,
|
||||
lyricsManager,
|
||||
type = 'track',
|
||||
ui = null,
|
||||
scrobbler = null
|
||||
) {
|
||||
if (!item) return;
|
||||
|
||||
if (action === 'add-to-queue') {
|
||||
|
|
@ -509,10 +537,10 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
|
|||
try {
|
||||
playlist = await syncManager.getPublicPlaylist(item.id);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
tracks = playlist ? playlist.tracks : (item.tracks || []);
|
||||
tracks = playlist ? playlist.tracks : item.tracks || [];
|
||||
}
|
||||
|
||||
if (tracks.length > 0) {
|
||||
|
|
@ -523,7 +551,7 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
|
|||
const name = type === 'user-playlist' ? item.name : item.title;
|
||||
showNotification(`Playing ${type.replace('user-', '')}: ${name}`);
|
||||
} else {
|
||||
showNotification(`No tracks found in this ${type}`);
|
||||
showNotification(`No tracks found in this ${type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to play card:', error);
|
||||
|
|
@ -541,9 +569,10 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
|
|||
|
||||
// Update all instances of this item's like button on the page
|
||||
const id = type === 'playlist' ? item.uuid : item.id;
|
||||
const selector = type === 'track'
|
||||
? `[data-track-id="${id}"] .like-btn`
|
||||
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
|
||||
const selector =
|
||||
type === 'track'
|
||||
? `[data-track-id="${id}"] .like-btn`
|
||||
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
|
||||
|
||||
// Also check header buttons
|
||||
const headerBtn = document.getElementById(`like-${type}-btn`);
|
||||
|
|
@ -556,26 +585,27 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
|
|||
elementsToUpdate.push(nowPlayingLikeBtn);
|
||||
}
|
||||
|
||||
elementsToUpdate.forEach(btn => {
|
||||
const heartIcon = btn.querySelector('svg');
|
||||
if (heartIcon) {
|
||||
heartIcon.classList.toggle('filled', added);
|
||||
if (heartIcon.hasAttribute('fill')) {
|
||||
elementsToUpdate.forEach((btn) => {
|
||||
const heartIcon = btn.querySelector('svg');
|
||||
if (heartIcon) {
|
||||
heartIcon.classList.toggle('filled', added);
|
||||
if (heartIcon.hasAttribute('fill')) {
|
||||
heartIcon.setAttribute('fill', added ? 'currentColor' : 'none');
|
||||
}
|
||||
}
|
||||
btn.classList.toggle('active', added);
|
||||
btn.title = added ? 'Remove from Favorites' : 'Add to Favorites';
|
||||
}
|
||||
}
|
||||
btn.classList.toggle('active', added);
|
||||
btn.title = added ? 'Remove from Favorites' : 'Add to Favorites';
|
||||
});
|
||||
|
||||
// Handle Library Page Update
|
||||
if (window.location.hash === '#library') {
|
||||
const itemSelector = type === 'track'
|
||||
? `.track-item[data-track-id="${id}"]`
|
||||
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
|
||||
|
||||
const itemSelector =
|
||||
type === 'track'
|
||||
? `.track-item[data-track-id="${id}"]`
|
||||
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
|
||||
|
||||
const itemEl = document.querySelector(itemSelector);
|
||||
|
||||
|
||||
if (!added && itemEl) {
|
||||
// Remove item
|
||||
const container = itemEl.parentElement;
|
||||
|
|
@ -625,22 +655,25 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
|
|||
const playlistsWithTrack = new Set();
|
||||
|
||||
for (const playlist of playlists) {
|
||||
if (playlist.tracks && playlist.tracks.some(track => track.id === trackId)) {
|
||||
if (playlist.tracks && playlist.tracks.some((track) => track.id === trackId)) {
|
||||
playlistsWithTrack.add(playlist.id);
|
||||
}
|
||||
}
|
||||
|
||||
const checkmarkSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
||||
const checkmarkSvg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
||||
|
||||
list.innerHTML = playlists.map(p => {
|
||||
const alreadyContains = playlistsWithTrack.has(p.id);
|
||||
return `
|
||||
list.innerHTML = playlists
|
||||
.map((p) => {
|
||||
const alreadyContains = playlistsWithTrack.has(p.id);
|
||||
return `
|
||||
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
|
||||
<span>${p.name}</span>
|
||||
${alreadyContains ? `<span class="checkmark">${checkmarkSvg}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
})
|
||||
.join('');
|
||||
|
||||
const closeModal = () => {
|
||||
modal.classList.remove('active');
|
||||
|
|
@ -699,7 +732,7 @@ async function updateContextMenuLikeState(contextMenu, contextTrack) {
|
|||
export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager, ui, scrobbler) {
|
||||
let contextTrack = null;
|
||||
|
||||
mainContent.addEventListener('click', async e => {
|
||||
mainContent.addEventListener('click', async (e) => {
|
||||
const actionBtn = e.target.closest('.track-action-btn, .like-btn, .play-btn');
|
||||
if (actionBtn && actionBtn.dataset.action) {
|
||||
e.preventDefault(); // Prevent card navigation
|
||||
|
|
@ -727,7 +760,9 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
const data = await api.getMix(id);
|
||||
item = data.mix;
|
||||
}
|
||||
} catch (err) { console.error(err); }
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -744,7 +779,12 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
if (trackItem && !trackItem.dataset.queueIndex) {
|
||||
const clickedTrack = trackDataStore.get(trackItem);
|
||||
|
||||
if (contextMenu.style.display === 'block' && contextTrack && clickedTrack && contextTrack.id === clickedTrack.id) {
|
||||
if (
|
||||
contextMenu.style.display === 'block' &&
|
||||
contextTrack &&
|
||||
clickedTrack &&
|
||||
contextTrack.id === clickedTrack.id
|
||||
) {
|
||||
contextMenu.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
|
@ -763,11 +803,11 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
if (trackItem && !trackItem.dataset.queueIndex && !e.target.closest('.remove-from-playlist-btn')) {
|
||||
const parentList = trackItem.closest('.track-list');
|
||||
const allTrackElements = Array.from(parentList.querySelectorAll('.track-item'));
|
||||
const trackList = allTrackElements.map(el => trackDataStore.get(el)).filter(Boolean);
|
||||
const trackList = allTrackElements.map((el) => trackDataStore.get(el)).filter(Boolean);
|
||||
|
||||
if (trackList.length > 0) {
|
||||
const clickedTrackId = trackItem.dataset.trackId;
|
||||
const startIndex = trackList.findIndex(t => t.id == clickedTrackId);
|
||||
const startIndex = trackList.findIndex((t) => t.id == clickedTrackId);
|
||||
|
||||
player.setQueue(trackList, startIndex);
|
||||
document.getElementById('shuffle-btn').classList.remove('active');
|
||||
|
|
@ -785,14 +825,14 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
if (href) {
|
||||
// Allow native links inside card to work if any exist
|
||||
if (e.target.closest('a')) return;
|
||||
|
||||
|
||||
e.preventDefault();
|
||||
window.location.hash = href;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mainContent.addEventListener('contextmenu', async e => {
|
||||
mainContent.addEventListener('contextmenu', async (e) => {
|
||||
const trackItem = e.target.closest('.track-item, .queue-track-item');
|
||||
if (trackItem) {
|
||||
e.preventDefault();
|
||||
|
|
@ -816,7 +856,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
contextMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
contextMenu.addEventListener('click', async e => {
|
||||
contextMenu.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const action = e.target.dataset.action;
|
||||
const track = contextMenu._contextTrack || contextTrack;
|
||||
|
|
@ -857,7 +897,16 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
nowPlayingLikeBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (player.currentTrack) {
|
||||
await handleTrackAction('toggle-like', player.currentTrack, player, api, lyricsManager, 'track', ui, scrobbler);
|
||||
await handleTrackAction(
|
||||
'toggle-like',
|
||||
player.currentTrack,
|
||||
player,
|
||||
api,
|
||||
lyricsManager,
|
||||
'track',
|
||||
ui,
|
||||
scrobbler
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -867,7 +916,16 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
nowPlayingMixBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (player.currentTrack) {
|
||||
await handleTrackAction('track-mix', player.currentTrack, player, api, lyricsManager, 'track', ui, scrobbler);
|
||||
await handleTrackAction(
|
||||
'track-mix',
|
||||
player.currentTrack,
|
||||
player,
|
||||
api,
|
||||
lyricsManager,
|
||||
'track',
|
||||
ui,
|
||||
scrobbler
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -877,26 +935,40 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
nowPlayingAddPlaylistBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (player.currentTrack) {
|
||||
await handleTrackAction('add-to-playlist', player.currentTrack, player, api, lyricsManager, 'track', ui, scrobbler);
|
||||
await handleTrackAction(
|
||||
'add-to-playlist',
|
||||
player.currentTrack,
|
||||
player,
|
||||
api,
|
||||
lyricsManager,
|
||||
'track',
|
||||
ui,
|
||||
scrobbler
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Mobile add playlist button functionality
|
||||
const mobileAddPlaylistBtn = document.getElementById('mobile-add-playlist-btn');
|
||||
|
||||
if (mobileAddPlaylistBtn) {
|
||||
mobileAddPlaylistBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (player.currentTrack) {
|
||||
await handleTrackAction('add-to-playlist', player.currentTrack, player, api, lyricsManager, 'track', ui, scrobbler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (mobileAddPlaylistBtn) {
|
||||
mobileAddPlaylistBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (player.currentTrack) {
|
||||
await handleTrackAction(
|
||||
'add-to-playlist',
|
||||
player.currentTrack,
|
||||
player,
|
||||
api,
|
||||
lyricsManager,
|
||||
'track',
|
||||
ui,
|
||||
scrobbler
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showSleepTimerModal(player) {
|
||||
|
|
@ -963,7 +1035,8 @@ function positionMenu(menu, x, y, anchorRect = null) {
|
|||
|
||||
if (anchorRect) {
|
||||
// Adjust horizontal position if it overflows right
|
||||
if (left + menuWidth > windowWidth - 10) { // 10px buffer
|
||||
if (left + menuWidth > windowWidth - 10) {
|
||||
// 10px buffer
|
||||
left = anchorRect.right - menuWidth;
|
||||
if (left < 10) left = 10;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
// js/firebase/auth.js
|
||||
import { auth, provider } from './config.js';
|
||||
import { signInWithPopup, signOut as firebaseSignOut, onAuthStateChanged, signInWithEmailAndPassword, createUserWithEmailAndPassword } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
|
||||
import {
|
||||
signInWithPopup,
|
||||
signOut as firebaseSignOut,
|
||||
onAuthStateChanged,
|
||||
signInWithEmailAndPassword,
|
||||
createUserWithEmailAndPassword,
|
||||
} from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js';
|
||||
import { syncManager } from './sync.js';
|
||||
|
||||
export class AuthManager {
|
||||
|
|
@ -16,12 +22,12 @@ export class AuthManager {
|
|||
this.unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||
this.user = user;
|
||||
this.updateUI(user);
|
||||
|
||||
|
||||
if (user) {
|
||||
console.log("User logged in:", user.uid);
|
||||
console.log('User logged in:', user.uid);
|
||||
syncManager.initialize(user);
|
||||
} else {
|
||||
console.log("User logged out");
|
||||
console.log('User logged out');
|
||||
syncManager.disconnect();
|
||||
}
|
||||
});
|
||||
|
|
@ -29,7 +35,7 @@ export class AuthManager {
|
|||
|
||||
async signInWithGoogle() {
|
||||
if (!auth) {
|
||||
alert("Firebase is not configured. Please check console.");
|
||||
alert('Firebase is not configured. Please check console.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +44,7 @@ export class AuthManager {
|
|||
// The onAuthStateChanged listener will handle the rest
|
||||
return result.user;
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
console.error('Login failed:', error);
|
||||
alert(`Login failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -46,14 +52,14 @@ export class AuthManager {
|
|||
|
||||
async signInWithEmail(email, password) {
|
||||
if (!auth) {
|
||||
alert("Firebase is not configured.");
|
||||
alert('Firebase is not configured.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await signInWithEmailAndPassword(auth, email, password);
|
||||
return result.user;
|
||||
} catch (error) {
|
||||
console.error("Email Login failed:", error);
|
||||
console.error('Email Login failed:', error);
|
||||
alert(`Login failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -61,14 +67,14 @@ export class AuthManager {
|
|||
|
||||
async signUpWithEmail(email, password) {
|
||||
if (!auth) {
|
||||
alert("Firebase is not configured.");
|
||||
alert('Firebase is not configured.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await createUserWithEmailAndPassword(auth, email, password);
|
||||
return result.user;
|
||||
} catch (error) {
|
||||
console.error("Sign Up failed:", error);
|
||||
console.error('Sign Up failed:', error);
|
||||
alert(`Sign Up failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -81,7 +87,7 @@ export class AuthManager {
|
|||
await firebaseSignOut(auth);
|
||||
// The onAuthStateChanged listener will handle the rest
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
console.error('Logout failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -99,18 +105,17 @@ export class AuthManager {
|
|||
connectBtn.textContent = 'Sign Out';
|
||||
connectBtn.classList.add('danger');
|
||||
connectBtn.onclick = () => this.signOut();
|
||||
|
||||
|
||||
if (clearDataBtn) clearDataBtn.style.display = 'block';
|
||||
if (emailContainer) emailContainer.style.display = 'none';
|
||||
if (emailToggleBtn) emailToggleBtn.style.display = 'none';
|
||||
|
||||
if (statusText) statusText.textContent = `Signed in as ${user.email}`;
|
||||
|
||||
} else {
|
||||
connectBtn.textContent = 'Connect with Google';
|
||||
connectBtn.classList.remove('danger');
|
||||
connectBtn.onclick = () => this.signInWithGoogle();
|
||||
|
||||
|
||||
if (clearDataBtn) clearDataBtn.style.display = 'none';
|
||||
if (emailToggleBtn) emailToggleBtn.style.display = 'inline-block';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// js/firebase/config.js
|
||||
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js";
|
||||
import { getAuth, GoogleAuthProvider } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
|
||||
import { getDatabase } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-database.js";
|
||||
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js';
|
||||
import { getAuth, GoogleAuthProvider } from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js';
|
||||
import { getDatabase } from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-database.js';
|
||||
|
||||
let app = null;
|
||||
let auth = null;
|
||||
|
|
@ -11,17 +11,14 @@ let provider = null;
|
|||
const STORAGE_KEY = 'monochrome-firebase-config';
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
apiKey: "AIzaSyDPU-unAjuLtQJt4IkGS5faG50UCF7lYyA",
|
||||
authDomain: "monochrome-database.firebaseapp.com",
|
||||
projectId: "monochrome-database",
|
||||
storageBucket: "monochrome-database.firebasestorage.app",
|
||||
messagingSenderId: "895657412760",
|
||||
appId: "1:895657412760:web:e81c5044c7f4e9b799e8ed"
|
||||
apiKey: 'AIzaSyDPU-unAjuLtQJt4IkGS5faG50UCF7lYyA',
|
||||
authDomain: 'monochrome-database.firebaseapp.com',
|
||||
projectId: 'monochrome-database',
|
||||
storageBucket: 'monochrome-database.firebasestorage.app',
|
||||
messagingSenderId: '895657412760',
|
||||
appId: '1:895657412760:web:e81c5044c7f4e9b799e8ed',
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
function getStoredConfig() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
|
|
@ -42,12 +39,12 @@ if (config) {
|
|||
auth = getAuth(app);
|
||||
database = getDatabase(app);
|
||||
provider = new GoogleAuthProvider();
|
||||
console.log("Firebase initialized from " + (storedConfig ? "saved" : "default") + " config");
|
||||
console.log('Firebase initialized from ' + (storedConfig ? 'saved' : 'default') + ' config');
|
||||
} catch (error) {
|
||||
console.error("Error initializing Firebase:", error);
|
||||
console.error('Error initializing Firebase:', error);
|
||||
}
|
||||
} else {
|
||||
console.log("No Firebase config found.");
|
||||
console.log('No Firebase config found.');
|
||||
}
|
||||
|
||||
export function saveFirebaseConfig(configObj) {
|
||||
|
|
@ -173,12 +170,15 @@ export function initializeFirebaseSettingsUI() {
|
|||
const config = JSON.parse(currentConfigStr);
|
||||
const link = generateShareLink(config);
|
||||
if (link) {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
alert('Magic Link copied to clipboard! Send it to your other device.');
|
||||
}).catch(err => {
|
||||
console.error('Clipboard error:', err);
|
||||
prompt('Copy this link:', link);
|
||||
});
|
||||
navigator.clipboard
|
||||
.writeText(link)
|
||||
.then(() => {
|
||||
alert('Magic Link copied to clipboard! Send it to your other device.');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Clipboard error:', err);
|
||||
prompt('Copy this link:', link);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Invalid configuration found.');
|
||||
|
|
@ -206,13 +206,13 @@ export function initializeFirebaseSettingsUI() {
|
|||
if (cleaned.endsWith(';')) {
|
||||
cleaned = cleaned.slice(0, -1);
|
||||
}
|
||||
|
||||
|
||||
// Convert JS Object format to JSON format
|
||||
const jsonReady = cleaned
|
||||
.replace(/([{,]\s*)([a-zA-Z0-9_]+)\s*:/g, '$1"$2":') // Wrap keys in double quotes
|
||||
.replace(/:\s*'([^']*)'/g, ': "$1"') // Replace single-quoted values with double quotes
|
||||
.replace(/,\s*([}\]])/g, '$1'); // Remove trailing commas
|
||||
|
||||
|
||||
const config = JSON.parse(jsonReady);
|
||||
saveFirebaseConfig(config);
|
||||
alert('Configuration saved. Reloading...');
|
||||
|
|
@ -227,7 +227,11 @@ export function initializeFirebaseSettingsUI() {
|
|||
// Clear Button
|
||||
if (clearFirebaseConfigBtn) {
|
||||
clearFirebaseConfigBtn.addEventListener('click', () => {
|
||||
if (confirm('Are you sure you want to remove the custom configuration? The app will revert to the shared default database.')) {
|
||||
if (
|
||||
confirm(
|
||||
'Are you sure you want to remove the custom configuration? The app will revert to the shared default database.'
|
||||
)
|
||||
) {
|
||||
clearFirebaseConfig();
|
||||
window.location.reload();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
// js/firebase/sync.js
|
||||
import { database } from './config.js';
|
||||
import { ref, get, set, update, onValue, off, child, remove, runTransaction } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-database.js";
|
||||
import {
|
||||
ref,
|
||||
get,
|
||||
set,
|
||||
update,
|
||||
onValue,
|
||||
off,
|
||||
child,
|
||||
remove,
|
||||
runTransaction,
|
||||
} from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-database.js';
|
||||
import { db } from '../db.js';
|
||||
|
||||
export class SyncManager {
|
||||
|
|
@ -15,28 +25,28 @@ export class SyncManager {
|
|||
if (!database || !user) return;
|
||||
this.user = user;
|
||||
this.userRef = ref(database, `users/${user.uid}`);
|
||||
|
||||
console.log("Initializing SyncManager for user:", user.uid);
|
||||
|
||||
console.log('Initializing SyncManager for user:', user.uid);
|
||||
this.performInitialSync();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.userRef) {
|
||||
// Remove listeners
|
||||
this.unsubscribeFunctions.forEach(unsub => unsub());
|
||||
this.unsubscribeFunctions.forEach((unsub) => unsub());
|
||||
this.unsubscribeFunctions = [];
|
||||
}
|
||||
this.user = null;
|
||||
this.userRef = null;
|
||||
console.log("SyncManager disconnected");
|
||||
console.log('SyncManager disconnected');
|
||||
}
|
||||
|
||||
async performInitialSync() {
|
||||
if (this.isSyncing) return;
|
||||
this.isSyncing = true;
|
||||
|
||||
|
||||
try {
|
||||
console.log("Starting initial sync...");
|
||||
console.log('Starting initial sync...');
|
||||
|
||||
// 1. Fetch Cloud Data
|
||||
const snapshot = await get(this.userRef);
|
||||
|
|
@ -49,7 +59,7 @@ export class SyncManager {
|
|||
const mergedData = this.mergeData(localData, cloudData);
|
||||
|
||||
// 4. Update Cloud (if different)
|
||||
// We optimize by just rewriting the whole node for simplicity in Phase 1,
|
||||
// We optimize by just rewriting the whole node for simplicity in Phase 1,
|
||||
// or we could diff. Rewriting is safer for "Initial Merge".
|
||||
await update(this.userRef, mergedData);
|
||||
|
||||
|
|
@ -61,18 +71,17 @@ export class SyncManager {
|
|||
favorites_artists: mergedData.library?.artists ? Object.values(mergedData.library.artists) : [],
|
||||
favorites_playlists: mergedData.library?.playlists ? Object.values(mergedData.library.playlists) : [],
|
||||
history_tracks: mergedData.history?.recentTracks ? Object.values(mergedData.history.recentTracks) : [],
|
||||
user_playlists: mergedData.user_playlists ? Object.values(mergedData.user_playlists) : []
|
||||
user_playlists: mergedData.user_playlists ? Object.values(mergedData.user_playlists) : [],
|
||||
};
|
||||
|
||||
await db.importData(importData, true);
|
||||
|
||||
console.log("Initial sync complete.");
|
||||
console.log('Initial sync complete.');
|
||||
|
||||
// 6. Setup Listeners for future changes
|
||||
this.setupListeners();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Initial sync failed:", error);
|
||||
console.error('Initial sync failed:', error);
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
|
|
@ -87,18 +96,18 @@ export class SyncManager {
|
|||
|
||||
// Add all local items
|
||||
if (Array.isArray(localItems)) {
|
||||
localItems.forEach(item => map.set(item[idKey], item));
|
||||
localItems.forEach((item) => map.set(item[idKey], item));
|
||||
} else if (localItems && typeof localItems === 'object') {
|
||||
// Handle case where cloud stores as object keys
|
||||
Object.values(localItems).forEach(item => map.set(item[idKey], item));
|
||||
Object.values(localItems).forEach((item) => map.set(item[idKey], item));
|
||||
}
|
||||
|
||||
// Add/Overwrite with cloud items (Union Strategy)
|
||||
if (cloudItems) {
|
||||
if (Array.isArray(cloudItems)) {
|
||||
cloudItems.forEach(item => map.set(item[idKey], item));
|
||||
cloudItems.forEach((item) => map.set(item[idKey], item));
|
||||
} else {
|
||||
Object.keys(cloudItems).forEach(key => {
|
||||
Object.keys(cloudItems).forEach((key) => {
|
||||
const val = cloudItems[key];
|
||||
if (typeof val === 'object') {
|
||||
map.set(val[idKey] || key, val);
|
||||
|
|
@ -115,14 +124,20 @@ export class SyncManager {
|
|||
tracks: this.arrayToObject(mergeStores(local.favorites_tracks, cloud.library?.tracks), 'id'),
|
||||
albums: this.arrayToObject(mergeStores(local.favorites_albums, cloud.library?.albums), 'id'),
|
||||
artists: this.arrayToObject(mergeStores(local.favorites_artists, cloud.library?.artists), 'id'),
|
||||
playlists: this.arrayToObject(mergeStores(local.favorites_playlists, cloud.library?.playlists, 'uuid'), 'uuid')
|
||||
playlists: this.arrayToObject(
|
||||
mergeStores(local.favorites_playlists, cloud.library?.playlists, 'uuid'),
|
||||
'uuid'
|
||||
),
|
||||
},
|
||||
history: {
|
||||
recentTracks: this.arrayToObject(mergeStores(local.history_tracks, cloud.history?.recentTracks, 'timestamp'), 'timestamp')
|
||||
recentTracks: this.arrayToObject(
|
||||
mergeStores(local.history_tracks, cloud.history?.recentTracks, 'timestamp'),
|
||||
'timestamp'
|
||||
),
|
||||
},
|
||||
user_playlists: this.arrayToObject(mergeStores(local.user_playlists, cloud.user_playlists), 'id'),
|
||||
// Settings are NOT synced (device specific)
|
||||
lastUpdated: Date.now()
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
// Transform back to local structure for db.importData
|
||||
|
|
@ -132,7 +147,7 @@ export class SyncManager {
|
|||
// Helper to convert array to object with keys
|
||||
arrayToObject(arr, keyField) {
|
||||
const obj = {};
|
||||
arr.forEach(item => {
|
||||
arr.forEach((item) => {
|
||||
if (item && item[keyField]) {
|
||||
obj[item[keyField]] = item;
|
||||
}
|
||||
|
|
@ -145,7 +160,7 @@ export class SyncManager {
|
|||
const libraryRef = child(this.userRef, 'library');
|
||||
|
||||
const unsubLibrary = onValue(libraryRef, (snapshot) => {
|
||||
if (this.isSyncing) return;
|
||||
if (this.isSyncing) return;
|
||||
|
||||
const val = snapshot.val();
|
||||
if (val) {
|
||||
|
|
@ -153,7 +168,7 @@ export class SyncManager {
|
|||
favorites_tracks: val.tracks ? Object.values(val.tracks) : [],
|
||||
favorites_albums: val.albums ? Object.values(val.albums) : [],
|
||||
favorites_artists: val.artists ? Object.values(val.artists) : [],
|
||||
favorites_playlists: val.playlists ? Object.values(val.playlists) : []
|
||||
favorites_playlists: val.playlists ? Object.values(val.playlists) : [],
|
||||
};
|
||||
db.importData(importData, true).then(() => {
|
||||
// Notify UI to refresh
|
||||
|
|
@ -161,7 +176,7 @@ export class SyncManager {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.unsubscribeFunctions.push(() => off(libraryRef, 'value', unsubLibrary));
|
||||
|
||||
// Listen for changes in history
|
||||
|
|
@ -173,7 +188,7 @@ export class SyncManager {
|
|||
const val = snapshot.val();
|
||||
if (val) {
|
||||
const importData = {
|
||||
history_tracks: Object.values(val)
|
||||
history_tracks: Object.values(val),
|
||||
};
|
||||
db.importData(importData, true).then(() => {
|
||||
// Notify UI to refresh
|
||||
|
|
@ -193,7 +208,7 @@ export class SyncManager {
|
|||
const val = snapshot.val();
|
||||
if (val) {
|
||||
const importData = {
|
||||
user_playlists: Object.values(val)
|
||||
user_playlists: Object.values(val),
|
||||
};
|
||||
db.importData(importData, true).then(() => {
|
||||
// Notify UI to refresh library
|
||||
|
|
@ -215,10 +230,10 @@ export class SyncManager {
|
|||
// isAdded: boolean
|
||||
|
||||
const categoryMap = {
|
||||
'track': 'tracks',
|
||||
'album': 'albums',
|
||||
'artist': 'artists',
|
||||
'playlist': 'playlists'
|
||||
track: 'tracks',
|
||||
album: 'albums',
|
||||
artist: 'artists',
|
||||
playlist: 'playlists',
|
||||
};
|
||||
const category = categoryMap[type];
|
||||
if (!category) return;
|
||||
|
|
@ -235,7 +250,7 @@ export class SyncManager {
|
|||
// we add it now. Ideally this matches local DB time, but a small diff is negligible.
|
||||
const entry = {
|
||||
...minified,
|
||||
addedAt: item.addedAt || minified.addedAt || Date.now()
|
||||
addedAt: item.addedAt || minified.addedAt || Date.now(),
|
||||
};
|
||||
await set(itemRef, entry);
|
||||
} else {
|
||||
|
|
@ -250,7 +265,7 @@ export class SyncManager {
|
|||
try {
|
||||
await set(itemRef, track);
|
||||
} catch (error) {
|
||||
console.error("Failed to sync history item:", error);
|
||||
console.error('Failed to sync history item:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,7 +285,7 @@ export class SyncManager {
|
|||
|
||||
async clearCloudData() {
|
||||
if (!this.user || !this.userRef) {
|
||||
throw new Error("Not authenticated");
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
await remove(this.userRef);
|
||||
}
|
||||
|
|
@ -278,12 +293,12 @@ export class SyncManager {
|
|||
// Public Playlist API
|
||||
|
||||
async publishPlaylist(playlist) {
|
||||
if (!this.user) throw new Error("Not authenticated");
|
||||
if (!this.user) throw new Error('Not authenticated');
|
||||
|
||||
const minified = db._minifyItem('playlist', playlist);
|
||||
const playlistId = playlist.id || playlist.uuid;
|
||||
|
||||
if (!playlistId) throw new Error("Invalid playlist ID");
|
||||
if (!playlistId) throw new Error('Invalid playlist ID');
|
||||
|
||||
// Ensure playlist has necessary data
|
||||
const publicData = {
|
||||
|
|
@ -291,7 +306,7 @@ export class SyncManager {
|
|||
uid: this.user.uid,
|
||||
originalId: playlistId,
|
||||
publishedAt: Date.now(),
|
||||
tracks: playlist.tracks ? playlist.tracks.map(t => db._minifyItem('track', t)) : []
|
||||
tracks: playlist.tracks ? playlist.tracks.map((t) => db._minifyItem('track', t)) : [],
|
||||
};
|
||||
|
||||
// Use a global 'public_playlists' node
|
||||
|
|
@ -300,14 +315,14 @@ export class SyncManager {
|
|||
}
|
||||
|
||||
async unpublishPlaylist(playlistId) {
|
||||
if (!this.user) throw new Error("Not authenticated");
|
||||
if (!this.user) throw new Error('Not authenticated');
|
||||
const publicRef = ref(database, `public_playlists/${playlistId}`);
|
||||
await remove(publicRef);
|
||||
}
|
||||
|
||||
async getPublicPlaylist(playlistId) {
|
||||
if (!database) {
|
||||
console.warn("[Sync] Database not initialized, cannot fetch public playlist");
|
||||
console.warn('[Sync] Database not initialized, cannot fetch public playlist');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
|
|
@ -321,7 +336,7 @@ export class SyncManager {
|
|||
console.log(`[Sync] Public playlist fetch for ${playlistId}: Found`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("[Sync] Failed to fetch public playlist:", error);
|
||||
console.error('[Sync] Failed to fetch public playlist:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
js/lastfm.js
33
js/lastfm.js
|
|
@ -33,10 +33,13 @@ export class LastFMScrobbler {
|
|||
saveSession(sessionKey, username) {
|
||||
this.sessionKey = sessionKey;
|
||||
this.username = username;
|
||||
localStorage.setItem('lastfm-session', JSON.stringify({
|
||||
key: sessionKey,
|
||||
name: username
|
||||
}));
|
||||
localStorage.setItem(
|
||||
'lastfm-session',
|
||||
JSON.stringify({
|
||||
key: sessionKey,
|
||||
name: username,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clearSession() {
|
||||
|
|
@ -56,9 +59,7 @@ export class LastFMScrobbler {
|
|||
|
||||
const sortedKeys = Object.keys(filteredParams).sort();
|
||||
|
||||
const signatureString = sortedKeys
|
||||
.map(key => `${key}${filteredParams[key]}`)
|
||||
.join('') + this.API_SECRET;
|
||||
const signatureString = sortedKeys.map((key) => `${key}${filteredParams[key]}`).join('') + this.API_SECRET;
|
||||
|
||||
console.log('Signature string:', signatureString);
|
||||
|
||||
|
|
@ -75,7 +76,7 @@ export class LastFMScrobbler {
|
|||
const requestParams = {
|
||||
method,
|
||||
api_key: this.API_KEY,
|
||||
...params
|
||||
...params,
|
||||
};
|
||||
|
||||
if (requiresAuth && this.sessionKey) {
|
||||
|
|
@ -87,7 +88,7 @@ export class LastFMScrobbler {
|
|||
const formData = new URLSearchParams({
|
||||
...requestParams,
|
||||
api_sig: signature,
|
||||
format: 'json'
|
||||
format: 'json',
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -96,7 +97,7 @@ export class LastFMScrobbler {
|
|||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
|
@ -119,7 +120,7 @@ export class LastFMScrobbler {
|
|||
|
||||
return {
|
||||
token,
|
||||
url: `https://www.last.fm/api/auth/?api_key=${this.API_KEY}&token=${token}`
|
||||
url: `https://www.last.fm/api/auth/?api_key=${this.API_KEY}&token=${token}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get auth URL:', error);
|
||||
|
|
@ -135,7 +136,7 @@ export class LastFMScrobbler {
|
|||
this.saveSession(data.session.key, data.session.name);
|
||||
return {
|
||||
success: true,
|
||||
username: data.session.name
|
||||
username: data.session.name,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +157,7 @@ export class LastFMScrobbler {
|
|||
try {
|
||||
const params = {
|
||||
artist: track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist',
|
||||
track: track.title
|
||||
track: track.title,
|
||||
};
|
||||
|
||||
if (track.album?.title) {
|
||||
|
|
@ -177,7 +178,6 @@ export class LastFMScrobbler {
|
|||
|
||||
this.scrobbleThreshold = Math.min(track.duration / 2, 240);
|
||||
this.scheduleScrobble(this.scrobbleThreshold * 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update now playing:', error);
|
||||
}
|
||||
|
|
@ -207,7 +207,7 @@ export class LastFMScrobbler {
|
|||
const params = {
|
||||
artist: this.currentTrack.artist?.name || this.currentTrack.artists?.[0]?.name || 'Unknown Artist',
|
||||
track: this.currentTrack.title,
|
||||
timestamp: timestamp
|
||||
timestamp: timestamp,
|
||||
};
|
||||
|
||||
if (this.currentTrack.album?.title) {
|
||||
|
|
@ -226,7 +226,6 @@ export class LastFMScrobbler {
|
|||
|
||||
this.hasScrobbled = true;
|
||||
console.log('Scrobbled:', this.currentTrack.title);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to scrobble:', error);
|
||||
}
|
||||
|
|
@ -238,7 +237,7 @@ export class LastFMScrobbler {
|
|||
try {
|
||||
const params = {
|
||||
artist: track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist',
|
||||
track: track.title
|
||||
track: track.title,
|
||||
};
|
||||
|
||||
await this.makeRequest('track.love', params, true);
|
||||
|
|
|
|||
1392
js/lyrics.js
1392
js/lyrics.js
File diff suppressed because it is too large
Load diff
452
js/metadata.js
452
js/metadata.js
File diff suppressed because it is too large
Load diff
98
js/player.js
98
js/player.js
|
|
@ -45,7 +45,7 @@ export class Player {
|
|||
|
||||
if (mode !== 'off' && this.currentRgValues) {
|
||||
const { trackReplayGain, trackPeakAmplitude, albumReplayGain, albumPeakAmplitude } = this.currentRgValues;
|
||||
|
||||
|
||||
if (mode === 'album' && albumReplayGain !== undefined) {
|
||||
gainDb = albumReplayGain;
|
||||
peak = albumPeakAmplitude || 1.0;
|
||||
|
|
@ -53,7 +53,7 @@ export class Player {
|
|||
gainDb = trackReplayGain;
|
||||
peak = trackPeakAmplitude || 1.0;
|
||||
}
|
||||
|
||||
|
||||
// Apply Pre-Amp
|
||||
gainDb += replayGainSettings.getPreamp();
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ export class Player {
|
|||
|
||||
// Calculate effective volume
|
||||
const effectiveVolume = this.userVolume * scale;
|
||||
|
||||
|
||||
// Apply to audio element
|
||||
this.audio.volume = Math.max(0, Math.min(1, effectiveVolume));
|
||||
}
|
||||
|
|
@ -82,17 +82,17 @@ export class Player {
|
|||
this.currentQueueIndex = savedState.currentQueueIndex ?? -1;
|
||||
this.shuffleActive = savedState.shuffleActive || false;
|
||||
this.repeatMode = savedState.repeatMode || REPEAT_MODE.OFF;
|
||||
|
||||
|
||||
// Restore current track if queue exists and index is valid
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
if (this.currentQueueIndex >= 0 && this.currentQueueIndex < currentQueue.length) {
|
||||
this.currentTrack = currentQueue[this.currentQueueIndex];
|
||||
|
||||
|
||||
// Restore UI
|
||||
const track = this.currentTrack;
|
||||
const trackTitle = getTrackTitle(track);
|
||||
const trackArtistsHTML = getTrackArtistsHTML(track);
|
||||
|
||||
|
||||
let yearDisplay = '';
|
||||
const releaseDate = track.album?.releaseDate || track.streamStartDate;
|
||||
if (releaseDate) {
|
||||
|
|
@ -112,11 +112,11 @@ export class Player {
|
|||
|
||||
const mixBtn = document.getElementById('now-playing-mix-btn');
|
||||
if (mixBtn) {
|
||||
mixBtn.style.display = (track.mixes && track.mixes.TRACK_MIX) ? 'flex' : 'none';
|
||||
mixBtn.style.display = track.mixes && track.mixes.TRACK_MIX ? 'flex' : 'none';
|
||||
}
|
||||
const totalDurationEl = document.getElementById('total-duration');
|
||||
if (totalDurationEl) totalDurationEl.textContent = formatTime(track.duration);
|
||||
document.title = `${trackTitle} • ${getTrackArtists(track)}`;
|
||||
document.title = `${trackTitle} • ${getTrackArtists(track)}`;
|
||||
|
||||
this.updatePlayingTrackIndicator();
|
||||
this.updateMediaSession(track);
|
||||
|
|
@ -131,7 +131,7 @@ export class Player {
|
|||
originalQueueBeforeShuffle: this.originalQueueBeforeShuffle,
|
||||
currentQueueIndex: this.currentQueueIndex,
|
||||
shuffleActive: this.shuffleActive,
|
||||
repeatMode: this.repeatMode
|
||||
repeatMode: this.repeatMode,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -230,7 +230,7 @@ export class Player {
|
|||
|
||||
const trackTitle = getTrackTitle(track);
|
||||
const trackArtistsHTML = getTrackArtistsHTML(track);
|
||||
|
||||
|
||||
let yearDisplay = '';
|
||||
const releaseDate = track.album?.releaseDate || track.streamStartDate;
|
||||
if (releaseDate) {
|
||||
|
|
@ -240,14 +240,13 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
document.querySelector('.now-playing-bar .cover').src =
|
||||
this.api.getCoverUrl(track.album?.cover);
|
||||
document.querySelector('.now-playing-bar .cover').src = this.api.getCoverUrl(track.album?.cover);
|
||||
document.querySelector('.now-playing-bar .title').textContent = trackTitle;
|
||||
document.querySelector('.now-playing-bar .artist').innerHTML = trackArtistsHTML + yearDisplay;
|
||||
|
||||
|
||||
const mixBtn = document.getElementById('now-playing-mix-btn');
|
||||
if (mixBtn) {
|
||||
mixBtn.style.display = (track.mixes && track.mixes.TRACK_MIX) ? 'flex' : 'none';
|
||||
mixBtn.style.display = track.mixes && track.mixes.TRACK_MIX ? 'flex' : 'none';
|
||||
}
|
||||
document.title = `${trackTitle} • ${getTrackArtists(track)}`;
|
||||
|
||||
|
|
@ -262,7 +261,7 @@ export class Player {
|
|||
trackReplayGain: trackData.info.trackReplayGain,
|
||||
trackPeakAmplitude: trackData.info.trackPeakAmplitude,
|
||||
albumReplayGain: trackData.info.albumReplayGain,
|
||||
albumPeakAmplitude: trackData.info.albumPeakAmplitude
|
||||
albumPeakAmplitude: trackData.info.albumPeakAmplitude,
|
||||
};
|
||||
} else {
|
||||
this.currentRgValues = null;
|
||||
|
|
@ -344,9 +343,9 @@ export class Player {
|
|||
}
|
||||
|
||||
if (this.audio.paused) {
|
||||
this.audio.play().catch(e => {
|
||||
this.audio.play().catch((e) => {
|
||||
if (e.name === 'NotAllowedError' || e.name === 'AbortError') return;
|
||||
console.error("Play failed, reloading track:", e);
|
||||
console.error('Play failed, reloading track:', e);
|
||||
if (this.currentTrack) {
|
||||
this.playTrackFromQueue();
|
||||
}
|
||||
|
|
@ -377,7 +376,7 @@ export class Player {
|
|||
this.originalQueueBeforeShuffle = [...this.queue];
|
||||
const currentTrack = this.queue[this.currentQueueIndex];
|
||||
this.shuffledQueue = [...this.queue].sort(() => Math.random() - 0.5);
|
||||
this.currentQueueIndex = this.shuffledQueue.findIndex(t => t.id === currentTrack?.id);
|
||||
this.currentQueueIndex = this.shuffledQueue.findIndex((t) => t.id === currentTrack?.id);
|
||||
|
||||
if (this.currentQueueIndex === -1 && currentTrack) {
|
||||
this.shuffledQueue.unshift(currentTrack);
|
||||
|
|
@ -386,7 +385,7 @@ export class Player {
|
|||
} else {
|
||||
const currentTrack = this.shuffledQueue[this.currentQueueIndex];
|
||||
this.queue = [...this.originalQueueBeforeShuffle];
|
||||
this.currentQueueIndex = this.queue.findIndex(t => t.id === currentTrack?.id);
|
||||
this.currentQueueIndex = this.queue.findIndex((t) => t.id === currentTrack?.id);
|
||||
}
|
||||
|
||||
this.preloadCache.clear();
|
||||
|
|
@ -421,17 +420,17 @@ export class Player {
|
|||
addNextToQueue(track) {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
const insertIndex = this.currentQueueIndex + 1;
|
||||
|
||||
|
||||
// Insert after current track
|
||||
currentQueue.splice(insertIndex, 0, track);
|
||||
|
||||
// If we are shuffling, we might want to also add it to the original queue for consistency,
|
||||
|
||||
// If we are shuffling, we might want to also add it to the original queue for consistency,
|
||||
// though syncing that is tricky. The standard logic often just appends to the active queue view.
|
||||
if (this.shuffleActive) {
|
||||
this.originalQueueBeforeShuffle.push(track); // Just append to end of main list? Or logic needed.
|
||||
// Simplest is to just modify the active playing queue.
|
||||
this.originalQueueBeforeShuffle.push(track); // Just append to end of main list? Or logic needed.
|
||||
// Simplest is to just modify the active playing queue.
|
||||
} else {
|
||||
// In linear mode, `currentQueue` IS `this.queue`
|
||||
// In linear mode, `currentQueue` IS `this.queue`
|
||||
}
|
||||
|
||||
this.saveQueueState();
|
||||
|
|
@ -440,14 +439,14 @@ export class Player {
|
|||
|
||||
removeFromQueue(index) {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
|
||||
|
||||
// If removing current track
|
||||
if (index === this.currentQueueIndex) {
|
||||
// If playing, we might want to stop or just let it finish?
|
||||
// For now, let's just remove it.
|
||||
// For now, let's just remove it.
|
||||
// If it's the last track, playback will stop naturally or we handle it?
|
||||
}
|
||||
|
||||
|
||||
if (index < this.currentQueueIndex) {
|
||||
this.currentQueueIndex--;
|
||||
}
|
||||
|
|
@ -456,12 +455,12 @@ export class Player {
|
|||
|
||||
if (this.shuffleActive) {
|
||||
// Also remove from original queue
|
||||
const originalIndex = this.originalQueueBeforeShuffle.findIndex(t => t.id === removedTrack.id); // Simple ID check
|
||||
if (originalIndex !== -1) {
|
||||
this.originalQueueBeforeShuffle.splice(originalIndex, 1);
|
||||
}
|
||||
const originalIndex = this.originalQueueBeforeShuffle.findIndex((t) => t.id === removedTrack.id); // Simple ID check
|
||||
if (originalIndex !== -1) {
|
||||
this.originalQueueBeforeShuffle.splice(originalIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.saveQueueState();
|
||||
this.preloadNextTracks();
|
||||
}
|
||||
|
|
@ -513,13 +512,11 @@ export class Player {
|
|||
|
||||
updatePlayingTrackIndicator() {
|
||||
const currentTrack = this.getCurrentQueue()[this.currentQueueIndex];
|
||||
document.querySelectorAll('.track-item').forEach(item => {
|
||||
item.classList.toggle('playing',
|
||||
currentTrack && item.dataset.trackId == currentTrack.id
|
||||
);
|
||||
document.querySelectorAll('.track-item').forEach((item) => {
|
||||
item.classList.toggle('playing', currentTrack && item.dataset.trackId == currentTrack.id);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.queue-track-item').forEach(item => {
|
||||
document.querySelectorAll('.queue-track-item').forEach((item) => {
|
||||
const index = parseInt(item.dataset.queueIndex);
|
||||
item.classList.toggle('playing', index === this.currentQueueIndex);
|
||||
});
|
||||
|
|
@ -537,11 +534,11 @@ export class Player {
|
|||
const trackTitle = getTrackTitle(track);
|
||||
|
||||
if (coverId) {
|
||||
sizes.forEach(size => {
|
||||
sizes.forEach((size) => {
|
||||
artwork.push({
|
||||
src: this.api.getCoverUrl(coverId, size),
|
||||
sizes: `${size}x${size}`,
|
||||
type: 'image/jpeg'
|
||||
type: 'image/jpeg',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -550,7 +547,7 @@ export class Player {
|
|||
title: trackTitle || 'Unknown Title',
|
||||
artist: getTrackArtists(track) || 'Unknown Artist',
|
||||
album: track.album?.title || 'Unknown Album',
|
||||
artwork: artwork.length > 0 ? artwork : undefined
|
||||
artwork: artwork.length > 0 ? artwork : undefined,
|
||||
});
|
||||
|
||||
this.updateMediaSessionPlaybackState();
|
||||
|
|
@ -576,7 +573,7 @@ export class Player {
|
|||
navigator.mediaSession.setPositionState({
|
||||
duration: duration,
|
||||
playbackRate: this.audio.playbackRate || 1,
|
||||
position: Math.min(this.audio.currentTime, duration)
|
||||
position: Math.min(this.audio.currentTime, duration),
|
||||
});
|
||||
} catch (error) {
|
||||
console.debug('Failed to update Media Session position:', error);
|
||||
|
|
@ -587,13 +584,16 @@ export class Player {
|
|||
setSleepTimer(minutes) {
|
||||
this.clearSleepTimer(); // Clear any existing timer
|
||||
|
||||
this.sleepTimerEndTime = Date.now() + (minutes * 60 * 1000);
|
||||
this.sleepTimerEndTime = Date.now() + minutes * 60 * 1000;
|
||||
|
||||
this.sleepTimer = setTimeout(() => {
|
||||
this.audio.pause();
|
||||
this.clearSleepTimer();
|
||||
this.updateSleepTimerUI();
|
||||
}, minutes * 60 * 1000);
|
||||
this.sleepTimer = setTimeout(
|
||||
() => {
|
||||
this.audio.pause();
|
||||
this.clearSleepTimer();
|
||||
this.updateSleepTimerUI();
|
||||
},
|
||||
minutes * 60 * 1000
|
||||
);
|
||||
|
||||
// Update UI every second
|
||||
this.sleepTimerInterval = setInterval(() => {
|
||||
|
|
@ -629,7 +629,7 @@ export class Player {
|
|||
updateSleepTimerUI() {
|
||||
const timerBtn = document.getElementById('sleep-timer-btn');
|
||||
const timerBtnDesktop = document.getElementById('sleep-timer-btn-desktop');
|
||||
|
||||
|
||||
const updateBtn = (btn) => {
|
||||
if (!btn) return;
|
||||
if (this.isSleepTimerActive()) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getTrackArtists } from './utils.js';
|
|||
|
||||
export function createRouter(ui) {
|
||||
const router = () => {
|
||||
const path = window.location.hash.substring(1) || "home";
|
||||
const path = window.location.hash.substring(1) || 'home';
|
||||
const [page, param] = path.split('/');
|
||||
|
||||
switch (page) {
|
||||
|
|
|
|||
966
js/settings.js
966
js/settings.js
File diff suppressed because it is too large
Load diff
|
|
@ -16,7 +16,7 @@ export class SidePanelManager {
|
|||
|
||||
this.currentView = view;
|
||||
this.titleElement.textContent = title;
|
||||
|
||||
|
||||
// Clear previous content
|
||||
this.controlsElement.innerHTML = '';
|
||||
this.contentElement.innerHTML = '';
|
||||
|
|
|
|||
|
|
@ -1,57 +1,57 @@
|
|||
//js/smooth-scrolling.js
|
||||
import { smoothScrollingSettings } from "./storage.js";
|
||||
import { smoothScrollingSettings } from './storage.js';
|
||||
|
||||
let lenis = null;
|
||||
|
||||
function initializeSmoothScrolling() {
|
||||
if (lenis) return; // Already initialized
|
||||
if (lenis) return; // Already initialized
|
||||
|
||||
lenis = new Lenis({
|
||||
wrapper: document.querySelector('.main-content'),
|
||||
content: document.querySelector('.main-content'),
|
||||
lerp: 0.1,
|
||||
smoothWheel: true,
|
||||
smoothTouch: false,
|
||||
normalizeWheel: true,
|
||||
wheelMultiplier: 0.8,
|
||||
});
|
||||
lenis = new Lenis({
|
||||
wrapper: document.querySelector('.main-content'),
|
||||
content: document.querySelector('.main-content'),
|
||||
lerp: 0.1,
|
||||
smoothWheel: true,
|
||||
smoothTouch: false,
|
||||
normalizeWheel: true,
|
||||
wheelMultiplier: 0.8,
|
||||
});
|
||||
|
||||
function raf(time) {
|
||||
lenis.raf(time);
|
||||
requestAnimationFrame(raf);
|
||||
}
|
||||
|
||||
function raf(time) {
|
||||
lenis.raf(time);
|
||||
requestAnimationFrame(raf);
|
||||
}
|
||||
|
||||
requestAnimationFrame(raf);
|
||||
}
|
||||
|
||||
function destroySmoothScrolling() {
|
||||
if (lenis) {
|
||||
lenis.destroy();
|
||||
lenis = null;
|
||||
}
|
||||
if (lenis) {
|
||||
lenis.destroy();
|
||||
lenis = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupSmoothScrolling() {
|
||||
// Check if smooth scrolling is enabled
|
||||
const smoothScrollingEnabled = smoothScrollingSettings.isEnabled();
|
||||
// Check if smooth scrolling is enabled
|
||||
const smoothScrollingEnabled = smoothScrollingSettings.isEnabled();
|
||||
|
||||
if (smoothScrollingEnabled) {
|
||||
initializeSmoothScrolling();
|
||||
}
|
||||
|
||||
// Listen for toggle changes
|
||||
window.addEventListener('smooth-scrolling-toggle', function(e) {
|
||||
if (e.detail.enabled) {
|
||||
initializeSmoothScrolling();
|
||||
} else {
|
||||
destroySmoothScrolling();
|
||||
if (smoothScrollingEnabled) {
|
||||
initializeSmoothScrolling();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for toggle changes
|
||||
window.addEventListener('smooth-scrolling-toggle', function (e) {
|
||||
if (e.detail.enabled) {
|
||||
initializeSmoothScrolling();
|
||||
} else {
|
||||
destroySmoothScrolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupSmoothScrolling);
|
||||
document.addEventListener('DOMContentLoaded', setupSmoothScrolling);
|
||||
} else {
|
||||
setupSmoothScrolling();
|
||||
setupSmoothScrolling();
|
||||
}
|
||||
|
|
|
|||
498
js/storage.js
498
js/storage.js
|
|
@ -1,250 +1,249 @@
|
|||
//storage.js
|
||||
export const apiSettings = {
|
||||
STORAGE_KEY: 'monochrome-api-instances-v2',
|
||||
INSTANCES_URL: "instances.json",
|
||||
INSTANCES_URL: 'instances.json',
|
||||
SPEED_TEST_CACHE_KEY: 'monochrome-instance-speeds',
|
||||
SPEED_TEST_CACHE_DURATION: 1000 * 60 * 60,
|
||||
defaultInstances: { api: [], streaming: [] },
|
||||
instancesLoaded: false,
|
||||
|
||||
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();
|
||||
|
||||
let groupedInstances = { api: [], streaming: [] };
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
// Legacy array format
|
||||
groupedInstances.api = [...data];
|
||||
groupedInstances.streaming = [...data];
|
||||
} else {
|
||||
// New object format or legacy object format
|
||||
if (data.api && Array.isArray(data.api)) {
|
||||
const isSimpleArray = data.api.length > 0 && typeof data.api[0] === 'string';
|
||||
if (isSimpleArray) {
|
||||
groupedInstances.api = [...data.api];
|
||||
} else {
|
||||
for (const [provider, config] of Object.entries(data.api)) {
|
||||
if (config.cors === false && Array.isArray(config.urls)) {
|
||||
groupedInstances.api.push(...config.urls);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.streaming && Array.isArray(data.streaming)) {
|
||||
groupedInstances.streaming = [...data.streaming];
|
||||
} else if (groupedInstances.api.length > 0) {
|
||||
groupedInstances.streaming = [...groupedInstances.api];
|
||||
}
|
||||
}
|
||||
|
||||
this.defaultInstances = groupedInstances;
|
||||
this.instancesLoaded = true;
|
||||
|
||||
return groupedInstances;
|
||||
} catch (error) {
|
||||
console.error('Failed to load instances from GitHub:', error);
|
||||
this.defaultInstances = {
|
||||
api: [
|
||||
"https://tidal-api.binimum.org",
|
||||
"https://monochrome-api.samidy.com"
|
||||
],
|
||||
streaming: [
|
||||
"https://triton.squid.wtf",
|
||||
"https://wolf.qqdl.site",
|
||||
"https://maus.qqdl.site",
|
||||
"https://vogel.qqdl.site",
|
||||
"https://katze.qqdl.site",
|
||||
"https://hund.qqdl.site",
|
||||
"https://tidal.kinoplus.online",
|
||||
"https://tidal-api.binimum.org"
|
||||
]
|
||||
};
|
||||
this.instancesLoaded = true;
|
||||
return this.defaultInstances;
|
||||
}
|
||||
},
|
||||
|
||||
async speedTestInstance(url, type = 'api') {
|
||||
let testUrl;
|
||||
// API instances might not support /track/ endpoint (which checks for streamability)
|
||||
// So we test API instances with a lightweight metadata endpoint
|
||||
if (type === 'streaming') {
|
||||
testUrl = url.endsWith('/')
|
||||
? `${url}track/?id=204567804&quality=HIGH`
|
||||
: `${url}/track/?id=204567804&quality=HIGH`;
|
||||
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();
|
||||
|
||||
let groupedInstances = { api: [], streaming: [] };
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
// Legacy array format
|
||||
groupedInstances.api = [...data];
|
||||
groupedInstances.streaming = [...data];
|
||||
} else {
|
||||
testUrl = url.endsWith('/')
|
||||
? `${url}artist/?id=3532302` // Daft Punk
|
||||
: `${url}/artist/?id=3532302`;
|
||||
}
|
||||
|
||||
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, type, speed: Infinity, error: `HTTP ${response.status}` };
|
||||
// New object format or legacy object format
|
||||
if (data.api && Array.isArray(data.api)) {
|
||||
const isSimpleArray = data.api.length > 0 && typeof data.api[0] === 'string';
|
||||
if (isSimpleArray) {
|
||||
groupedInstances.api = [...data.api];
|
||||
} else {
|
||||
for (const [provider, config] of Object.entries(data.api)) {
|
||||
if (config.cors === false && Array.isArray(config.urls)) {
|
||||
groupedInstances.api.push(...config.urls);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const speed = endTime - startTime;
|
||||
|
||||
return { url, type, speed, error: null };
|
||||
} catch (error) {
|
||||
return { url, type, speed: Infinity, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
getCachedSpeedTests() {
|
||||
try {
|
||||
const cached = localStorage.getItem(this.SPEED_TEST_CACHE_KEY);
|
||||
if (!cached) return { speeds: {}, timestamp: Date.now() };
|
||||
|
||||
const data = JSON.parse(cached);
|
||||
|
||||
if (Date.now() - data.timestamp > this.SPEED_TEST_CACHE_DURATION) {
|
||||
return { speeds: {}, timestamp: Date.now() };
|
||||
|
||||
if (data.streaming && Array.isArray(data.streaming)) {
|
||||
groupedInstances.streaming = [...data.streaming];
|
||||
} else if (groupedInstances.api.length > 0) {
|
||||
groupedInstances.streaming = [...groupedInstances.api];
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
this.defaultInstances = groupedInstances;
|
||||
this.instancesLoaded = true;
|
||||
|
||||
return groupedInstances;
|
||||
} catch (error) {
|
||||
console.error('Failed to load instances from GitHub:', error);
|
||||
this.defaultInstances = {
|
||||
api: ['https://tidal-api.binimum.org', 'https://monochrome-api.samidy.com'],
|
||||
streaming: [
|
||||
'https://triton.squid.wtf',
|
||||
'https://wolf.qqdl.site',
|
||||
'https://maus.qqdl.site',
|
||||
'https://vogel.qqdl.site',
|
||||
'https://katze.qqdl.site',
|
||||
'https://hund.qqdl.site',
|
||||
'https://tidal.kinoplus.online',
|
||||
'https://tidal-api.binimum.org',
|
||||
],
|
||||
};
|
||||
this.instancesLoaded = true;
|
||||
return this.defaultInstances;
|
||||
}
|
||||
},
|
||||
|
||||
async speedTestInstance(url, type = 'api') {
|
||||
let testUrl;
|
||||
// API instances might not support /track/ endpoint (which checks for streamability)
|
||||
// So we test API instances with a lightweight metadata endpoint
|
||||
if (type === 'streaming') {
|
||||
testUrl = url.endsWith('/')
|
||||
? `${url}track/?id=204567804&quality=HIGH`
|
||||
: `${url}/track/?id=204567804&quality=HIGH`;
|
||||
} else {
|
||||
testUrl = url.endsWith('/')
|
||||
? `${url}artist/?id=3532302` // Daft Punk
|
||||
: `${url}/artist/?id=3532302`;
|
||||
}
|
||||
|
||||
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, type, speed: Infinity, error: `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const speed = endTime - startTime;
|
||||
|
||||
return { url, type, speed, error: null };
|
||||
} catch (error) {
|
||||
return { url, type, speed: Infinity, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
getCachedSpeedTests() {
|
||||
try {
|
||||
const cached = localStorage.getItem(this.SPEED_TEST_CACHE_KEY);
|
||||
if (!cached) return { speeds: {}, timestamp: Date.now() };
|
||||
|
||||
const data = JSON.parse(cached);
|
||||
|
||||
if (Date.now() - data.timestamp > this.SPEED_TEST_CACHE_DURATION) {
|
||||
return { speeds: {}, timestamp: Date.now() };
|
||||
}
|
||||
},
|
||||
|
||||
updateSpeedCache(newResults) {
|
||||
const currentCache = this.getCachedSpeedTests();
|
||||
|
||||
newResults.forEach(r => {
|
||||
// Use distinct keys for streaming tests to avoid overwriting API tests for same URL
|
||||
// API tests use raw URL as key (for backward compatibility with UI)
|
||||
const key = r.type === 'streaming' ? `${r.url}#streaming` : r.url;
|
||||
currentCache.speeds[key] = { speed: r.speed, error: r.error };
|
||||
});
|
||||
|
||||
currentCache.timestamp = Date.now();
|
||||
|
||||
try {
|
||||
localStorage.setItem(this.SPEED_TEST_CACHE_KEY, JSON.stringify(currentCache));
|
||||
} catch (e) {
|
||||
console.warn('[SpeedTest] Failed to cache results');
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
return { speeds: {}, timestamp: Date.now() };
|
||||
}
|
||||
},
|
||||
|
||||
updateSpeedCache(newResults) {
|
||||
const currentCache = this.getCachedSpeedTests();
|
||||
|
||||
newResults.forEach((r) => {
|
||||
// Use distinct keys for streaming tests to avoid overwriting API tests for same URL
|
||||
// API tests use raw URL as key (for backward compatibility with UI)
|
||||
const key = r.type === 'streaming' ? `${r.url}#streaming` : r.url;
|
||||
currentCache.speeds[key] = { speed: r.speed, error: r.error };
|
||||
});
|
||||
|
||||
currentCache.timestamp = Date.now();
|
||||
|
||||
try {
|
||||
localStorage.setItem(this.SPEED_TEST_CACHE_KEY, JSON.stringify(currentCache));
|
||||
} catch (e) {
|
||||
console.warn('[SpeedTest] Failed to cache results');
|
||||
}
|
||||
|
||||
return currentCache;
|
||||
},
|
||||
|
||||
async testSpecificUrls(urls, type) {
|
||||
if (!urls || urls.length === 0) return [];
|
||||
console.log(`[SpeedTest] Testing ${urls.length} instances for ${type}...`);
|
||||
|
||||
const results = await Promise.all(urls.map((url) => this.speedTestInstance(url, type)));
|
||||
|
||||
const validResults = results.filter((r) => r.speed !== Infinity);
|
||||
console.log(
|
||||
`[SpeedTest] ${type} Results:`,
|
||||
validResults.map((r) => `${r.url}: ${r.speed.toFixed(0)}ms`)
|
||||
);
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async getInstances(type = 'api') {
|
||||
let instancesObj;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (stored) {
|
||||
instancesObj = JSON.parse(stored);
|
||||
}
|
||||
|
||||
return currentCache;
|
||||
},
|
||||
|
||||
async testSpecificUrls(urls, type) {
|
||||
if (!urls || urls.length === 0) return [];
|
||||
console.log(`[SpeedTest] Testing ${urls.length} instances for ${type}...`);
|
||||
|
||||
const results = await Promise.all(
|
||||
urls.map(url => this.speedTestInstance(url, type))
|
||||
);
|
||||
|
||||
const validResults = results.filter(r => r.speed !== Infinity);
|
||||
console.log(`[SpeedTest] ${type} Results:`, validResults.map(r => `${r.url}: ${r.speed.toFixed(0)}ms`));
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async getInstances(type = 'api') {
|
||||
let instancesObj;
|
||||
|
||||
} catch (e) {}
|
||||
|
||||
if (!instancesObj) {
|
||||
instancesObj = await this.loadInstancesFromGitHub();
|
||||
}
|
||||
|
||||
const targetUrls = instancesObj[type] || instancesObj.api || [];
|
||||
if (targetUrls.length === 0) return [];
|
||||
|
||||
const speedCache = this.getCachedSpeedTests();
|
||||
// Construct cache key based on type
|
||||
const getCacheKey = (u) => (type === 'streaming' ? `${u}#streaming` : u);
|
||||
|
||||
const urlsToTest = targetUrls.filter((url) => !speedCache.speeds[getCacheKey(url)]);
|
||||
|
||||
if (urlsToTest.length > 0) {
|
||||
const results = await this.testSpecificUrls(urlsToTest, type);
|
||||
this.updateSpeedCache(results);
|
||||
Object.assign(speedCache, this.getCachedSpeedTests());
|
||||
}
|
||||
|
||||
const sortList = (list) => {
|
||||
return [...list].sort((a, b) => {
|
||||
const speedA = speedCache.speeds[getCacheKey(a)]?.speed ?? Infinity;
|
||||
const speedB = speedCache.speeds[getCacheKey(b)]?.speed ?? Infinity;
|
||||
return speedA - speedB;
|
||||
});
|
||||
};
|
||||
|
||||
const sortedList = sortList(targetUrls);
|
||||
|
||||
// Persist the sorted order
|
||||
instancesObj[type] = sortedList;
|
||||
this.saveInstances(instancesObj);
|
||||
|
||||
return sortedList;
|
||||
},
|
||||
|
||||
async refreshSpeedTests() {
|
||||
const instances = await this.loadInstancesFromGitHub();
|
||||
const promises = [];
|
||||
|
||||
if (instances.api && instances.api.length) {
|
||||
promises.push(this.testSpecificUrls(instances.api, 'api'));
|
||||
}
|
||||
|
||||
if (instances.streaming && instances.streaming.length) {
|
||||
promises.push(this.testSpecificUrls(instances.streaming, 'streaming'));
|
||||
}
|
||||
|
||||
const resultsArray = await Promise.all(promises);
|
||||
const allResults = resultsArray.flat();
|
||||
|
||||
this.updateSpeedCache(allResults);
|
||||
|
||||
// Return API instances for the UI to render (default view)
|
||||
return this.getInstances('api');
|
||||
},
|
||||
saveInstances(instances, type) {
|
||||
if (type) {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (stored) {
|
||||
instancesObj = JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (!instancesObj) {
|
||||
instancesObj = await this.loadInstancesFromGitHub();
|
||||
let fullObj = stored ? JSON.parse(stored) : { api: [], streaming: [] };
|
||||
fullObj[type] = instances;
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(fullObj));
|
||||
} catch (e) {
|
||||
console.error('Failed to save instances:', e);
|
||||
}
|
||||
|
||||
const targetUrls = instancesObj[type] || instancesObj.api || [];
|
||||
if (targetUrls.length === 0) return [];
|
||||
|
||||
const speedCache = this.getCachedSpeedTests();
|
||||
// Construct cache key based on type
|
||||
const getCacheKey = (u) => type === 'streaming' ? `${u}#streaming` : u;
|
||||
|
||||
const urlsToTest = targetUrls.filter(url => !speedCache.speeds[getCacheKey(url)]);
|
||||
|
||||
if (urlsToTest.length > 0) {
|
||||
const results = await this.testSpecificUrls(urlsToTest, type);
|
||||
this.updateSpeedCache(results);
|
||||
Object.assign(speedCache, this.getCachedSpeedTests());
|
||||
}
|
||||
|
||||
const sortList = (list) => {
|
||||
return [...list].sort((a, b) => {
|
||||
const speedA = speedCache.speeds[getCacheKey(a)]?.speed ?? Infinity;
|
||||
const speedB = speedCache.speeds[getCacheKey(b)]?.speed ?? Infinity;
|
||||
return speedA - speedB;
|
||||
});
|
||||
};
|
||||
|
||||
const sortedList = sortList(targetUrls);
|
||||
|
||||
// Persist the sorted order
|
||||
instancesObj[type] = sortedList;
|
||||
this.saveInstances(instancesObj);
|
||||
|
||||
return sortedList;
|
||||
},
|
||||
|
||||
async refreshSpeedTests() {
|
||||
const instances = await this.loadInstancesFromGitHub();
|
||||
const promises = [];
|
||||
|
||||
if (instances.api && instances.api.length) {
|
||||
promises.push(this.testSpecificUrls(instances.api, 'api'));
|
||||
}
|
||||
|
||||
if (instances.streaming && instances.streaming.length) {
|
||||
promises.push(this.testSpecificUrls(instances.streaming, 'streaming'));
|
||||
}
|
||||
|
||||
const resultsArray = await Promise.all(promises);
|
||||
const allResults = resultsArray.flat();
|
||||
|
||||
this.updateSpeedCache(allResults);
|
||||
|
||||
// Return API instances for the UI to render (default view)
|
||||
return this.getInstances('api');
|
||||
},
|
||||
saveInstances(instances, type) {
|
||||
if (type) {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
let fullObj = stored ? JSON.parse(stored) : { api: [], streaming: [] };
|
||||
fullObj[type] = instances;
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(fullObj));
|
||||
} catch (e) {
|
||||
console.error("Failed to save instances:", e);
|
||||
}
|
||||
} else {
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(instances));
|
||||
}
|
||||
} };
|
||||
} else {
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(instances));
|
||||
}
|
||||
},
|
||||
};
|
||||
export const recentActivityManager = {
|
||||
STORAGE_KEY: 'monochrome-recent-activity',
|
||||
LIMIT: 10,
|
||||
|
|
@ -271,7 +270,7 @@ export const recentActivityManager = {
|
|||
|
||||
_add(type, item) {
|
||||
const data = this._get();
|
||||
data[type] = data[type].filter(i => i.id !== item.id);
|
||||
data[type] = data[type].filter((i) => i.id !== item.id);
|
||||
data[type].unshift(item);
|
||||
data[type] = data[type].slice(0, this.LIMIT);
|
||||
this._save(data);
|
||||
|
|
@ -291,7 +290,7 @@ export const recentActivityManager = {
|
|||
|
||||
addMix(mix) {
|
||||
this._add('mixes', mix);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const themeManager = {
|
||||
|
|
@ -308,7 +307,7 @@ export const themeManager = {
|
|||
mocha: {},
|
||||
machiatto: {},
|
||||
frappe: {},
|
||||
latte: {}
|
||||
latte: {},
|
||||
},
|
||||
|
||||
getTheme() {
|
||||
|
|
@ -329,10 +328,9 @@ export const themeManager = {
|
|||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
|
||||
if (theme !== 'custom') {
|
||||
const root = document.documentElement;
|
||||
['background', 'foreground', 'primary', 'secondary', 'muted', 'border', 'highlight'].forEach(key => {
|
||||
['background', 'foreground', 'primary', 'secondary', 'muted', 'border', 'highlight'].forEach((key) => {
|
||||
root.style.removeProperty(`--${key}`);
|
||||
});
|
||||
} else {
|
||||
|
|
@ -363,7 +361,7 @@ export const themeManager = {
|
|||
for (const [key, value] of Object.entries(colors)) {
|
||||
root.style.setProperty(`--${key}`, value);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const lastFMStorage = {
|
||||
|
|
@ -392,7 +390,7 @@ export const lastFMStorage = {
|
|||
|
||||
setLoveOnLike(enabled) {
|
||||
localStorage.setItem(this.LOVE_ON_LIKE_KEY, enabled ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const nowPlayingSettings = {
|
||||
|
|
@ -408,7 +406,7 @@ export const nowPlayingSettings = {
|
|||
|
||||
setMode(mode) {
|
||||
localStorage.setItem(this.STORAGE_KEY, mode);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const lyricsSettings = {
|
||||
|
|
@ -424,7 +422,7 @@ export const lyricsSettings = {
|
|||
|
||||
setDownloadLyrics(enabled) {
|
||||
localStorage.setItem(this.DOWNLOAD_WITH_TRACKS, enabled ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const backgroundSettings = {
|
||||
|
|
@ -441,7 +439,7 @@ export const backgroundSettings = {
|
|||
|
||||
setEnabled(enabled) {
|
||||
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const trackListSettings = {
|
||||
|
|
@ -460,7 +458,7 @@ export const trackListSettings = {
|
|||
setMode(mode) {
|
||||
localStorage.setItem(this.STORAGE_KEY, mode);
|
||||
document.documentElement.setAttribute('data-track-actions-mode', mode);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const cardSettings = {
|
||||
|
|
@ -490,7 +488,7 @@ export const cardSettings = {
|
|||
|
||||
setCompactAlbum(enabled) {
|
||||
localStorage.setItem(this.COMPACT_ALBUM_KEY, enabled ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const replayGainSettings = {
|
||||
|
|
@ -508,7 +506,7 @@ export const replayGainSettings = {
|
|||
},
|
||||
setPreamp(db) {
|
||||
localStorage.setItem(this.STORAGE_KEY_PREAMP, db);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const downloadQualitySettings = {
|
||||
|
|
@ -522,7 +520,7 @@ export const downloadQualitySettings = {
|
|||
},
|
||||
setQuality(quality) {
|
||||
localStorage.setItem(this.STORAGE_KEY, quality);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const waveformSettings = {
|
||||
|
|
@ -538,7 +536,7 @@ export const waveformSettings = {
|
|||
|
||||
setEnabled(enabled) {
|
||||
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const smoothScrollingSettings = {
|
||||
|
|
@ -554,7 +552,7 @@ export const smoothScrollingSettings = {
|
|||
|
||||
setEnabled(enabled) {
|
||||
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const queueManager = {
|
||||
|
|
@ -578,20 +576,18 @@ export const queueManager = {
|
|||
originalQueueBeforeShuffle: queueState.originalQueueBeforeShuffle,
|
||||
currentQueueIndex: queueState.currentQueueIndex,
|
||||
shuffleActive: queueState.shuffleActive,
|
||||
repeatMode: queueState.repeatMode
|
||||
repeatMode: queueState.repeatMode,
|
||||
};
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(minimalState));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save queue to localStorage:', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
// System theme listener
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (themeManager.getTheme() === 'system') {
|
||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
//js/ui-interactions.js
|
||||
import { SVG_CLOSE, SVG_BIN, SVG_HEART, SVG_DOWNLOAD, formatTime, trackDataStore, getTrackTitle, getTrackArtists, escapeHtml } from './utils.js';
|
||||
import {
|
||||
SVG_CLOSE,
|
||||
SVG_BIN,
|
||||
SVG_HEART,
|
||||
SVG_DOWNLOAD,
|
||||
formatTime,
|
||||
trackDataStore,
|
||||
getTrackTitle,
|
||||
getTrackArtists,
|
||||
escapeHtml,
|
||||
} from './utils.js';
|
||||
import { sidePanelManager } from './side-panel.js';
|
||||
|
||||
export function initializeUIInteractions(player, api) {
|
||||
|
|
@ -23,7 +33,7 @@ export function initializeUIInteractions(player, api) {
|
|||
|
||||
sidebarOverlay.addEventListener('click', closeSidebar);
|
||||
|
||||
sidebar.addEventListener('click', e => {
|
||||
sidebar.addEventListener('click', (e) => {
|
||||
if (e.target.closest('a')) {
|
||||
closeSidebar();
|
||||
}
|
||||
|
|
@ -113,9 +123,13 @@ export function initializeUIInteractions(player, api) {
|
|||
<div class="modal-content">
|
||||
<h3>Add Queue to Playlist</h3>
|
||||
<div class="modal-list">
|
||||
${playlists.map(p => `
|
||||
${playlists
|
||||
.map(
|
||||
(p) => `
|
||||
<div class="modal-option" data-id="${p.id}">${escapeHtml(p.name)}</div>
|
||||
`).join('')}
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary cancel-btn">Cancel</button>
|
||||
|
|
@ -179,12 +193,13 @@ export function initializeUIInteractions(player, api) {
|
|||
return;
|
||||
}
|
||||
|
||||
const html = currentQueue.map((track, index) => {
|
||||
const isPlaying = index === player.currentQueueIndex;
|
||||
const trackTitle = getTrackTitle(track);
|
||||
const trackArtists = getTrackArtists(track, { fallback: "Unknown" });
|
||||
const html = currentQueue
|
||||
.map((track, index) => {
|
||||
const isPlaying = index === player.currentQueueIndex;
|
||||
const trackTitle = getTrackTitle(track);
|
||||
const trackArtists = getTrackArtists(track, { fallback: 'Unknown' });
|
||||
|
||||
return `
|
||||
return `
|
||||
<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">
|
||||
|
|
@ -209,7 +224,8 @@ export function initializeUIInteractions(player, api) {
|
|||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
})
|
||||
.join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
|
|
@ -223,7 +239,9 @@ export function initializeUIInteractions(player, api) {
|
|||
const { db } = await import('./db.js');
|
||||
const isLiked = await db.isFavorite('track', track.id);
|
||||
likeBtn.classList.toggle('active', isLiked);
|
||||
likeBtn.innerHTML = isLiked ? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"') : SVG_HEART;
|
||||
likeBtn.innerHTML = isLiked
|
||||
? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"')
|
||||
: SVG_HEART;
|
||||
}
|
||||
|
||||
item.addEventListener('click', async (e) => {
|
||||
|
|
@ -249,9 +267,13 @@ export function initializeUIInteractions(player, api) {
|
|||
|
||||
// Update button state
|
||||
likeBtn.classList.toggle('active', added);
|
||||
likeBtn.innerHTML = added ? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"') : SVG_HEART;
|
||||
likeBtn.innerHTML = added
|
||||
? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"')
|
||||
: SVG_HEART;
|
||||
|
||||
showNotification(added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}`);
|
||||
showNotification(
|
||||
added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -279,7 +301,6 @@ export function initializeUIInteractions(player, api) {
|
|||
trackMixItem.style.display = hasMix ? 'block' : 'none';
|
||||
}
|
||||
|
||||
|
||||
const rect = item.getBoundingClientRect();
|
||||
const menuWidth = 150;
|
||||
const menuHeight = 200;
|
||||
|
|
@ -298,7 +319,6 @@ export function initializeUIInteractions(player, api) {
|
|||
contextMenu.style.top = `${top}px`;
|
||||
contextMenu.style.display = 'block';
|
||||
|
||||
|
||||
contextMenu._contextTrack = track;
|
||||
}
|
||||
}
|
||||
|
|
@ -345,16 +365,16 @@ export function initializeUIInteractions(player, api) {
|
|||
};
|
||||
|
||||
// Search and Library tabs
|
||||
document.querySelectorAll('.search-tab').forEach(tab => {
|
||||
document.querySelectorAll('.search-tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
const page = tab.closest('.page');
|
||||
if (!page) return;
|
||||
|
||||
page.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active'));
|
||||
page.querySelectorAll('.search-tab-content').forEach(c => c.classList.remove('active'));
|
||||
page.querySelectorAll('.search-tab').forEach((t) => t.classList.remove('active'));
|
||||
page.querySelectorAll('.search-tab-content').forEach((c) => c.classList.remove('active'));
|
||||
|
||||
tab.classList.add('active');
|
||||
|
||||
|
||||
const prefix = page.id === 'page-library' ? 'library-tab-' : 'search-tab-';
|
||||
const contentId = `${prefix}${tab.dataset.tab}`;
|
||||
document.getElementById(contentId)?.classList.add('active');
|
||||
|
|
|
|||
79
js/utils.js
79
js/utils.js
|
|
@ -5,37 +5,58 @@ export const QUALITY = 'LOSSLESS';
|
|||
export const REPEAT_MODE = {
|
||||
OFF: 0,
|
||||
ALL: 1,
|
||||
ONE: 2
|
||||
ONE: 2,
|
||||
};
|
||||
|
||||
export const AUDIO_QUALITIES = {
|
||||
HI_RES_LOSSLESS: 'HI_RES_LOSSLESS',
|
||||
LOSSLESS: 'LOSSLESS',
|
||||
HIGH: 'HIGH',
|
||||
LOW: 'LOW'
|
||||
LOW: 'LOW',
|
||||
};
|
||||
|
||||
export const QUALITY_PRIORITY = ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'];
|
||||
|
||||
export const QUALITY_TOKENS = {
|
||||
HI_RES_LOSSLESS: ['HI_RES_LOSSLESS', 'HIRES_LOSSLESS', 'HIRESLOSSLESS', 'HIFI_PLUS', 'HI_RES_FLAC', 'HI_RES', 'HIRES', 'MASTER', 'MASTER_QUALITY', 'MQA'],
|
||||
HI_RES_LOSSLESS: [
|
||||
'HI_RES_LOSSLESS',
|
||||
'HIRES_LOSSLESS',
|
||||
'HIRESLOSSLESS',
|
||||
'HIFI_PLUS',
|
||||
'HI_RES_FLAC',
|
||||
'HI_RES',
|
||||
'HIRES',
|
||||
'MASTER',
|
||||
'MASTER_QUALITY',
|
||||
'MQA',
|
||||
],
|
||||
LOSSLESS: ['LOSSLESS', 'HIFI'],
|
||||
HIGH: ['HIGH', 'HIGH_QUALITY'],
|
||||
LOW: ['LOW', 'LOW_QUALITY']
|
||||
LOW: ['LOW', 'LOW_QUALITY'],
|
||||
};
|
||||
|
||||
export const RATE_LIMIT_ERROR_MESSAGE = 'Too Many Requests. Please wait a moment and try again.';
|
||||
|
||||
export const SVG_PLAY = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="7 3 21 12 7 21 7 3"></polygon></svg>';
|
||||
export const SVG_PAUSE = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>';
|
||||
export const SVG_VOLUME = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg>';
|
||||
export const SVG_MUTE = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><line x1="23" y1="9" x2="17" y2="15"></line><line x1="17" y1="9" x2="23" y2="15"></line></svg>';
|
||||
export const SVG_DOWNLOAD = '<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="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>';
|
||||
export const SVG_MENU = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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>';
|
||||
export const SVG_HEART = '<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="heart-icon"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>';
|
||||
export const SVG_CLOSE = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
|
||||
export const SVG_BIN = '<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="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><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>';
|
||||
export const SVG_MIX = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>';
|
||||
export const SVG_PLAY =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="7 3 21 12 7 21 7 3"></polygon></svg>';
|
||||
export const SVG_PAUSE =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>';
|
||||
export const SVG_VOLUME =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg>';
|
||||
export const SVG_MUTE =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><line x1="23" y1="9" x2="17" y2="15"></line><line x1="17" y1="9" x2="23" y2="15"></line></svg>';
|
||||
export const SVG_DOWNLOAD =
|
||||
'<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="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>';
|
||||
export const SVG_MENU =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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>';
|
||||
export const SVG_HEART =
|
||||
'<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="heart-icon"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>';
|
||||
export const SVG_CLOSE =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
|
||||
export const SVG_BIN =
|
||||
'<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="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><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>';
|
||||
export const SVG_MIX =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>';
|
||||
|
||||
export const formatTime = (seconds) => {
|
||||
if (isNaN(seconds)) return '0:00';
|
||||
|
|
@ -78,7 +99,7 @@ export const buildTrackFilename = (track, quality) => {
|
|||
trackNumber: track.trackNumber,
|
||||
artist: artistName,
|
||||
title: getTrackTitle(track),
|
||||
album: track.album?.title
|
||||
album: track.album?.title,
|
||||
};
|
||||
|
||||
return formatTemplate(template, data) + '.' + extension;
|
||||
|
|
@ -86,7 +107,10 @@ export const buildTrackFilename = (track, quality) => {
|
|||
|
||||
const sanitizeToken = (value) => {
|
||||
if (!value) return '';
|
||||
return value.trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_');
|
||||
return value
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '_');
|
||||
};
|
||||
|
||||
export const normalizeQualityToken = (value) => {
|
||||
|
|
@ -142,13 +166,13 @@ export const deriveTrackQuality = (track) => {
|
|||
const candidates = [
|
||||
deriveQualityFromTags(track.mediaMetadata?.tags),
|
||||
deriveQualityFromTags(track.album?.mediaMetadata?.tags),
|
||||
normalizeQualityToken(track.audioQuality)
|
||||
normalizeQualityToken(track.audioQuality),
|
||||
];
|
||||
|
||||
return pickBestQuality(candidates);
|
||||
};
|
||||
|
||||
export const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const hasExplicitContent = (item) => {
|
||||
return item?.explicit === true || item?.explicitLyrics === true;
|
||||
|
|
@ -169,11 +193,11 @@ export const debounce = (func, wait) => {
|
|||
export const escapeHtml = (unsafe) => {
|
||||
if (typeof unsafe !== 'string') return unsafe;
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
export const getTrackTitle = (track, { fallback = 'Unknown Title' } = {}) => {
|
||||
|
|
@ -183,7 +207,7 @@ export const getTrackTitle = (track, { fallback = 'Unknown Title' } = {}) => {
|
|||
|
||||
export const getTrackArtists = (track = {}, { fallback = 'Unknown Artist' } = {}) => {
|
||||
if (track?.artists?.length) {
|
||||
return track.artists.map(artist => artist?.name).join(', ');
|
||||
return track.artists.map((artist) => artist?.name).join(', ');
|
||||
}
|
||||
|
||||
return fallback;
|
||||
|
|
@ -191,9 +215,9 @@ export const getTrackArtists = (track = {}, { fallback = 'Unknown Artist' } = {}
|
|||
|
||||
export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } = {}) => {
|
||||
if (track?.artists?.length) {
|
||||
return track.artists.map(artist =>
|
||||
`<span class="artist-link" data-artist-id="${artist.id}">${artist.name}</span>`
|
||||
).join(', ');
|
||||
return track.artists
|
||||
.map((artist) => `<span class="artist-link" data-artist-id="${artist.id}">${artist.name}</span>`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
return fallback;
|
||||
|
|
@ -275,4 +299,3 @@ export async function getCoverBlob(api, coverId) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ function rgbToHsl(r, g, b) {
|
|||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h, s, l = (max + min) / 2;
|
||||
let h,
|
||||
s,
|
||||
l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0; // achromatic
|
||||
|
|
@ -19,9 +21,15 @@ function rgbToHsl(r, g, b) {
|
|||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
case b: h = (r - g) / d + 4; break;
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
|
@ -41,20 +49,20 @@ function hslToHex(h, s, l) {
|
|||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||
if (t < 1/2) return q;
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1/3);
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1/3);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
const toHex = x => {
|
||||
const toHex = (x) => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
|
|
@ -89,7 +97,7 @@ export function getVibrantColorFromImage(imgElement) {
|
|||
const imageData = ctx.getImageData(0, 0, w, h);
|
||||
const pixels = imageData.data;
|
||||
const candidates = [];
|
||||
|
||||
|
||||
// Iterate through pixels
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const r = pixels[i];
|
||||
|
|
@ -110,7 +118,7 @@ export function getVibrantColorFromImage(imgElement) {
|
|||
|
||||
// If no candidates found with strict criteria, relax criteria
|
||||
if (candidates.length === 0) {
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const r = pixels[i];
|
||||
const g = pixels[i + 1];
|
||||
const b = pixels[i + 2];
|
||||
|
|
@ -129,15 +137,14 @@ export function getVibrantColorFromImage(imgElement) {
|
|||
|
||||
// Sort by saturation (descending) then lightness (proximity to 0.5)
|
||||
candidates.sort((c1, c2) => {
|
||||
return c2.s - c1.s || (0.5 - Math.abs(c1.l - 0.5)) - (0.5 - Math.abs(c2.l - 0.5));
|
||||
return c2.s - c1.s || 0.5 - Math.abs(c1.l - 0.5) - (0.5 - Math.abs(c2.l - 0.5));
|
||||
});
|
||||
|
||||
// Pick the top candidate (most vibrant)
|
||||
// Optionally averaging top N could be done, but simplified "best single pixel" is usually sufficient for "Vibrant"
|
||||
const best = candidates[0];
|
||||
|
||||
return hslToHex(best.h, best.s, best.l);
|
||||
|
||||
return hslToHex(best.h, best.s, best.l);
|
||||
} catch (e) {
|
||||
throw e; // Re-throw to allow UI to handle CORS retry
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export class WaveformGenerator {
|
|||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
|
||||
|
||||
|
||||
const peaks = this.extractPeaks(audioBuffer);
|
||||
const result = { peaks, duration: audioBuffer.duration };
|
||||
this.cache.set(trackId, result);
|
||||
|
|
@ -28,7 +28,7 @@ export class WaveformGenerator {
|
|||
|
||||
extractPeaks(audioBuffer) {
|
||||
const { length, duration } = audioBuffer;
|
||||
const numPeaks = Math.min(Math.floor(4*duration), 1000);
|
||||
const numPeaks = Math.min(Math.floor(4 * duration), 1000);
|
||||
const peaks = new Float32Array(numPeaks);
|
||||
const chanData = audioBuffer.getChannelData(0); // Use first channel
|
||||
const step = Math.floor(length / numPeaks);
|
||||
|
|
@ -71,13 +71,13 @@ export class WaveformGenerator {
|
|||
const height = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
|
||||
const step = width / peaks.length;
|
||||
const centerY = height / 2;
|
||||
|
||||
ctx.fillStyle = '#000'; // Mask color (opaque part)
|
||||
ctx.beginPath();
|
||||
|
||||
|
||||
// Draw top half
|
||||
ctx.moveTo(0, centerY);
|
||||
for (let i = 0; i < peaks.length; i++) {
|
||||
|
|
@ -92,7 +92,7 @@ export class WaveformGenerator {
|
|||
const barHeight = Math.max(1.5, peak * height * 0.9);
|
||||
ctx.lineTo(i * step, centerY + barHeight / 2);
|
||||
}
|
||||
|
||||
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
|
|
|||
86
package.json
86
package.json
|
|
@ -1,45 +1,45 @@
|
|||
{
|
||||
"name": "monochrome",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "[<img src=\"https://github.com/SamidyFR/monochrome/blob/main/assets/512.png?raw=true\" alt=\"Monochrome Logo\">](https://monochrome.samidy.com)",
|
||||
"main": "sw.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint:js": "eslint .",
|
||||
"lint:css": "stylelint \"**/*.css\"",
|
||||
"lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"",
|
||||
"lint": "npm run lint:js && npm run lint:css && npm run lint:html",
|
||||
"format": "prettier --write .",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/SamidyFR/monochrome.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/SamidyFR/monochrome/issues"
|
||||
},
|
||||
"homepage": "https://github.com/SamidyFR/monochrome#readme",
|
||||
"devDependencies": {
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.0.0",
|
||||
"htmlhint": "^1.8.0",
|
||||
"prettier": "^3.7.4",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-standard": "^39.0.1",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"overrides": {
|
||||
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@^1.4.14",
|
||||
"source-map": "^0.7.4"
|
||||
}
|
||||
"name": "monochrome",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "[<img src=\"https://github.com/SamidyFR/monochrome/blob/main/assets/512.png?raw=true\" alt=\"Monochrome Logo\">](https://monochrome.samidy.com)",
|
||||
"main": "sw.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint:js": "eslint .",
|
||||
"lint:css": "stylelint \"**/*.css\"",
|
||||
"lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"",
|
||||
"lint": "npm run lint:js && npm run lint:css && npm run lint:html",
|
||||
"format": "prettier --write .",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/SamidyFR/monochrome.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/SamidyFR/monochrome/issues"
|
||||
},
|
||||
"homepage": "https://github.com/SamidyFR/monochrome#readme",
|
||||
"devDependencies": {
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.0.0",
|
||||
"htmlhint": "^1.8.0",
|
||||
"prettier": "^3.7.4",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-standard": "^39.0.1",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"overrides": {
|
||||
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@^1.4.14",
|
||||
"source-map": "^0.7.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="favicon.ico" type="image/x-icon">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Redirecting...</title>
|
||||
<meta http-equiv="refresh" content="0; URL='https://discord.gg/4DYm4artsN'">
|
||||
<script>
|
||||
window.location.href = "https://discord.gg/4DYm4artsN";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>If you are not redirected, <a href="https://discord.gg/4DYm4artsN">click here</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="favicon.ico" type="image/x-icon" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Redirecting...</title>
|
||||
<meta http-equiv="refresh" content="0; URL='https://discord.gg/4DYm4artsN'" />
|
||||
<script>
|
||||
window.location.href = 'https://discord.gg/4DYm4artsN';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>If you are not redirected, <a href="https://discord.gg/4DYm4artsN">click here</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
{
|
||||
"api": [
|
||||
"https://tidal-api.binimum.org",
|
||||
"https://monochrome-api.samidy.com"
|
||||
],
|
||||
"streaming": [
|
||||
"https://triton.squid.wtf",
|
||||
"https://wolf.qqdl.site",
|
||||
"https://maus.qqdl.site",
|
||||
"https://vogel.qqdl.site",
|
||||
"https://katze.qqdl.site",
|
||||
"https://hund.qqdl.site",
|
||||
"https://tidal.kinoplus.online",
|
||||
"https://tidal-api.binimum.org"
|
||||
]
|
||||
}
|
||||
"api": ["https://tidal-api.binimum.org", "https://monochrome-api.samidy.com"],
|
||||
"streaming": [
|
||||
"https://triton.squid.wtf",
|
||||
"https://wolf.qqdl.site",
|
||||
"https://maus.qqdl.site",
|
||||
"https://vogel.qqdl.site",
|
||||
"https://katze.qqdl.site",
|
||||
"https://hund.qqdl.site",
|
||||
"https://tidal.kinoplus.online",
|
||||
"https://tidal-api.binimum.org"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,47 @@
|
|||
{
|
||||
"name": "Monochrome Music",
|
||||
"short_name": "Monochrome",
|
||||
"description": "A minimalist music streaming application",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"display_override": [
|
||||
"window-controls-overlay"
|
||||
],
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/appicon.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "assets/appicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "assets/appicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"music",
|
||||
"entertainment"
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Search",
|
||||
"short_name": "Search",
|
||||
"description": "Search for music",
|
||||
"url": "./#search",
|
||||
"icons": [
|
||||
"name": "Monochrome Music",
|
||||
"short_name": "Monochrome",
|
||||
"description": "A minimalist music streaming application",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay"],
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
"src": "assets/appicon.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "assets/appicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "assets/appicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"categories": ["music", "entertainment"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Search",
|
||||
"short_name": "Search",
|
||||
"description": "Search for music",
|
||||
"url": "./#search",
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
295
styles.css
295
styles.css
|
|
@ -1,5 +1,6 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
--spacing-xs: 0.4rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
|
|
@ -8,15 +9,16 @@
|
|||
--spacing-2xl: 3rem;
|
||||
--radius: 0.5rem;
|
||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
--shadow-md: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.8);
|
||||
--shadow-sm: 0 4px 12px rgb(0, 0, 0, 0.15);
|
||||
--shadow-md: 0 6px 16px rgb(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 10px 30px rgb(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 20px 60px rgb(0, 0, 0, 0.8);
|
||||
--cover-filter: blur(50px) brightness(0.4);
|
||||
}
|
||||
|
||||
:root[data-theme="monochrome"] {
|
||||
:root[data-theme='monochrome'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #000;
|
||||
--foreground: #fafafa;
|
||||
--card: #111;
|
||||
|
|
@ -36,8 +38,9 @@
|
|||
--explicit-badge: #fafafa;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--card: #1a1a1a;
|
||||
|
|
@ -57,8 +60,9 @@
|
|||
--explicit-badge: #750a0a;
|
||||
}
|
||||
|
||||
:root[data-theme="ocean"] {
|
||||
:root[data-theme='ocean'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #0c1821;
|
||||
--foreground: #e0f4ff;
|
||||
--card: #1b2838;
|
||||
|
|
@ -78,8 +82,9 @@
|
|||
--explicit-badge: #f43f5e;
|
||||
}
|
||||
|
||||
:root[data-theme="purple"] {
|
||||
:root[data-theme='purple'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #0f0514;
|
||||
--foreground: #f3e8ff;
|
||||
--card: #1e0a2e;
|
||||
|
|
@ -99,8 +104,9 @@
|
|||
--explicit-badge: #ec4899;
|
||||
}
|
||||
|
||||
:root[data-theme="forest"] {
|
||||
:root[data-theme='forest'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #0a1409;
|
||||
--foreground: #e8f5e9;
|
||||
--card: #1a2e1a;
|
||||
|
|
@ -120,8 +126,9 @@
|
|||
--explicit-badge: #f59e0b;
|
||||
}
|
||||
|
||||
:root[data-theme="mocha"] {
|
||||
:root[data-theme='mocha'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #1e1e2e;
|
||||
--foreground: #cdd6f4;
|
||||
--card: #313244;
|
||||
|
|
@ -141,8 +148,9 @@
|
|||
--explicit-badge: #f9e2af;
|
||||
}
|
||||
|
||||
:root[data-theme="machiatto"] {
|
||||
:root[data-theme='machiatto'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #24273a;
|
||||
--foreground: #cad3f5;
|
||||
--card: #363a4f;
|
||||
|
|
@ -162,8 +170,9 @@
|
|||
--explicit-badge: #eed49f;
|
||||
}
|
||||
|
||||
:root[data-theme="frappe"] {
|
||||
:root[data-theme='frappe'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #303446;
|
||||
--foreground: #c6d0f5;
|
||||
--card: #414559;
|
||||
|
|
@ -183,8 +192,9 @@
|
|||
--explicit-badge: #e5c890;
|
||||
}
|
||||
|
||||
:root[data-theme="latte"] {
|
||||
:root[data-theme='latte'] {
|
||||
color-scheme: light;
|
||||
|
||||
--background: #eff1f5;
|
||||
--foreground: #4c4f69;
|
||||
--card: #ccd0da;
|
||||
|
|
@ -204,16 +214,17 @@
|
|||
--explicit-badge: #df8e1d;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
:root[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
--background: #ffffff;
|
||||
--foreground: #000000;
|
||||
|
||||
--background: #fff;
|
||||
--foreground: #000;
|
||||
--card: #f4f4f5;
|
||||
--card-foreground: #000000;
|
||||
--card-foreground: #000;
|
||||
--primary: #2563eb;
|
||||
--primary-foreground: #ffffff;
|
||||
--primary-foreground: #fff;
|
||||
--secondary: #e4e4e7;
|
||||
--secondary-foreground: #000000;
|
||||
--secondary-foreground: #000;
|
||||
--muted: #e4e4e7;
|
||||
--muted-foreground: #71717a;
|
||||
--border: #e4e4e7;
|
||||
|
|
@ -226,7 +237,9 @@
|
|||
--cover-filter: blur(50px) brightness(1.6) opacity(0.35);
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
|
@ -243,9 +256,11 @@ html {
|
|||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: Inter, sans-serif;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
|
|
@ -280,7 +295,7 @@ kbd {
|
|||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 4px rgb(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.app-container {
|
||||
|
|
@ -288,8 +303,8 @@ kbd {
|
|||
height: 100vh;
|
||||
height: 100dvh;
|
||||
grid-template:
|
||||
"sidebar main" 1fr
|
||||
"player player" auto / 190px 1fr;
|
||||
'sidebar main' 1fr
|
||||
'player player' auto / 190px 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
|
|
@ -333,8 +348,8 @@ kbd {
|
|||
transition: opacity 0.5s ease-in-out;
|
||||
|
||||
/* Fade out at the bottom */
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.8) 40%, rgba(0, 0, 0, 0) 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.8) 40%, rgba(0, 0, 0, 0) 100%);
|
||||
mask-image: linear-gradient(to bottom, rgb(0, 0, 0, 1) 0%, rgb(0, 0, 0, 0.8) 40%, rgb(0, 0, 0, 0) 100%);
|
||||
mask-image: linear-gradient(to bottom, rgb(0, 0, 0, 1) 0%, rgb(0, 0, 0, 0.8) 40%, rgb(0, 0, 0, 0) 100%);
|
||||
|
||||
/* Blur effect */
|
||||
filter: var(--cover-filter);
|
||||
|
|
@ -346,9 +361,9 @@ kbd {
|
|||
}
|
||||
|
||||
/* Light mode adjustments */
|
||||
:root[data-theme="light"] #page-background {
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
:root[data-theme='light'] #page-background {
|
||||
mask-image: linear-gradient(to bottom, rgb(0, 0, 0, 1) 0%, rgb(0, 0, 0, 0) 100%);
|
||||
mask-image: linear-gradient(to bottom, rgb(0, 0, 0, 1) 0%, rgb(0, 0, 0, 0) 100%);
|
||||
}
|
||||
|
||||
.now-playing-bar {
|
||||
|
|
@ -414,7 +429,7 @@ kbd {
|
|||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: rgb(0, 0, 0, 0.5);
|
||||
z-index: 1999;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
|
@ -579,7 +594,6 @@ body.has-page-background .track-item:hover {
|
|||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
|
@ -680,9 +694,9 @@ body.has-page-background .track-item:hover {
|
|||
position: absolute;
|
||||
top: 2%;
|
||||
right: 2%;
|
||||
background: rgba(0, 0, 0, 0.25) !important;
|
||||
background: rgb(0, 0, 0, 0.25) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-radius: 50% !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
|
|
@ -705,7 +719,7 @@ body.has-page-background .track-item:hover {
|
|||
}
|
||||
|
||||
.card-like-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
background: rgb(0, 0, 0, 0.7) !important;
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
|
||||
|
|
@ -717,9 +731,9 @@ body.has-page-background .track-item:hover {
|
|||
.delete-playlist-btn {
|
||||
position: absolute;
|
||||
top: 2%;
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
background: rgb(0, 0, 0, 0.5) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-radius: 50% !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
|
|
@ -752,7 +766,7 @@ body.has-page-background .track-item:hover {
|
|||
|
||||
.edit-playlist-btn:hover,
|
||||
.delete-playlist-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.8) !important;
|
||||
background: rgb(0, 0, 0, 0.8) !important;
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
|
||||
|
|
@ -821,7 +835,9 @@ body.has-page-background .track-item:hover {
|
|||
}
|
||||
|
||||
.heart-icon {
|
||||
transition: transform 0.2s ease, color 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.like-btn:hover .heart-icon {
|
||||
|
|
@ -893,7 +909,7 @@ body.has-page-background .track-item:hover {
|
|||
}
|
||||
|
||||
.track-item.playing {
|
||||
background-color: rgba(var(--highlight-rgb), 0.15);
|
||||
background-color: rgb(var(--highlight-rgb), 0.15);
|
||||
border-left: 3px solid var(--highlight);
|
||||
padding-left: calc(var(--spacing-sm) - 3px);
|
||||
}
|
||||
|
|
@ -984,7 +1000,7 @@ body.has-page-background .track-item:hover {
|
|||
z-index: 10;
|
||||
}
|
||||
|
||||
[data-track-actions-mode="dropdown"] .track-menu-btn {
|
||||
[data-track-actions-mode='dropdown'] .track-menu-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
|
@ -992,10 +1008,8 @@ body.has-page-background .track-item:hover {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.track-menu-btn:hover {
|
||||
background-color: rgba(var(--highlight-rgb), 0.2);
|
||||
background-color: rgb(var(--highlight-rgb), 0.2);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
|
|
@ -1010,7 +1024,7 @@ body.has-page-background .track-item:hover {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-track-actions-mode="inline"] .track-actions-inline {
|
||||
[data-track-actions-mode='inline'] .track-actions-inline {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
|
@ -1018,8 +1032,6 @@ body.has-page-background .track-item:hover {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.track-action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
|
@ -1035,7 +1047,7 @@ body.has-page-background .track-item:hover {
|
|||
}
|
||||
|
||||
.track-action-btn:hover {
|
||||
background-color: rgba(var(--highlight-rgb), 0.2);
|
||||
background-color: rgb(var(--highlight-rgb), 0.2);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
|
|
@ -1257,7 +1269,7 @@ body.has-page-background .track-item:hover {
|
|||
}
|
||||
|
||||
.setting-item select,
|
||||
.setting-item input[type="number"] {
|
||||
.setting-item input[type='number'] {
|
||||
background-color: var(--input);
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -1265,7 +1277,7 @@ body.has-page-background .track-item:hover {
|
|||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.setting-item input[type="number"] {
|
||||
.setting-item input[type='number'] {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
|
|
@ -1310,7 +1322,7 @@ body.has-page-background .track-item:hover {
|
|||
|
||||
.slider::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
content: '';
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 4px;
|
||||
|
|
@ -1479,7 +1491,7 @@ input:checked + .slider::before {
|
|||
position: relative;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
|
|
@ -1519,8 +1531,9 @@ input:checked + .slider::before {
|
|||
height: 12px;
|
||||
background-color: var(--highlight);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 2px 4px rgb(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.progress-bar.has-waveform.waveform-loaded {
|
||||
height: 28px;
|
||||
}
|
||||
|
|
@ -1607,7 +1620,7 @@ input:checked + .slider::before {
|
|||
height: 12px;
|
||||
background-color: var(--highlight);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 2px 4px rgb(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Sleep Timer Button */
|
||||
|
|
@ -1624,7 +1637,7 @@ input:checked + .slider::before {
|
|||
|
||||
#sleep-timer-btn.active {
|
||||
color: var(--primary);
|
||||
text-shadow: 0 0 8px rgba(var(--highlight-rgb), 0.5);
|
||||
text-shadow: 0 0 8px rgb(var(--highlight-rgb), 0.5);
|
||||
}
|
||||
|
||||
#sleep-timer-btn svg {
|
||||
|
|
@ -1672,7 +1685,7 @@ input:checked + .slider::before {
|
|||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
background-color: rgb(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 3000;
|
||||
justify-content: center;
|
||||
|
|
@ -1691,13 +1704,15 @@ input:checked + .slider::before {
|
|||
animation: fadeIn 0.3s ease;
|
||||
overflow: hidden;
|
||||
background-color: var(--background);
|
||||
|
||||
/* Use a CSS variable for the image so we can set it in JS */
|
||||
--bg-image: none;
|
||||
|
||||
padding-bottom: 90px; /* Account for desktop player bar */
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay::before {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
background-size: cover;
|
||||
|
|
@ -1716,6 +1731,7 @@ input:checked + .slider::before {
|
|||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
/* Remove fixed padding to allow flex centering to work within the overlay's padded box */
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
|
|
@ -1749,7 +1765,7 @@ input:checked + .slider::before {
|
|||
max-width: 80vw;
|
||||
max-height: 60vh;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0 20px 50px rgb(0, 0, 0, 0.5);
|
||||
object-fit: contain;
|
||||
margin-bottom: 2rem;
|
||||
z-index: 1;
|
||||
|
|
@ -1805,8 +1821,6 @@ input:checked + .slider::before {
|
|||
color: var(--foreground);
|
||||
}
|
||||
|
||||
|
||||
|
||||
#queue-modal {
|
||||
background-color: var(--card);
|
||||
width: 90%;
|
||||
|
|
@ -1892,7 +1906,7 @@ input:checked + .slider::before {
|
|||
}
|
||||
|
||||
.queue-track-item .queue-like-btn:hover {
|
||||
background-color: rgba(var(--highlight-rgb), 0.2);
|
||||
background-color: rgb(var(--highlight-rgb), 0.2);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
|
|
@ -1914,7 +1928,7 @@ input:checked + .slider::before {
|
|||
}
|
||||
|
||||
.queue-track-item.playing {
|
||||
background-color: rgba(var(--highlight-rgb), 0.15);
|
||||
background-color: rgb(var(--highlight-rgb), 0.15);
|
||||
border-left: 3px solid var(--highlight);
|
||||
padding-left: calc(var(--spacing-sm) - 3px);
|
||||
}
|
||||
|
|
@ -1930,7 +1944,6 @@ input:checked + .slider::before {
|
|||
.queue-track-item .queue-remove-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
|
|
@ -1947,8 +1960,6 @@ input:checked + .slider::before {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.queue-track-item .queue-remove-btn:hover {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
|
|
@ -1969,10 +1980,7 @@ input:checked + .slider::before {
|
|||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg,
|
||||
var(--secondary) 0%,
|
||||
var(--muted) 50%,
|
||||
var(--secondary) 100%);
|
||||
background: linear-gradient(90deg, var(--secondary) 0%, var(--muted) 50%, var(--secondary) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius);
|
||||
|
|
@ -2173,7 +2181,7 @@ input:checked + .slider::before {
|
|||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.theme-color-input input[type="color"] {
|
||||
.theme-color-input input[type='color'] {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -2223,7 +2231,7 @@ input:checked + .slider::before {
|
|||
}
|
||||
|
||||
.about-features li::before {
|
||||
content: "✓";
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--highlight);
|
||||
|
|
@ -2466,7 +2474,6 @@ input:checked + .slider::before {
|
|||
border-bottom: none;
|
||||
}
|
||||
|
||||
|
||||
#playlist-detail-description,
|
||||
#mix-detail-description {
|
||||
color: var(--foreground);
|
||||
|
|
@ -2497,21 +2504,6 @@ input:checked + .slider::before {
|
|||
|
||||
/* Responsive Design */
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
.now-playing-bar {
|
||||
padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom));
|
||||
|
|
@ -2542,7 +2534,9 @@ input:checked + .slider::before {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s ease;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
|
@ -2604,7 +2598,6 @@ input:checked + .slider::before {
|
|||
/* Inherits side-panel */
|
||||
}
|
||||
|
||||
|
||||
/* Synced lyrics styling with Apple Music animations */
|
||||
.synced-line {
|
||||
padding: 0.5rem 0;
|
||||
|
|
@ -2624,7 +2617,7 @@ input:checked + .slider::before {
|
|||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
text-shadow: 0 0 20px rgba(var(--highlight-rgb), 0.3);
|
||||
text-shadow: 0 0 20px rgb(var(--highlight-rgb), 0.3);
|
||||
}
|
||||
|
||||
.synced-line.upcoming {
|
||||
|
|
@ -2708,7 +2701,6 @@ input:checked + .slider::before {
|
|||
|
||||
/* Hide play button initially on desktop (hover capable), show on hover */
|
||||
|
||||
|
||||
/* Adjust like button for compact size */
|
||||
.card.compact .card-like-btn {
|
||||
width: 24px !important;
|
||||
|
|
@ -2728,7 +2720,6 @@ input:checked + .slider::before {
|
|||
|
||||
/* Mobile adjustments */
|
||||
|
||||
|
||||
/* Clickable album cover indicator */
|
||||
.now-playing-bar .cover {
|
||||
cursor: pointer;
|
||||
|
|
@ -2747,7 +2738,7 @@ input:checked + .slider::before {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
background: rgb(0, 0, 0, 0.7);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
font-size: 1.5rem;
|
||||
|
|
@ -2759,7 +2750,6 @@ input:checked + .slider::before {
|
|||
|
||||
/* Window Controls Overlay */
|
||||
|
||||
|
||||
.now-playing-bar .artist .artist-link {
|
||||
cursor: pointer;
|
||||
transition: color var(--transition);
|
||||
|
|
@ -2799,8 +2789,9 @@ input:checked + .slider::before {
|
|||
}
|
||||
|
||||
/* Show a grey box for images with no source, hiding the broken icon */
|
||||
img:not([src]), img[src=''] {
|
||||
content: url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");
|
||||
img:not([src]),
|
||||
img[src=''] {
|
||||
content: url('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
|
|
@ -2885,7 +2876,6 @@ img:not([src]), img[src=''] {
|
|||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.fullscreen-cover-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
@ -2908,7 +2898,7 @@ img:not([src]), img[src=''] {
|
|||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 4rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: rgb(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.75rem;
|
||||
|
|
@ -2922,7 +2912,7 @@ img:not([src]), img[src=''] {
|
|||
}
|
||||
|
||||
.fullscreen-lyrics-toggle:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
background: rgb(0, 0, 0, 0.7);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
|
|
@ -2947,7 +2937,7 @@ img:not([src]), img[src=''] {
|
|||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
background: rgb(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
|
@ -3029,8 +3019,14 @@ img:not([src]), img[src=''] {
|
|||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
from {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#playlist-modal {
|
||||
|
|
@ -3045,7 +3041,8 @@ img:not([src]), img[src=''] {
|
|||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -3071,7 +3068,7 @@ img:not([src]), img[src=''] {
|
|||
}
|
||||
|
||||
.csv-import-progress .progress-header h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
|
|
@ -3121,18 +3118,17 @@ img:not([src]), img[src=''] {
|
|||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.3) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background: linear-gradient(90deg, transparent 0%, rgb(255, 255, 255, 0.3) 50%, transparent 100%);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.csv-import-progress .progress-text {
|
||||
|
|
@ -3146,13 +3142,11 @@ img:not([src]), img[src=''] {
|
|||
color: var(--foreground);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.missing-tracks-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 2rem 2rem 1.5rem 2rem;
|
||||
padding: 2rem 2rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
|
|
@ -3218,7 +3212,7 @@ img:not([src]), img[src=''] {
|
|||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
|
|
@ -3229,11 +3223,11 @@ img:not([src]), img[src=''] {
|
|||
}
|
||||
|
||||
.missing-tracks-list li:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: rgb(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.missing-tracks-actions {
|
||||
padding: 1.5rem 2rem 2rem 2rem;
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
|
@ -3246,10 +3240,6 @@ img:not([src]), img[src=''] {
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* Default responsive classes */
|
||||
.mobile-only {
|
||||
display: none !important;
|
||||
|
|
@ -3306,19 +3296,19 @@ img:not([src]), img[src=''] {
|
|||
/* --- Responsive & Media Queries --- */
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.card-grid {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.card-grid {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.now-playing-bar {
|
||||
.now-playing-bar {
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
|
@ -3333,7 +3323,7 @@ img:not([src]), img[src=''] {
|
|||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.app-container {
|
||||
.app-container {
|
||||
grid-template-columns: 160px 1fr;
|
||||
}
|
||||
|
||||
|
|
@ -3360,13 +3350,11 @@ img:not([src]), img[src=''] {
|
|||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.player-controls .progress-container {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
|
||||
.player-controls .progress-container {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay {
|
||||
#fullscreen-cover-overlay {
|
||||
padding-bottom: 160px; /* Account for taller mobile player bar */
|
||||
}
|
||||
|
||||
|
|
@ -3383,11 +3371,11 @@ img:not([src]), img[src=''] {
|
|||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
.app-container {
|
||||
grid-template:
|
||||
"header" auto
|
||||
"main" 1fr
|
||||
"player" auto / 1fr;
|
||||
'header' auto
|
||||
'main' 1fr
|
||||
'player' auto / 1fr;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
|
@ -3409,12 +3397,14 @@ img:not([src]), img[src=''] {
|
|||
height: 100%;
|
||||
transform: translateX(-100%);
|
||||
z-index: 2000;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar.is-open {
|
||||
transform: translateX(0);
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0 0 20px rgb(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hamburger-menu {
|
||||
|
|
@ -3509,8 +3499,8 @@ img:not([src]), img[src=''] {
|
|||
|
||||
.now-playing-bar {
|
||||
grid-template:
|
||||
"track controls" auto
|
||||
"progress progress" auto / 1fr auto;
|
||||
'track controls' auto
|
||||
'progress progress' auto / 1fr auto;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
height: var(--player-bar-height-mobile);
|
||||
|
|
@ -3699,7 +3689,7 @@ img:not([src]), img[src=''] {
|
|||
bottom: 10px;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
.side-panel {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
bottom: var(--player-bar-height-mobile);
|
||||
|
|
@ -3810,7 +3800,7 @@ img:not([src]), img[src=''] {
|
|||
}
|
||||
|
||||
.detail-header-info .title.long-title {
|
||||
font-size: 1.10rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.detail-header-info .title.very-long-title {
|
||||
|
|
@ -3914,7 +3904,7 @@ img:not([src]), img[src=''] {
|
|||
height: 32px;
|
||||
}
|
||||
|
||||
[data-track-actions-mode="inline"] .track-actions-inline .track-action-btn:not([data-action="play-next"]) {
|
||||
[data-track-actions-mode='inline'] .track-actions-inline .track-action-btn:not([data-action='play-next']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
@ -3922,8 +3912,9 @@ img:not([src]), img[src=''] {
|
|||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.player-controls .buttons {
|
||||
.player-controls .buttons {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -3935,7 +3926,7 @@ img:not([src]), img[src=''] {
|
|||
}
|
||||
|
||||
@media (display-mode: window-controls-overlay) {
|
||||
.app-container {
|
||||
.app-container {
|
||||
margin-top: env(titlebar-area-height, 0);
|
||||
}
|
||||
|
||||
|
|
@ -3953,7 +3944,7 @@ img:not([src]), img[src=''] {
|
|||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.card.compact .card-play-btn {
|
||||
.card.compact .card-play-btn {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
|
@ -3963,15 +3954,15 @@ img:not([src]), img[src=''] {
|
|||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.track-menu-btn {
|
||||
.track-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track-actions-inline {
|
||||
.track-actions-inline {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.queue-track-item .queue-remove-btn {
|
||||
.queue-track-item .queue-remove-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
|
@ -3982,7 +3973,7 @@ img:not([src]), img[src=''] {
|
|||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.main-content {
|
||||
.main-content {
|
||||
padding: var(--spacing-sm);
|
||||
grid-area: main;
|
||||
}
|
||||
|
|
@ -4003,7 +3994,7 @@ img:not([src]), img[src=''] {
|
|||
height: 16px;
|
||||
background-color: var(--highlight);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 2px 6px rgb(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
button {
|
||||
|
|
@ -4043,4 +4034,4 @@ img:not([src]), img[src=''] {
|
|||
.player-controls .progress-container {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,47 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
plugins: [
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,json}"],
|
||||
cleanupOutdatedCaches: true,
|
||||
// Define runtime caching strategies
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ request }) => request.destination === "image",
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "images",
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 24 * 60 * 60, // 60 Days
|
||||
},
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
plugins: [
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'],
|
||||
cleanupOutdatedCaches: true,
|
||||
// Define runtime caching strategies
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ request }) => request.destination === 'image',
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'images',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 24 * 60 * 60, // 60 Days
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ request }) => request.destination === 'audio' || request.destination === 'video',
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'media',
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 24 * 60 * 60, // 60 Days
|
||||
},
|
||||
rangeRequests: true, // Support scrubbing
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ request }) =>
|
||||
request.destination === "audio" ||
|
||||
request.destination === "video",
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "media",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 24 * 60 * 60, // 60 Days
|
||||
},
|
||||
rangeRequests: true, // Support scrubbing
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
includeAssets: ["instances.json", "discord.html"],
|
||||
manifest: false, // Use existing public/manifest.json
|
||||
}),
|
||||
],
|
||||
includeAssets: ['instances.json', 'discord.html'],
|
||||
manifest: false, // Use existing public/manifest.json
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue