style: auto-fix linting issues

This commit is contained in:
JulienMaille 2026-01-10 23:28:10 +00:00 committed by Julien Maille
parent caea2fc707
commit dc3ae80d9f
35 changed files with 5193 additions and 4240 deletions

View file

@ -1,22 +1,25 @@
# Development # Development
This project uses [Vite](https://vitejs.dev/) for local development and optimized builds. This project uses [Vite](https://vitejs.dev/) for local development and optimized builds.
### Prerequisites ### Prerequisites
- [Node.js](https://nodejs.org/) (Version 20+ or 22+ recommended) - [Node.js](https://nodejs.org/) (Version 20+ or 22+ recommended)
### Getting Started ### Getting Started
1. Install dependencies: 1. Install dependencies:
```bash ```bash
npm install npm install
``` ```
2. Start the development server: 2. Start the development server:
```bash ```bash
npm run dev npm run dev
``` ```
The app will be available at `http://localhost:5173/`. The app will be available at `http://localhost:5173/`.
### Why Vite? ### Why Vite?
- **Instant Updates**: Support for Hot Module Replacement (HMR) means changes to JS/CSS are reflected instantly in the browser. - **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. - **Dependency Management**: No more manual path tracking or broken internal imports.
- **Automated PWA**: Service Worker generation and asset hashing are handled automatically. - **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: We use a standard stack to ensure code quality and consistency:
- **JS**: [ESLint](https://eslint.org/) - **JS**: [ESLint](https://eslint.org/)
- **CSS**: [Stylelint](https://stylelint.io/) - **CSS**: [Stylelint](https://stylelint.io/)
- **HTML**: [HTMLHint](https://htmlhint.com/) - **HTML**: [HTMLHint](https://htmlhint.com/)
- **Formatting**: [Prettier](https://prettier.io/) - **Formatting**: [Prettier](https://prettier.io/)
### Commands ### Commands
- **Check everything:** `npm run lint` - **Check everything:** `npm run lint`
- **Auto-format code:** `npm run format` (Runs Prettier) - **Auto-format code:** `npm run format` (Runs Prettier)
- **Fix JS issues:** `npm run lint:js -- --fix` - **Fix JS issues:** `npm run lint:js -- --fix`
- **Fix CSS issues:** `npm run lint:css -- --fix` - **Fix CSS issues:** `npm run lint:css -- --fix`
> [!IMPORTANT] > [!IMPORTANT]
> A GitHub Action automatically runs these checks on every push and pull request. Please ensure `npm run lint` passes before committing. > A GitHub Action automatically runs these checks on every push and pull request. Please ensure `npm run lint` passes before committing.
## Project Structure ## Project Structure
- `/js`: Application source code. - `/js`: Application source code.
- `/public`: Static assets (images, manifest, instances.json) that are copied directly to the build folder. - `/public`: Static assets (images, manifest, instances.json) that are copied directly to the build folder.
- `index.html`: The entry point of the application. - `index.html`: The entry point of the application.
- `vite.config.js`: Build and PWA configuration. - `vite.config.js`: Build and PWA configuration.
## Deployment ## Deployment
Deployment is automated via **GitHub Actions**. Deployment is automated via **GitHub Actions**.
> [!NOTE] > [!NOTE]

View file

@ -1,24 +1,22 @@
API: API:
| Provider | Instance URL | | Provider | Instance URL |
|----------------|---------------------------------| |----------------|---------------------------------|
| Monochrome | https://monochrome-api.samidy.com ([NOTE](https://rentry.co/monochromeapi)) | | Monochrome | https://monochrome-api.samidy.com ([NOTE](https://rentry.co/monochromeapi)) |
| squid.wtf | https://triton.squid.wtf | | squid.wtf | https://triton.squid.wtf |
| Lucida (QQDL) | https://wolf.qqdl.site | | Lucida (QQDL) | https://wolf.qqdl.site |
| | https://maus.qqdl.site | | | https://maus.qqdl.site |
| | https://vogel.qqdl.site | | | https://vogel.qqdl.site |
| | https://katze.qqdl.site | | | https://katze.qqdl.site |
| | https://hund.qqdl.site | | | https://hund.qqdl.site |
| Kinoplus | https://tidal.kinoplus.online | | Kinoplus | https://tidal.kinoplus.online |
| Binimum | https://tidal-api.binimum.org | | Binimum | https://tidal-api.binimum.org |
UI: UI:
| Provider | Instance URL | | Provider | Instance URL |
|-------------------|------------------------------------| |-------------------|------------------------------------|
| Monochrome | https://monochrome.samidy.com | | Monochrome | https://monochrome.samidy.com |
| tidal-ui (bini) | https://music.binimum.org | | tidal-ui (bini) | https://music.binimum.org |
| squid.wtf | https://tidal.squid.wtf | | squid.wtf | https://tidal.squid.wtf |
| QQDL | https://tidal.qqdl.site/ | | QQDL | https://tidal.qqdl.site/ |
| Arjix | https://music.arjix.dev/ | | Arjix | https://music.arjix.dev/ |
| Spofree | https://spo.free.nf | | Spofree | https://spo.free.nf |

View file

@ -4,7 +4,6 @@
</a> </a>
</p> </p>
<h1 align="center">Monochrome</h1> <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). ### **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) [<img src="https://files.catbox.moe/94f3pq.png" alt="Monochrome UI" width="800">](https://monochrome.samidy.com/#album/378149557)
### Features ### Features
<ul> <ul>
<li>High-quality lossless audio streaming</li> <li>High-quality lossless audio streaming</li>
<li>Lyrics support with karaoke mode</li> <li>Lyrics support with karaoke mode</li>
@ -30,13 +30,12 @@
<li>Keyboard shortcuts for power users</li> <li>Keyboard shortcuts for power users</li>
</ul> </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> <br>
[![GitHub stars](https://img.shields.io/github/stars/SamidyFR/monochrome?style=for-the-badge&color=ffffff&labelColor=000000)](https://github.com/SamidyFR/monochrome/stargazers) [![GitHub stars](https://img.shields.io/github/stars/SamidyFR/monochrome?style=for-the-badge&color=ffffff&labelColor=000000)](https://github.com/SamidyFR/monochrome/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/SamidyFR/monochrome?style=for-the-badge&color=ffffff&labelColor=000000)](https://github.com/SamidyFR/monochrome/forks) [![GitHub forks](https://img.shields.io/github/forks/SamidyFR/monochrome?style=for-the-badge&color=ffffff&labelColor=000000)](https://github.com/SamidyFR/monochrome/forks)
[![GitHub issues](https://img.shields.io/github/issues/SamidyFR/monochrome?style=for-the-badge&color=ffffff&labelColor=000000)](https://github.com/SamidyFR/monochrome/issues) [![GitHub issues](https://img.shields.io/github/issues/SamidyFR/monochrome?style=for-the-badge&color=ffffff&labelColor=000000)](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) [<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)

View file

@ -1,25 +1,25 @@
import js from "@eslint/js"; import js from '@eslint/js';
import globals from "globals"; import globals from 'globals';
import prettierConfig from "eslint-config-prettier"; import prettierConfig from 'eslint-config-prettier';
export default [ export default [
{ {
ignores: ["dist/", "node_modules/", "legacy/", "sw.js"] ignores: ['dist/', 'node_modules/', 'legacy/', 'sw.js'],
}, },
js.configs.recommended, js.configs.recommended,
prettierConfig, prettierConfig,
{ {
languageOptions: { languageOptions: {
ecmaVersion: 2022, ecmaVersion: 2022,
sourceType: "module", sourceType: 'module',
globals: { globals: {
...globals.browser, ...globals.browser,
...globals.node ...globals.node,
} },
}, },
rules: { rules: {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
"no-console": ["warn", { "allow": ["warn", "error"] }] 'no-console': ['warn', { allow: ['warn', 'error'] }],
} },
} },
]; ];

View file

@ -3,12 +3,14 @@
Follow these steps to enable cross-device synchronization for your library, history, and settings using your own Firebase project. 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. Create a Firebase Project
1. Go to the [Firebase Console](https://console.firebase.google.com/). 1. Go to the [Firebase Console](https://console.firebase.google.com/).
2. Click **Add project** and give it a name (e.g., "Monochrome Sync"). 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. 3. (Optional) Disable Gemini and Google Analytics if you want to keep it simple.
4. Click **Create project**. 4. Click **Create project**.
## 2. Enable Authentication ## 2. Enable Authentication
1. In the left sidebar, click **Build** > **Authentication**. 1. In the left sidebar, click **Build** > **Authentication**.
2. Click **Get Started**. 2. Click **Get Started**.
3. Go to the **Sign-in method** tab. 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**. 5. Set your project support email and click **Save**.
### 2.1 Authorized Domains (CRITICAL) ### 2.1 Authorized Domains (CRITICAL)
Firebase will block login attempts from unknown domains. Firebase will block login attempts from unknown domains.
1. In the **Authentication** section, go to the **Settings** tab. 1. In the **Authentication** section, go to the **Settings** tab.
2. Click **Authorized domains** in the left sub-menu. 2. Click **Authorized domains** in the left sub-menu.
3. Click **Add domain**. 3. Click **Add domain**.
4. Add your hosting domain (e.g., `julienmaille.github.io`). 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 ## 3. Enable Realtime Database
1. In the left sidebar, click **Build** > **Realtime Database**. 1. In the left sidebar, click **Build** > **Realtime Database**.
2. Click **Create Database**. 2. Click **Create Database**.
3. Choose a location near you and click **Next**. 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. Select **Start in test mode** (we will change the rules in the next step) and click **Enable**.
## 4. Set Security Rules ## 4. Set Security Rules
1. In the Realtime Database section, go to the **Rules** tab. 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: 2. Replace the existing rules with the following to ensure users can only see their own data:
```json ```json
{ {
"rules": { "rules": {
"users": { "users": {
"$uid": { "$uid": {
".read": "$uid === auth.uid", ".read": "$uid === auth.uid",
".write": "$uid === auth.uid" ".write": "$uid === auth.uid"
} }
}, },
"public_playlists": { "public_playlists": {
".read": true, ".read": true,
"$playlistId": { "$playlistId": {
".write": "auth != null && (!data.exists() || data.child('uid').val() === auth.uid)" ".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**. 3. Click **Publish**.
## 5. Get Your Configuration ## 5. Get Your Configuration
1. Click the gear icon (⚙️) next to "Project Overview" and select **Project settings**. 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 (`</>`)**. 2. In the **General** tab, scroll down to "Your apps" and click the **Web icon (`</>`)**.
3. Register the app (e.g., "Monochrome App"). 3. Register the app (e.g., "Monochrome App").
4. You will see a `firebaseConfig` object. It looks like this: 4. You will see a `firebaseConfig` object. It looks like this:
```javascript ```javascript
const firebaseConfig = { const firebaseConfig = {
apiKey: "AIzaSy...", apiKey: 'AIzaSy...',
authDomain: "your-project.firebaseapp.com", authDomain: 'your-project.firebaseapp.com',
databaseURL: "https://your-project.firebaseio.com", databaseURL: 'https://your-project.firebaseio.com',
projectId: "your-project", projectId: 'your-project',
storageBucket: "your-project.appspot.com", storageBucket: 'your-project.appspot.com',
messagingSenderId: "...", messagingSenderId: '...',
appId: "..." appId: '...',
}; };
``` ```
5. **Copy only the part with the curly braces `{ ... }`**. 5. **Copy only the part with the curly braces `{ ... }`**.
## 6. Configure Monochrome ## 6. Configure Monochrome
1. Open the Monochrome app and go to **Settings**. 1. Open the Monochrome app and go to **Settings**.
2. Find the **Firebase Configuration** section. 2. Find the **Firebase Configuration** section.
3. Paste the JSON object you copied into the textarea. 3. Paste the JSON object you copied into the textarea.

2718
index.html

File diff suppressed because it is too large Load diff

204
js/api.js
View file

@ -10,14 +10,17 @@ export class LosslessAPI {
this.settings = settings; this.settings = settings;
this.cache = new APICache({ this.cache = new APICache({
maxSize: 200, maxSize: 200,
ttl: 1000 * 60 * 30 ttl: 1000 * 60 * 30,
}); });
this.streamCache = new Map(); this.streamCache = new Map();
setInterval(() => { setInterval(
this.cache.clearExpired(); () => {
this.pruneStreamCache(); this.cache.clearExpired();
}, 1000 * 60 * 5); this.pruneStreamCache();
},
1000 * 60 * 5
);
} }
pruneStreamCache() { pruneStreamCache() {
@ -39,28 +42,26 @@ export class LosslessAPI {
let lastError = null; let lastError = null;
for (const baseUrl of instances) { for (const baseUrl of instances) {
const url = baseUrl.endsWith('/') const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`;
? `${baseUrl}${relativePath.substring(1)}`
: `${baseUrl}${relativePath}`;
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { try {
const response = await fetch(url, { signal: options.signal }); const response = await fetch(url, { signal: options.signal });
if (response.status === 429) { if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After'); const retryAfter = response.headers.get('Retry-After');
let waitTime = 2000 * attempt; // Default exponential backoff let waitTime = 2000 * attempt; // Default exponential backoff
if (retryAfter) { if (retryAfter) {
const seconds = parseInt(retryAfter, 10); const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) { if (!isNaN(seconds)) {
waitTime = seconds * 1000; waitTime = seconds * 1000;
} }
} }
console.warn(`Rate limit hit. Waiting ${waitTime}ms before retry ${attempt}/${maxRetries}...`); console.warn(`Rate limit hit. Waiting ${waitTime}ms before retry ${attempt}/${maxRetries}...`);
await delay(waitTime); await delay(waitTime);
continue; continue;
} }
if (response.ok) { if (response.ok) {
@ -89,7 +90,6 @@ export class LosslessAPI {
lastError = new Error(`Request failed with status ${response.status}`); lastError = new Error(`Request failed with status ${response.status}`);
break; break;
} catch (error) { } catch (error) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
throw error; throw error;
@ -140,7 +140,7 @@ export class LosslessAPI {
items, items,
limit: section?.limit ?? items.length, limit: section?.limit ?? items.length,
offset: section?.offset ?? 0, 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) { for (const album of albums) {
// Key based on title and numberOfTracks (excluding duration and explicit) // Key based on title and numberOfTracks (excluding duration and explicit)
const key = JSON.stringify([album.title, album.numberOfTracks || 0]); const key = JSON.stringify([album.title, album.numberOfTracks || 0]);
if (unique.has(key)) { if (unique.has(key)) {
const existing = unique.get(key); const existing = unique.get(key);
// Priority 1: Explicit // Priority 1: Explicit
if (album.explicit && !existing.explicit) { if (album.explicit && !existing.explicit) {
unique.set(key, album); unique.set(key, album);
@ -263,7 +263,7 @@ export class LosslessAPI {
unique.set(key, album); unique.set(key, album);
} }
} }
return Array.from(unique.values()); return Array.from(unique.values());
} }
@ -277,7 +277,7 @@ export class LosslessAPI {
const normalized = this.normalizeSearchResponse(data, 'tracks'); const normalized = this.normalizeSearchResponse(data, 'tracks');
const result = { const result = {
...normalized, ...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); await this.cache.set('search_tracks', query, result);
@ -299,7 +299,7 @@ export class LosslessAPI {
const normalized = this.normalizeSearchResponse(data, 'artists'); const normalized = this.normalizeSearchResponse(data, 'artists');
const result = { const result = {
...normalized, ...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); 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 response = await this.fetchWithRetry(`/search/?al=${encodeURIComponent(query)}`, options);
const data = await response.json(); const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'albums'); 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 = { const result = {
...normalized, ...normalized,
items: this.deduplicateAlbums(preparedItems) items: this.deduplicateAlbums(preparedItems),
}; };
await this.cache.set('search_albums', query, result); await this.cache.set('search_albums', query, result);
@ -344,7 +344,7 @@ export class LosslessAPI {
const normalized = this.normalizeSearchResponse(data, 'playlists'); const normalized = this.normalizeSearchResponse(data, 'playlists');
const result = { const result = {
...normalized, ...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); await this.cache.set('search_playlists', query, result);
@ -362,27 +362,27 @@ export class LosslessAPI {
const response = await this.fetchWithRetry(`/album/?id=${id}`); const response = await this.fetchWithRetry(`/album/?id=${id}`);
const jsonData = await response.json(); const jsonData = await response.json();
// Unwrap the data property if it exists // Unwrap the data property if it exists
const data = jsonData.data || jsonData; const data = jsonData.data || jsonData;
let album, tracksSection; let album, tracksSection;
if (data && typeof data === 'object' && !Array.isArray(data)) { if (data && typeof data === 'object' && !Array.isArray(data)) {
// Check for album metadata at root level // Check for album metadata at root level
if ('numberOfTracks' in data || 'title' in data) { if ('numberOfTracks' in data || 'title' in data) {
album = this.prepareAlbum(data); album = this.prepareAlbum(data);
} }
// Set tracksSection if items exist // Set tracksSection if items exist
if ('items' in data) { if ('items' in data) {
tracksSection = data; tracksSection = data;
// If we still don't have album but have items with tracks, try to extract album from first track // 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) { if (!album && data.items && data.items.length > 0) {
const firstItem = data.items[0]; const firstItem = data.items[0];
const track = firstItem.item || firstItem; const track = firstItem.item || firstItem;
// Check if track has album property // Check if track has album property
if (track && track.album) { if (track && track.album) {
album = this.prepareAlbum(track.album); album = this.prepareAlbum(track.album);
@ -390,7 +390,7 @@ export class LosslessAPI {
} }
} }
} }
if (!album) throw new Error('Album not found'); if (!album) throw new Error('Album not found');
// If album exists but has no artist, try to extract from tracks // 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 }; const result = { album, tracks };
await this.cache.set('album', id, result); await this.cache.set('album', id, result);
@ -429,10 +429,10 @@ export class LosslessAPI {
const response = await this.fetchWithRetry(`/playlist/?id=${id}`); const response = await this.fetchWithRetry(`/playlist/?id=${id}`);
const jsonData = await response.json(); const jsonData = await response.json();
// Unwrap the data property if it exists // Unwrap the data property if it exists
const data = jsonData.data || jsonData; const data = jsonData.data || jsonData;
let playlist = null; let playlist = null;
let tracksSection = null; let tracksSection = null;
@ -452,7 +452,10 @@ export class LosslessAPI {
for (const entry of entries) { for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue; 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; 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 // 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)) { 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)) { if (entry && typeof entry === 'object' && ('uuid' in entry || 'numberOfTracks' in entry)) {
playlist = entry; playlist = entry;
break; break;
@ -474,25 +477,25 @@ export class LosslessAPI {
if (!playlist) throw new Error('Playlist not found'); 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 // Handle pagination if there are more tracks
if (playlist.numberOfTracks > tracks.length) { if (playlist.numberOfTracks > tracks.length) {
let offset = 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) { while (tracks.length < playlist.numberOfTracks && tracks.length < SAFE_MAX_TRACKS) {
try { try {
const nextResponse = await this.fetchWithRetry(`/playlist/?id=${id}&offset=${offset}`); const nextResponse = await this.fetchWithRetry(`/playlist/?id=${id}&offset=${offset}`);
const nextJson = await nextResponse.json(); const nextJson = await nextResponse.json();
const nextData = nextJson.data || nextJson; const nextData = nextJson.data || nextJson;
let nextItems = []; let nextItems = [];
if (nextData.items) { if (nextData.items) {
nextItems = nextData.items; nextItems = nextData.items;
} else if (Array.isArray(nextData)) { } 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)) { if (entry && typeof entry === 'object' && 'items' in entry && Array.isArray(entry.items)) {
nextItems = entry.items; nextItems = entry.items;
break; break;
@ -502,7 +505,7 @@ export class LosslessAPI {
if (!nextItems || nextItems.length === 0) break; 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; if (preparedItems.length === 0) break;
// Safeguard: If API ignores offset, it returns the first page again. // Safeguard: If API ignores offset, it returns the first page again.
@ -513,7 +516,6 @@ export class LosslessAPI {
tracks = tracks.concat(preparedItems); tracks = tracks.concat(preparedItems);
offset += preparedItems.length; offset += preparedItems.length;
} catch (error) { } catch (error) {
console.error(`Error fetching playlist tracks at offset ${offset}:`, error); console.error(`Error fetching playlist tracks at offset ${offset}:`, error);
break; break;
@ -533,23 +535,23 @@ export class LosslessAPI {
const response = await this.fetchWithRetry(`/mix/?id=${id}`, { type: 'api' }); const response = await this.fetchWithRetry(`/mix/?id=${id}`, { type: 'api' });
const data = await response.json(); const data = await response.json();
const mixData = data.mix; const mixData = data.mix;
const items = data.items || []; const items = data.items || [];
if (!mixData) { if (!mixData) {
throw new Error('Mix metadata not found'); 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 = { const mix = {
id: mixData.id, id: mixData.id,
title: mixData.title, title: mixData.title,
subTitle: mixData.subTitle, subTitle: mixData.subTitle,
description: mixData.description, description: mixData.description,
mixType: mixData.mixType, 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 }; const result = { mix, tracks };
@ -563,11 +565,11 @@ export class LosslessAPI {
const [primaryResponse, contentResponse] = await Promise.all([ const [primaryResponse, contentResponse] = await Promise.all([
this.fetchWithRetry(`/artist/?id=${artistId}`), 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(); const primaryJsonData = await primaryResponse.json();
// Unwrap data property if it exists, then unwrap artist property if it exists // Unwrap data property if it exists, then unwrap artist property if it exists
let primaryData = primaryJsonData.data || primaryJsonData; let primaryData = primaryJsonData.data || primaryJsonData;
const rawArtist = primaryData.artist || (Array.isArray(primaryData) ? primaryData[0] : primaryData); const rawArtist = primaryData.artist || (Array.isArray(primaryData) ? primaryData[0] : primaryData);
@ -577,7 +579,7 @@ export class LosslessAPI {
const artist = { const artist = {
...this.prepareArtist(rawArtist), ...this.prepareArtist(rawArtist),
picture: rawArtist.picture || primaryData.cover || null, picture: rawArtist.picture || primaryData.cover || null,
name: rawArtist.name || 'Unknown Artist' name: rawArtist.name || 'Unknown Artist',
}; };
const contentJsonData = await contentResponse.json(); const contentJsonData = await contentResponse.json();
@ -588,15 +590,15 @@ export class LosslessAPI {
const albumMap = new Map(); const albumMap = new Map();
const trackMap = new Map(); const trackMap = new Map();
const isTrack = v => v?.id && v.duration && v.album; const isTrack = (v) => v?.id && v.duration && v.album;
const isAlbum = v => v?.id && 'numberOfTracks' in v; const isAlbum = (v) => v?.id && 'numberOfTracks' in v;
const scan = (value, visited = new Set()) => { const scan = (value, visited = new Set()) => {
if (!value || typeof value !== 'object' || visited.has(value)) return; if (!value || typeof value !== 'object' || visited.has(value)) return;
visited.add(value); visited.add(value);
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach(item => scan(item, visited)); value.forEach((item) => scan(item, visited));
return; return;
} }
@ -604,21 +606,22 @@ export class LosslessAPI {
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item)); if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(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 // Attempt to find more albums/EPs via search since the direct feed might be limited
try { try {
const searchResults = await this.searchAlbums(artist.name); const searchResults = await this.searchAlbums(artist.name);
if (searchResults && searchResults.items) { if (searchResults && searchResults.items) {
const numericArtistId = Number(artistId); const numericArtistId = Number(artistId);
for (const item of searchResults.items) { for (const item of searchResults.items) {
const itemArtistId = item.artist?.id; const itemArtistId = item.artist?.id;
const matchesArtist = itemArtistId === numericArtistId || const matchesArtist =
(Array.isArray(item.artists) && item.artists.some(a => a.id === numericArtistId)); itemArtistId === numericArtistId ||
(Array.isArray(item.artists) && item.artists.some((a) => a.id === numericArtistId));
if (matchesArtist && !albumMap.has(item.id)) { if (matchesArtist && !albumMap.has(item.id)) {
albumMap.set(item.id, item); albumMap.set(item.id, item);
@ -630,15 +633,12 @@ export class LosslessAPI {
} }
const rawReleases = Array.from(albumMap.values()); const rawReleases = Array.from(albumMap.values());
const allReleases = this.deduplicateAlbums(rawReleases).sort((a, b) => const allReleases = this.deduplicateAlbums(rawReleases).sort(
new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0) (a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
); );
const eps = allReleases.filter(a => const eps = allReleases.filter((a) => a.type === 'EP' || a.type === 'SINGLE');
a.type === 'EP' || const albums = allReleases.filter((a) => !eps.includes(a));
a.type === 'SINGLE'
);
const albums = allReleases.filter(a => !eps.includes(a));
const tracks = Array.from(trackMap.values()) const tracks = Array.from(trackMap.values())
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0)) .sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
@ -650,8 +650,6 @@ export class LosslessAPI {
return result; return result;
} }
async getSimilarArtists(artistId) { async getSimilarArtists(artistId) {
const cached = await this.cache.get('similar_artists', artistId); const cached = await this.cache.get('similar_artists', artistId);
if (cached) return cached; if (cached) return cached;
@ -659,12 +657,12 @@ export class LosslessAPI {
try { try {
const response = await this.fetchWithRetry(`/artist/similar/?id=${artistId}`, { type: 'api' }); const response = await this.fetchWithRetry(`/artist/similar/?id=${artistId}`, { type: 'api' });
const data = await response.json(); const data = await response.json();
// Handle various response structures // Handle various response structures
const items = data.artists || data.items || data.data || (Array.isArray(data) ? data : []); 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); await this.cache.set('similar_artists', artistId, result);
return result; return result;
} catch (e) { } catch (e) {
@ -680,11 +678,11 @@ export class LosslessAPI {
try { try {
const response = await this.fetchWithRetry(`/album/similar/?id=${albumId}`, { type: 'api' }); const response = await this.fetchWithRetry(`/album/similar/?id=${albumId}`, { type: 'api' });
const data = await response.json(); const data = await response.json();
const items = data.items || data.albums || data.data || (Array.isArray(data) ? data : []); 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); await this.cache.set('similar_albums', albumId, result);
return result; return result;
} catch (e) { } catch (e) {
@ -694,23 +692,23 @@ export class LosslessAPI {
} }
normalizeTrackResponse(apiResponse) { normalizeTrackResponse(apiResponse) {
if (!apiResponse || typeof apiResponse !== 'object') { if (!apiResponse || typeof apiResponse !== 'object') {
return apiResponse; 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') { async getTrack(id, quality = 'LOSSLESS') {
const cacheKey = `${id}_${quality}`; const cacheKey = `${id}_${quality}`;
const cached = await this.cache.get('track', cacheKey); const cached = await this.cache.get('track', cacheKey);
@ -765,7 +763,7 @@ export class LosslessAPI {
const response = await fetch(streamUrl, { const response = await fetch(streamUrl, {
cache: 'no-store', cache: 'no-store',
signal: options.signal signal: options.signal,
}); });
if (!response.ok) { if (!response.ok) {
@ -793,7 +791,7 @@ export class LosslessAPI {
onProgress({ onProgress({
stage: 'downloading', stage: 'downloading',
receivedBytes, receivedBytes,
totalBytes: totalBytes || undefined totalBytes: totalBytes || undefined,
}); });
} }
} }
@ -805,7 +803,7 @@ export class LosslessAPI {
onProgress({ onProgress({
stage: 'downloading', stage: 'downloading',
receivedBytes: blob.size, receivedBytes: blob.size,
totalBytes: blob.size totalBytes: blob.size,
}); });
} }
} }
@ -815,7 +813,7 @@ export class LosslessAPI {
if (onProgress) { if (onProgress) {
onProgress({ onProgress({
stage: 'processing', stage: 'processing',
message: 'Adding metadata...' message: 'Adding metadata...',
}); });
} }
blob = await addMetadataToAudio(blob, track, this, quality); blob = await addMetadataToAudio(blob, track, this, quality);
@ -826,7 +824,7 @@ export class LosslessAPI {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
throw error; throw error;
} }
console.error("Download failed:", error); console.error('Download failed:', error);
if (error.message === RATE_LIMIT_ERROR_MESSAGE) { if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
throw error; throw error;
} }
@ -871,7 +869,7 @@ export class LosslessAPI {
getCacheStats() { getCacheStats() {
return { return {
...this.cache.getCacheStats(), ...this.cache.getCacheStats(),
streamUrls: this.streamCache.size streamUrls: this.streamCache.size,
}; };
} }
} }

717
js/app.js

File diff suppressed because it is too large Load diff

View file

@ -34,9 +34,7 @@ export class APICache {
} }
generateKey(type, params) { generateKey(type, params) {
const paramString = typeof params === 'object' const paramString = typeof params === 'object' ? JSON.stringify(params) : String(params);
? JSON.stringify(params)
: String(params);
return `${type}:${paramString}`; return `${type}:${paramString}`;
} }
@ -71,7 +69,7 @@ export class APICache {
const entry = { const entry = {
key, key,
data, data,
timestamp: Date.now() timestamp: Date.now(),
}; };
this.memoryCache.set(key, entry); 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) { if (this.db) {
try { try {
@ -174,7 +172,7 @@ export class APICache {
return { return {
memoryEntries: this.memoryCache.size, memoryEntries: this.memoryCache.size,
maxSize: this.maxSize, maxSize: this.maxSize,
ttl: this.ttl ttl: this.ttl,
}; };
} }
} }

View file

@ -12,7 +12,7 @@ export class MusicDatabase {
const request = indexedDB.open(this.dbName, this.version); const request = indexedDB.open(this.dbName, this.version);
request.onerror = (event) => { request.onerror = (event) => {
console.error("Database error:", event.target.error); console.error('Database error:', event.target.error);
reject(event.target.error); reject(event.target.error);
}; };
@ -99,7 +99,7 @@ export class MusicDatabase {
const transaction = db.transaction(storeName, 'readonly'); const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName); const store = transaction.objectStore(storeName);
const index = store.index('timestamp'); const index = store.index('timestamp');
const request = index.getAll(); const request = index.getAll();
request.onsuccess = () => { request.onsuccess = () => {
// Return reversed (newest first) // Return reversed (newest first)
@ -162,7 +162,7 @@ export class MusicDatabase {
// Base properties to keep // Base properties to keep
const base = { const base = {
id: item.id, id: item.id,
addedAt: item.addedAt || null addedAt: item.addedAt || null,
}; };
if (type === 'track') { if (type === 'track') {
@ -172,18 +172,20 @@ export class MusicDatabase {
duration: item.duration, duration: item.duration,
explicit: item.explicit, explicit: item.explicit,
// Keep minimal artist info // 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 // Keep minimal album info
album: item.album ? { album: item.album
id: item.album.id, ? {
cover: item.album.cover, id: item.album.id,
releaseDate: item.album.releaseDate || null, cover: item.album.cover,
vibrantColor: item.album.vibrantColor || null releaseDate: item.album.releaseDate || null,
} : null, vibrantColor: item.album.vibrantColor || null,
}
: null,
// Fallback date // Fallback date
streamStartDate: item.streamStartDate || null, streamStartDate: item.streamStartDate || null,
// Keep version if exists // Keep version if exists
version: item.version || null version: item.version || null,
}; };
} }
@ -195,10 +197,14 @@ export class MusicDatabase {
releaseDate: item.releaseDate || null, releaseDate: item.releaseDate || null,
explicit: item.explicit, explicit: item.explicit,
// UI uses singular 'artist' // 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 // Keep type and track count for UI labels
type: item.type || null, type: item.type || null,
numberOfTracks: item.numberOfTracks numberOfTracks: item.numberOfTracks,
}; };
} }
@ -206,7 +212,7 @@ export class MusicDatabase {
return { return {
...base, ...base,
name: item.name, 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 // UI checks squareImage || image || uuid
image: item.image || item.squareImage || item.cover || null, image: item.image || item.squareImage || item.cover || null,
numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0), 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, subTitle: item.subTitle,
description: item.description, description: item.description,
mixType: item.mixType, mixType: item.mixType,
cover: item.cover cover: item.cover,
}; };
} }
@ -247,13 +253,13 @@ export class MusicDatabase {
const userPlaylists = await this.getPlaylists(); const userPlaylists = await this.getPlaylists();
const data = { const data = {
favorites_tracks: tracks.map(t => this._minifyItem('track', t)), favorites_tracks: tracks.map((t) => this._minifyItem('track', t)),
favorites_albums: albums.map(a => this._minifyItem('album', a)), favorites_albums: albums.map((a) => this._minifyItem('album', a)),
favorites_artists: artists.map(a => this._minifyItem('artist', a)), favorites_artists: artists.map((a) => this._minifyItem('artist', a)),
favorites_playlists: playlists.map(p => this._minifyItem('playlist', p)), favorites_playlists: playlists.map((p) => this._minifyItem('playlist', p)),
favorites_mixes: mixes.map(m => this._minifyItem('mix', m)), favorites_mixes: mixes.map((m) => this._minifyItem('mix', m)),
history_tracks: history.map(t => this._minifyItem('track', t)), history_tracks: history.map((t) => this._minifyItem('track', t)),
user_playlists: userPlaylists user_playlists: userPlaylists,
}; };
return data; return data;
} }
@ -267,7 +273,6 @@ export class MusicDatabase {
// This allows partial updates (e.g. library only) // This allows partial updates (e.g. library only)
if (items === undefined) return; if (items === undefined) return;
let itemsArray = Array.isArray(items) ? items : Object.values(items || {}); let itemsArray = Array.isArray(items) ? items : Object.values(items || {});
const transaction = db.transaction(storeName, 'readwrite'); const transaction = db.transaction(storeName, 'readwrite');
@ -302,9 +307,9 @@ export class MusicDatabase {
const playlist = { const playlist = {
id: id, id: id,
name: name, name: name,
tracks: tracks.map(t => this._minifyItem('track', t)), tracks: tracks.map((t) => this._minifyItem('track', t)),
cover: cover, cover: cover,
createdAt: Date.now() createdAt: Date.now(),
}; };
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist; return playlist;
@ -315,7 +320,7 @@ export class MusicDatabase {
if (!playlist) throw new Error('Playlist not found'); if (!playlist) throw new Error('Playlist not found');
playlist.tracks = playlist.tracks || []; playlist.tracks = playlist.tracks || [];
const minifiedTrack = this._minifyItem('track', track); 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); playlist.tracks.push(minifiedTrack);
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist; return playlist;
@ -325,7 +330,7 @@ export class MusicDatabase {
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId)); const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
if (!playlist) throw new Error('Playlist not found'); if (!playlist) throw new Error('Playlist not found');
playlist.tracks = playlist.tracks || []; 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)); await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist; return playlist;
} }

View file

@ -1,5 +1,14 @@
//js/downloads.js //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 { lyricsSettings } from './storage.js';
import { addMetadataToAudio } from './metadata.js'; import { addMetadataToAudio } from './metadata.js';
@ -105,16 +114,12 @@ export function updateDownloadProgress(trackId, progress) {
const statusEl = taskEl.querySelector('.download-status'); const statusEl = taskEl.querySelector('.download-status');
if (progress.stage === 'downloading') { if (progress.stage === 'downloading') {
const percent = progress.totalBytes const percent = progress.totalBytes ? Math.round((progress.receivedBytes / progress.totalBytes) * 100) : 0;
? Math.round((progress.receivedBytes / progress.totalBytes) * 100)
: 0;
progressFill.style.width = `${percent}%`; progressFill.style.width = `${percent}%`;
const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1); const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
const totalMB = progress.totalBytes const totalMB = progress.totalBytes ? (progress.totalBytes / (1024 * 1024)).toFixed(1) : '?';
? (progress.totalBytes / (1024 * 1024)).toFixed(1)
: '?';
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`; statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
} }
@ -161,7 +166,7 @@ function removeDownloadTask(trackId) {
taskEl.remove(); taskEl.remove();
downloadTasks.delete(trackId); downloadTasks.delete(trackId);
if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) { if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) {
downloadNotificationContainer.remove(); downloadNotificationContainer.remove();
downloadNotificationContainer = null; downloadNotificationContainer = null;
} }
@ -202,12 +207,12 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch track: ${response.status}`); throw new Error(`Failed to fetch track: ${response.status}`);
} }
let blob = await response.blob(); let blob = await response.blob();
// Add metadata to the blob // Add metadata to the blob
blob = await addMetadataToAudio(blob, track, api, quality); blob = await addMetadataToAudio(blob, track, api, quality);
return blob; return blob;
} }
@ -218,32 +223,32 @@ async function generateAndDownloadZip(zip, filename, notification, progressTotal
// Use the pre-acquired file handle for streaming (Chrome/Edge/Opera) // Use the pre-acquired file handle for streaming (Chrome/Edge/Opera)
if (fileHandle) { if (fileHandle) {
const writable = await fileHandle.createWritable(); const writable = await fileHandle.createWritable();
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
zip.generateInternalStream({ zip.generateInternalStream({
type: 'uint8array', type: 'uint8array',
compression: 'STORE', compression: 'STORE',
streamFiles: true streamFiles: true,
}) })
.on('data', (chunk, metadata) => { .on('data', (chunk, metadata) => {
writable.write(chunk); writable.write(chunk);
}) })
.on('error', (err) => { .on('error', (err) => {
writable.close(); writable.close();
reject(err); reject(err);
}) })
.on('end', () => { .on('end', () => {
writable.close(); writable.close();
resolve(); resolve();
}) })
.resume(); .resume();
}); });
} else { } else {
// Fallback for Firefox/Safari or if user cancelled/API not available // Fallback for Firefox/Safari or if user cancelled/API not available
const zipBlob = await zip.generateAsync({ const zipBlob = await zip.generateAsync({
type: 'blob', type: 'blob',
compression: 'STORE', compression: 'STORE',
streamFiles: true streamFiles: true,
}); });
const url = URL.createObjectURL(zipBlob); const url = URL.createObjectURL(zipBlob);
@ -272,10 +277,12 @@ async function initializeZipDownload(defaultName, useFilePicker = false) {
try { try {
fileHandle = await window.showSaveFilePicker({ fileHandle = await window.showSaveFilePicker({
suggestedName: `${defaultName}.zip`, suggestedName: `${defaultName}.zip`,
types: [{ types: [
description: 'ZIP Archive', {
accept: { 'application/zip': ['.zip'] } description: 'ZIP Archive',
}] accept: { 'application/zip': ['.zip'] },
},
],
}); });
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return null; // User cancelled if (err.name === 'AbortError') return null; // User cancelled
@ -285,7 +292,17 @@ async function initializeZipDownload(defaultName, useFilePicker = false) {
return { zip, fileHandle }; 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 { abortController } = bulkDownloadTasks.get(notification);
const signal = abortController.signal; 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) { 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 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}', { const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
albumTitle: album.title, albumTitle: album.title,
albumArtist: album.artist?.name, albumArtist: album.artist?.name,
year: year year: year,
}); });
// Only prompt for save location if we have >= 20 tracks (to capture user gesture early) // 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}', { const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
albumTitle: playlist.title, albumTitle: playlist.title,
albumArtist: 'Playlist', albumArtist: 'Playlist',
year: new Date().getFullYear() year: new Date().getFullYear(),
}); });
const initResult = await initializeZipDownload(folderName, tracks.length >= 20); const initResult = await initializeZipDownload(folderName, tracks.length >= 20);
@ -372,7 +390,7 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
try { try {
// Find a representative cover for the playlist (first track with cover) // 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); const coverBlob = await getCoverBlob(api, representativeTrack?.album?.cover);
addCoverBlobToZip(zip, folderName, coverBlob); addCoverBlobToZip(zip, folderName, coverBlob);
@ -408,23 +426,28 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
try { try {
const { album: fullAlbum, tracks } = await api.getAlbum(album.id); const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover); 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}', { const releaseDateStr =
albumTitle: fullAlbum.title, fullAlbum.releaseDate ||
albumArtist: fullAlbum.artist?.name, (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
year: year 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}`; const fullFolderPath = `${rootFolder}/${albumFolder}`;
addCoverBlobToZip(zip, fullFolderPath, coverBlob); addCoverBlobToZip(zip, fullFolderPath, coverBlob);
for (const track of tracks) { for (const track of tracks) {
const filename = buildTrackFilename(track, quality); const filename = buildTrackFilename(track, quality);
try { try {
const blob = await downloadTrackBlob(track, quality, api, null, signal); const blob = await downloadTrackBlob(track, quality, api, null, signal);
zip.file(`${fullFolderPath}/${filename}`, blob); zip.file(`${fullFolderPath}/${filename}`, blob);
@ -442,14 +465,13 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
// Silent fail for lyrics in bulk // Silent fail for lyrics in bulk
} }
} }
} catch (err) { } catch (err) {
if (err.name === 'AbortError') { if (err.name === 'AbortError') {
throw err; throw err;
} }
console.error(`Failed to download track ${track.title}:`, err); console.error(`Failed to download track ${track.title}:`, err);
} }
} }
} catch (error) { } catch (error) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
throw error; throw error;
@ -545,7 +567,6 @@ function completeBulkDownload(notifEl, success = true, message = null) {
} }
export async function downloadTrackWithMetadata(track, quality, api, lyricsManager = null, abortController = null) { export async function downloadTrackWithMetadata(track, quality, api, lyricsManager = null, abortController = null) {
if (!track) { if (!track) {
alert('No track is currently playing'); alert('No track is currently playing');
return; return;
@ -556,20 +577,14 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
const controller = abortController || new AbortController(); const controller = abortController || new AbortController();
try { try {
const { taskEl } = addDownloadTask( const { taskEl } = addDownloadTask(track.id, track, filename, api, controller);
track.id,
track,
filename,
api,
controller
);
await api.downloadTrack(track.id, quality, filename, { await api.downloadTrack(track.id, quality, filename, {
signal: controller.signal, signal: controller.signal,
track: track, track: track,
onProgress: (progress) => { onProgress: (progress) => {
updateDownloadProgress(track.id, progress); updateDownloadProgress(track.id, progress);
} },
}); });
completeDownloadTask(track.id, true); completeDownloadTask(track.id, true);
@ -586,10 +601,9 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
} }
} catch (error) { } catch (error) {
if (error.name !== 'AbortError') { if (error.name !== 'AbortError') {
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE const errorMsg =
? error.message error.message === RATE_LIMIT_ERROR_MESSAGE ? error.message : 'Download failed. Please try again.';
: 'Download failed. Please try again.';
completeDownloadTask(track.id, false, errorMsg); completeDownloadTask(track.id, false, errorMsg);
} }
} }
} }

View file

@ -1,5 +1,16 @@
//js/events.js //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 { lastFMStorage, waveformSettings } from './storage.js';
import { showNotification, downloadTrackWithMetadata } from './downloads.js'; import { showNotification, downloadTrackWithMetadata } from './downloads.js';
import { lyricsSettings, downloadQualitySettings } from './storage.js'; import { lyricsSettings, downloadQualitySettings } from './storage.js';
@ -30,7 +41,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
if (player.repeatMode !== REPEAT_MODE.OFF) { if (player.repeatMode !== REPEAT_MODE.OFF) {
repeatBtn.classList.add('active'); repeatBtn.classList.add('active');
if (player.repeatMode === REPEAT_MODE.ONE) { if (player.repeatMode === REPEAT_MODE.ONE) {
repeatBtn.classList.add('repeat-one'); repeatBtn.classList.add('repeat-one');
} }
repeatBtn.title = player.repeatMode === REPEAT_MODE.ALL ? 'Repeat Queue' : '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()) { if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled()) {
scrobbler.updateNowPlaying(player.currentTrack); scrobbler.updateNowPlaying(player.currentTrack);
} }
// Resume AudioContext for waveform on mobile (iOS) // Resume AudioContext for waveform on mobile (iOS)
if (waveformGenerator.audioContext.state === 'suspended') { if (waveformGenerator.audioContext.state === 'suspended') {
waveformGenerator.audioContext.resume(); waveformGenerator.audioContext.resume();
} }
updateWaveform(); updateWaveform();
} }
@ -121,9 +132,8 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const mode = player.toggleRepeat(); const mode = player.toggleRepeat();
repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF); repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE); repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE);
repeatBtn.title = mode === REPEAT_MODE.OFF repeatBtn.title =
? 'Repeat' mode === REPEAT_MODE.OFF ? 'Repeat' : mode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One';
: (mode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One');
}); });
// Sleep Timer for desktop // Sleep Timer for desktop
@ -180,7 +190,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
if (playerControls) { if (playerControls) {
playerControls.classList.remove('waveform-loaded'); playerControls.classList.remove('waveform-loaded');
} }
// Clear current mask while loading // Clear current mask while loading
progressBar.style.webkitMaskImage = ''; progressBar.style.webkitMaskImage = '';
progressBar.style.maskImage = ''; progressBar.style.maskImage = '';
@ -188,7 +198,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
try { try {
const streamUrl = await player.api.getStreamUrl(player.currentTrack.id, 'LOW'); const streamUrl = await player.api.getStreamUrl(player.currentTrack.id, 'LOW');
const waveformData = await waveformGenerator.getWaveform(streamUrl, player.currentTrack.id); const waveformData = await waveformGenerator.getWaveform(streamUrl, player.currentTrack.id);
if (waveformData && currentTrackIdForWaveform === player.currentTrack.id) { if (waveformData && currentTrackIdForWaveform === player.currentTrack.id) {
let { peaks, duration } = waveformData; let { peaks, duration } = waveformData;
const trackDuration = player.currentTrack.duration; const trackDuration = player.currentTrack.duration;
@ -196,13 +206,14 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
// Padding logic for sync // Padding logic for sync
if (trackDuration && duration && duration < trackDuration) { if (trackDuration && duration && duration < trackDuration) {
const diff = trackDuration - duration; 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 // Calculate how many peaks represent the missing time
// peaks.length represents 'duration' // peaks.length represents 'duration'
// X peaks represent 'diff' // X peaks represent 'diff'
const peaksPerSecond = peaks.length / duration; const peaksPerSecond = peaks.length / duration;
const paddingPeaksCount = Math.floor(diff * peaksPerSecond); const paddingPeaksCount = Math.floor(diff * peaksPerSecond);
if (paddingPeaksCount > 0) { if (paddingPeaksCount > 0) {
const newPeaks = new Float32Array(peaks.length + paddingPeaksCount); const newPeaks = new Float32Array(peaks.length + paddingPeaksCount);
// Fill start with 0s (implied by new Float32Array) // 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.maskImage = `url(${dataUrl})`;
progressBar.style.maskSize = '100% 100%'; progressBar.style.maskSize = '100% 100%';
progressBar.style.maskRepeat = 'no-repeat'; progressBar.style.maskRepeat = 'no-repeat';
progressBar.classList.add('waveform-loaded'); progressBar.classList.add('waveform-loaded');
if (playerControls) { if (playerControls) {
playerControls.classList.add('waveform-loaded'); playerControls.classList.add('waveform-loaded');
@ -258,7 +269,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const updateVolumeUI = () => { const updateVolumeUI = () => {
const { muted } = audioPlayer; const { muted } = audioPlayer;
const volume = player.userVolume; 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; const effectiveVolume = muted ? 0 : volume * 100;
volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`); volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
volumeFill.style.width = `${effectiveVolume}%`; volumeFill.style.width = `${effectiveVolume}%`;
@ -308,7 +319,7 @@ function initializeSmoothSliders(audioPlayer, player) {
wasPlaying = !audioPlayer.paused; wasPlaying = !audioPlayer.paused;
if (wasPlaying) audioPlayer.pause(); if (wasPlaying) audioPlayer.pause();
seek(progressBar, e, position => { seek(progressBar, e, (position) => {
if (!isNaN(audioPlayer.duration)) { if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration; audioPlayer.currentTime = position * audioPlayer.duration;
progressFill.style.width = `${position * 100}%`; progressFill.style.width = `${position * 100}%`;
@ -334,7 +345,7 @@ function initializeSmoothSliders(audioPlayer, player) {
document.addEventListener('mousemove', (e) => { document.addEventListener('mousemove', (e) => {
if (isSeeking) { if (isSeeking) {
seek(progressBar, e, position => { seek(progressBar, e, (position) => {
if (!isNaN(audioPlayer.duration)) { if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration; audioPlayer.currentTime = position * audioPlayer.duration;
progressFill.style.width = `${position * 100}%`; progressFill.style.width = `${position * 100}%`;
@ -343,7 +354,7 @@ function initializeSmoothSliders(audioPlayer, player) {
} }
if (isAdjustingVolume) { if (isAdjustingVolume) {
seek(volumeBar, e, position => { seek(volumeBar, e, (position) => {
player.setVolume(position); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`); volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
@ -374,7 +385,7 @@ function initializeSmoothSliders(audioPlayer, player) {
document.addEventListener('mouseup', (e) => { document.addEventListener('mouseup', (e) => {
if (isSeeking) { if (isSeeking) {
seek(progressBar, e, position => { seek(progressBar, e, (position) => {
if (!isNaN(audioPlayer.duration)) { if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration; audioPlayer.currentTime = position * audioPlayer.duration;
player.updateMediaSessionPositionState(); player.updateMediaSessionPositionState();
@ -403,9 +414,9 @@ function initializeSmoothSliders(audioPlayer, player) {
} }
}); });
progressBar.addEventListener('click', e => { progressBar.addEventListener('click', (e) => {
if (!isSeeking) { if (!isSeeking) {
seek(progressBar, e, position => { seek(progressBar, e, (position) => {
if (!isNaN(audioPlayer.duration) && audioPlayer.duration > 0 && audioPlayer.duration !== Infinity) { if (!isNaN(audioPlayer.duration) && audioPlayer.duration > 0 && audioPlayer.duration !== Infinity) {
audioPlayer.currentTime = position * audioPlayer.duration; audioPlayer.currentTime = position * audioPlayer.duration;
player.updateMediaSessionPositionState(); player.updateMediaSessionPositionState();
@ -421,7 +432,7 @@ function initializeSmoothSliders(audioPlayer, player) {
volumeBar.addEventListener('mousedown', (e) => { volumeBar.addEventListener('mousedown', (e) => {
isAdjustingVolume = true; isAdjustingVolume = true;
seek(volumeBar, e, position => { seek(volumeBar, e, (position) => {
player.setVolume(position); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${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.style.setProperty('--volume-level', `${position * 100}%`);
}); });
volumeBar.addEventListener('click', e => { volumeBar.addEventListener('click', (e) => {
if (!isAdjustingVolume) { if (!isAdjustingVolume) {
seek(volumeBar, e, position => { seek(volumeBar, e, (position) => {
player.setVolume(position); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`); volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
}); });
} }
}); });
volumeBar.addEventListener('wheel', e => { volumeBar.addEventListener(
e.preventDefault(); 'wheel',
const delta = e.deltaY > 0 ? -0.05 : 0.05; (e) => {
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta)); 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) { if (delta > 0 && audioPlayer.muted) {
audioPlayer.muted = false; audioPlayer.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
} }
player.setVolume(newVolume); player.setVolume(newVolume);
volumeFill.style.width = `${newVolume * 100}%`; volumeFill.style.width = `${newVolume * 100}%`;
volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`); volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`);
}, { passive: false }); },
{ passive: false }
);
volumeBtn?.addEventListener('wheel', e => { volumeBtn?.addEventListener(
e.preventDefault(); 'wheel',
const delta = e.deltaY > 0 ? -0.05 : 0.05; (e) => {
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta)); 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) { if (delta > 0 && audioPlayer.muted) {
audioPlayer.muted = false; audioPlayer.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
} }
player.setVolume(newVolume); player.setVolume(newVolume);
volumeFill.style.width = `${newVolume * 100}%`; volumeFill.style.width = `${newVolume * 100}%`;
volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`); volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`);
}, { passive: false }); },
{ 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 (!item) return;
if (action === 'add-to-queue') { if (action === 'add-to-queue') {
@ -509,10 +537,10 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
try { try {
playlist = await syncManager.getPublicPlaylist(item.id); playlist = await syncManager.getPublicPlaylist(item.id);
} catch (e) { } catch (e) {
// Ignore // Ignore
} }
} }
tracks = playlist ? playlist.tracks : (item.tracks || []); tracks = playlist ? playlist.tracks : item.tracks || [];
} }
if (tracks.length > 0) { 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; const name = type === 'user-playlist' ? item.name : item.title;
showNotification(`Playing ${type.replace('user-', '')}: ${name}`); showNotification(`Playing ${type.replace('user-', '')}: ${name}`);
} else { } else {
showNotification(`No tracks found in this ${type}`); showNotification(`No tracks found in this ${type}`);
} }
} catch (error) { } catch (error) {
console.error('Failed to play card:', 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 // Update all instances of this item's like button on the page
const id = type === 'playlist' ? item.uuid : item.id; const id = type === 'playlist' ? item.uuid : item.id;
const selector = type === 'track' const selector =
? `[data-track-id="${id}"] .like-btn` type === 'track'
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`; ? `[data-track-id="${id}"] .like-btn`
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
// Also check header buttons // Also check header buttons
const headerBtn = document.getElementById(`like-${type}-btn`); const headerBtn = document.getElementById(`like-${type}-btn`);
@ -556,26 +585,27 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
elementsToUpdate.push(nowPlayingLikeBtn); elementsToUpdate.push(nowPlayingLikeBtn);
} }
elementsToUpdate.forEach(btn => { elementsToUpdate.forEach((btn) => {
const heartIcon = btn.querySelector('svg'); const heartIcon = btn.querySelector('svg');
if (heartIcon) { if (heartIcon) {
heartIcon.classList.toggle('filled', added); heartIcon.classList.toggle('filled', added);
if (heartIcon.hasAttribute('fill')) { if (heartIcon.hasAttribute('fill')) {
heartIcon.setAttribute('fill', added ? 'currentColor' : 'none'); heartIcon.setAttribute('fill', added ? 'currentColor' : 'none');
} }
} }
btn.classList.toggle('active', added); btn.classList.toggle('active', added);
btn.title = added ? 'Remove from Favorites' : 'Add to Favorites'; btn.title = added ? 'Remove from Favorites' : 'Add to Favorites';
}); });
// Handle Library Page Update // Handle Library Page Update
if (window.location.hash === '#library') { if (window.location.hash === '#library') {
const itemSelector = type === 'track' const itemSelector =
? `.track-item[data-track-id="${id}"]` type === 'track'
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`; ? `.track-item[data-track-id="${id}"]`
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
const itemEl = document.querySelector(itemSelector); const itemEl = document.querySelector(itemSelector);
if (!added && itemEl) { if (!added && itemEl) {
// Remove item // Remove item
const container = itemEl.parentElement; const container = itemEl.parentElement;
@ -625,22 +655,25 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
const playlistsWithTrack = new Set(); const playlistsWithTrack = new Set();
for (const playlist of playlists) { 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); 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 => { list.innerHTML = playlists
const alreadyContains = playlistsWithTrack.has(p.id); .map((p) => {
return ` const alreadyContains = playlistsWithTrack.has(p.id);
return `
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}"> <div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
<span>${p.name}</span> <span>${p.name}</span>
${alreadyContains ? `<span class="checkmark">${checkmarkSvg}</span>` : ''} ${alreadyContains ? `<span class="checkmark">${checkmarkSvg}</span>` : ''}
</div> </div>
`; `;
}).join(''); })
.join('');
const closeModal = () => { const closeModal = () => {
modal.classList.remove('active'); modal.classList.remove('active');
@ -699,7 +732,7 @@ async function updateContextMenuLikeState(contextMenu, contextTrack) {
export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager, ui, scrobbler) { export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager, ui, scrobbler) {
let contextTrack = null; 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'); const actionBtn = e.target.closest('.track-action-btn, .like-btn, .play-btn');
if (actionBtn && actionBtn.dataset.action) { if (actionBtn && actionBtn.dataset.action) {
e.preventDefault(); // Prevent card navigation e.preventDefault(); // Prevent card navigation
@ -727,7 +760,9 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
const data = await api.getMix(id); const data = await api.getMix(id);
item = data.mix; 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) { if (trackItem && !trackItem.dataset.queueIndex) {
const clickedTrack = trackDataStore.get(trackItem); 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'; contextMenu.style.display = 'none';
return; return;
} }
@ -763,11 +803,11 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
if (trackItem && !trackItem.dataset.queueIndex && !e.target.closest('.remove-from-playlist-btn')) { if (trackItem && !trackItem.dataset.queueIndex && !e.target.closest('.remove-from-playlist-btn')) {
const parentList = trackItem.closest('.track-list'); const parentList = trackItem.closest('.track-list');
const allTrackElements = Array.from(parentList.querySelectorAll('.track-item')); 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) { if (trackList.length > 0) {
const clickedTrackId = trackItem.dataset.trackId; 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); player.setQueue(trackList, startIndex);
document.getElementById('shuffle-btn').classList.remove('active'); document.getElementById('shuffle-btn').classList.remove('active');
@ -785,14 +825,14 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
if (href) { if (href) {
// Allow native links inside card to work if any exist // Allow native links inside card to work if any exist
if (e.target.closest('a')) return; if (e.target.closest('a')) return;
e.preventDefault(); e.preventDefault();
window.location.hash = href; window.location.hash = href;
} }
} }
}); });
mainContent.addEventListener('contextmenu', async e => { mainContent.addEventListener('contextmenu', async (e) => {
const trackItem = e.target.closest('.track-item, .queue-track-item'); const trackItem = e.target.closest('.track-item, .queue-track-item');
if (trackItem) { if (trackItem) {
e.preventDefault(); e.preventDefault();
@ -816,7 +856,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
contextMenu.style.display = 'none'; contextMenu.style.display = 'none';
}); });
contextMenu.addEventListener('click', async e => { contextMenu.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
const action = e.target.dataset.action; const action = e.target.dataset.action;
const track = contextMenu._contextTrack || contextTrack; const track = contextMenu._contextTrack || contextTrack;
@ -857,7 +897,16 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
nowPlayingLikeBtn.addEventListener('click', async (e) => { nowPlayingLikeBtn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
if (player.currentTrack) { 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) => { nowPlayingMixBtn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
if (player.currentTrack) { 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) => { nowPlayingAddPlaylistBtn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
if (player.currentTrack) { 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 // Mobile add playlist button functionality
const mobileAddPlaylistBtn = document.getElementById('mobile-add-playlist-btn'); const mobileAddPlaylistBtn = document.getElementById('mobile-add-playlist-btn');
if (mobileAddPlaylistBtn) { if (mobileAddPlaylistBtn) {
mobileAddPlaylistBtn.addEventListener('click', async (e) => { mobileAddPlaylistBtn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
if (player.currentTrack) { 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
);
}
});
}
} }
function showSleepTimerModal(player) { function showSleepTimerModal(player) {
@ -963,7 +1035,8 @@ function positionMenu(menu, x, y, anchorRect = null) {
if (anchorRect) { if (anchorRect) {
// Adjust horizontal position if it overflows right // 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; left = anchorRect.right - menuWidth;
if (left < 10) left = 10; if (left < 10) left = 10;
} }

View file

@ -1,6 +1,12 @@
// js/firebase/auth.js // js/firebase/auth.js
import { auth, provider } from './config.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'; import { syncManager } from './sync.js';
export class AuthManager { export class AuthManager {
@ -16,12 +22,12 @@ export class AuthManager {
this.unsubscribe = onAuthStateChanged(auth, (user) => { this.unsubscribe = onAuthStateChanged(auth, (user) => {
this.user = user; this.user = user;
this.updateUI(user); this.updateUI(user);
if (user) { if (user) {
console.log("User logged in:", user.uid); console.log('User logged in:', user.uid);
syncManager.initialize(user); syncManager.initialize(user);
} else { } else {
console.log("User logged out"); console.log('User logged out');
syncManager.disconnect(); syncManager.disconnect();
} }
}); });
@ -29,7 +35,7 @@ export class AuthManager {
async signInWithGoogle() { async signInWithGoogle() {
if (!auth) { if (!auth) {
alert("Firebase is not configured. Please check console."); alert('Firebase is not configured. Please check console.');
return; return;
} }
@ -38,7 +44,7 @@ export class AuthManager {
// The onAuthStateChanged listener will handle the rest // The onAuthStateChanged listener will handle the rest
return result.user; return result.user;
} catch (error) { } catch (error) {
console.error("Login failed:", error); console.error('Login failed:', error);
alert(`Login failed: ${error.message}`); alert(`Login failed: ${error.message}`);
throw error; throw error;
} }
@ -46,14 +52,14 @@ export class AuthManager {
async signInWithEmail(email, password) { async signInWithEmail(email, password) {
if (!auth) { if (!auth) {
alert("Firebase is not configured."); alert('Firebase is not configured.');
return; return;
} }
try { try {
const result = await signInWithEmailAndPassword(auth, email, password); const result = await signInWithEmailAndPassword(auth, email, password);
return result.user; return result.user;
} catch (error) { } catch (error) {
console.error("Email Login failed:", error); console.error('Email Login failed:', error);
alert(`Login failed: ${error.message}`); alert(`Login failed: ${error.message}`);
throw error; throw error;
} }
@ -61,14 +67,14 @@ export class AuthManager {
async signUpWithEmail(email, password) { async signUpWithEmail(email, password) {
if (!auth) { if (!auth) {
alert("Firebase is not configured."); alert('Firebase is not configured.');
return; return;
} }
try { try {
const result = await createUserWithEmailAndPassword(auth, email, password); const result = await createUserWithEmailAndPassword(auth, email, password);
return result.user; return result.user;
} catch (error) { } catch (error) {
console.error("Sign Up failed:", error); console.error('Sign Up failed:', error);
alert(`Sign Up failed: ${error.message}`); alert(`Sign Up failed: ${error.message}`);
throw error; throw error;
} }
@ -81,7 +87,7 @@ export class AuthManager {
await firebaseSignOut(auth); await firebaseSignOut(auth);
// The onAuthStateChanged listener will handle the rest // The onAuthStateChanged listener will handle the rest
} catch (error) { } catch (error) {
console.error("Logout failed:", error); console.error('Logout failed:', error);
throw error; throw error;
} }
} }
@ -99,18 +105,17 @@ export class AuthManager {
connectBtn.textContent = 'Sign Out'; connectBtn.textContent = 'Sign Out';
connectBtn.classList.add('danger'); connectBtn.classList.add('danger');
connectBtn.onclick = () => this.signOut(); connectBtn.onclick = () => this.signOut();
if (clearDataBtn) clearDataBtn.style.display = 'block'; if (clearDataBtn) clearDataBtn.style.display = 'block';
if (emailContainer) emailContainer.style.display = 'none'; if (emailContainer) emailContainer.style.display = 'none';
if (emailToggleBtn) emailToggleBtn.style.display = 'none'; if (emailToggleBtn) emailToggleBtn.style.display = 'none';
if (statusText) statusText.textContent = `Signed in as ${user.email}`; if (statusText) statusText.textContent = `Signed in as ${user.email}`;
} else { } else {
connectBtn.textContent = 'Connect with Google'; connectBtn.textContent = 'Connect with Google';
connectBtn.classList.remove('danger'); connectBtn.classList.remove('danger');
connectBtn.onclick = () => this.signInWithGoogle(); connectBtn.onclick = () => this.signInWithGoogle();
if (clearDataBtn) clearDataBtn.style.display = 'none'; if (clearDataBtn) clearDataBtn.style.display = 'none';
if (emailToggleBtn) emailToggleBtn.style.display = 'inline-block'; if (emailToggleBtn) emailToggleBtn.style.display = 'inline-block';

View file

@ -1,7 +1,7 @@
// js/firebase/config.js // js/firebase/config.js
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.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 { 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 { getDatabase } from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-database.js';
let app = null; let app = null;
let auth = null; let auth = null;
@ -11,17 +11,14 @@ let provider = null;
const STORAGE_KEY = 'monochrome-firebase-config'; const STORAGE_KEY = 'monochrome-firebase-config';
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
apiKey: "AIzaSyDPU-unAjuLtQJt4IkGS5faG50UCF7lYyA", apiKey: 'AIzaSyDPU-unAjuLtQJt4IkGS5faG50UCF7lYyA',
authDomain: "monochrome-database.firebaseapp.com", authDomain: 'monochrome-database.firebaseapp.com',
projectId: "monochrome-database", projectId: 'monochrome-database',
storageBucket: "monochrome-database.firebasestorage.app", storageBucket: 'monochrome-database.firebasestorage.app',
messagingSenderId: "895657412760", messagingSenderId: '895657412760',
appId: "1:895657412760:web:e81c5044c7f4e9b799e8ed" appId: '1:895657412760:web:e81c5044c7f4e9b799e8ed',
}; };
function getStoredConfig() { function getStoredConfig() {
try { try {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
@ -42,12 +39,12 @@ if (config) {
auth = getAuth(app); auth = getAuth(app);
database = getDatabase(app); database = getDatabase(app);
provider = new GoogleAuthProvider(); provider = new GoogleAuthProvider();
console.log("Firebase initialized from " + (storedConfig ? "saved" : "default") + " config"); console.log('Firebase initialized from ' + (storedConfig ? 'saved' : 'default') + ' config');
} catch (error) { } catch (error) {
console.error("Error initializing Firebase:", error); console.error('Error initializing Firebase:', error);
} }
} else { } else {
console.log("No Firebase config found."); console.log('No Firebase config found.');
} }
export function saveFirebaseConfig(configObj) { export function saveFirebaseConfig(configObj) {
@ -173,12 +170,15 @@ export function initializeFirebaseSettingsUI() {
const config = JSON.parse(currentConfigStr); const config = JSON.parse(currentConfigStr);
const link = generateShareLink(config); const link = generateShareLink(config);
if (link) { if (link) {
navigator.clipboard.writeText(link).then(() => { navigator.clipboard
alert('Magic Link copied to clipboard! Send it to your other device.'); .writeText(link)
}).catch(err => { .then(() => {
console.error('Clipboard error:', err); alert('Magic Link copied to clipboard! Send it to your other device.');
prompt('Copy this link:', link); })
}); .catch((err) => {
console.error('Clipboard error:', err);
prompt('Copy this link:', link);
});
} }
} catch (e) { } catch (e) {
alert('Invalid configuration found.'); alert('Invalid configuration found.');
@ -206,13 +206,13 @@ export function initializeFirebaseSettingsUI() {
if (cleaned.endsWith(';')) { if (cleaned.endsWith(';')) {
cleaned = cleaned.slice(0, -1); cleaned = cleaned.slice(0, -1);
} }
// Convert JS Object format to JSON format // Convert JS Object format to JSON format
const jsonReady = cleaned const jsonReady = cleaned
.replace(/([{,]\s*)([a-zA-Z0-9_]+)\s*:/g, '$1"$2":') // Wrap keys in double quotes .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"') // Replace single-quoted values with double quotes
.replace(/,\s*([}\]])/g, '$1'); // Remove trailing commas .replace(/,\s*([}\]])/g, '$1'); // Remove trailing commas
const config = JSON.parse(jsonReady); const config = JSON.parse(jsonReady);
saveFirebaseConfig(config); saveFirebaseConfig(config);
alert('Configuration saved. Reloading...'); alert('Configuration saved. Reloading...');
@ -227,7 +227,11 @@ export function initializeFirebaseSettingsUI() {
// Clear Button // Clear Button
if (clearFirebaseConfigBtn) { if (clearFirebaseConfigBtn) {
clearFirebaseConfigBtn.addEventListener('click', () => { 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(); clearFirebaseConfig();
window.location.reload(); window.location.reload();
} }

View file

@ -1,6 +1,16 @@
// js/firebase/sync.js // js/firebase/sync.js
import { database } from './config.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'; import { db } from '../db.js';
export class SyncManager { export class SyncManager {
@ -15,28 +25,28 @@ export class SyncManager {
if (!database || !user) return; if (!database || !user) return;
this.user = user; this.user = user;
this.userRef = ref(database, `users/${user.uid}`); 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(); this.performInitialSync();
} }
disconnect() { disconnect() {
if (this.userRef) { if (this.userRef) {
// Remove listeners // Remove listeners
this.unsubscribeFunctions.forEach(unsub => unsub()); this.unsubscribeFunctions.forEach((unsub) => unsub());
this.unsubscribeFunctions = []; this.unsubscribeFunctions = [];
} }
this.user = null; this.user = null;
this.userRef = null; this.userRef = null;
console.log("SyncManager disconnected"); console.log('SyncManager disconnected');
} }
async performInitialSync() { async performInitialSync() {
if (this.isSyncing) return; if (this.isSyncing) return;
this.isSyncing = true; this.isSyncing = true;
try { try {
console.log("Starting initial sync..."); console.log('Starting initial sync...');
// 1. Fetch Cloud Data // 1. Fetch Cloud Data
const snapshot = await get(this.userRef); const snapshot = await get(this.userRef);
@ -49,7 +59,7 @@ export class SyncManager {
const mergedData = this.mergeData(localData, cloudData); const mergedData = this.mergeData(localData, cloudData);
// 4. Update Cloud (if different) // 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". // or we could diff. Rewriting is safer for "Initial Merge".
await update(this.userRef, mergedData); await update(this.userRef, mergedData);
@ -61,18 +71,17 @@ export class SyncManager {
favorites_artists: mergedData.library?.artists ? Object.values(mergedData.library.artists) : [], favorites_artists: mergedData.library?.artists ? Object.values(mergedData.library.artists) : [],
favorites_playlists: mergedData.library?.playlists ? Object.values(mergedData.library.playlists) : [], favorites_playlists: mergedData.library?.playlists ? Object.values(mergedData.library.playlists) : [],
history_tracks: mergedData.history?.recentTracks ? Object.values(mergedData.history.recentTracks) : [], 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); await db.importData(importData, true);
console.log("Initial sync complete."); console.log('Initial sync complete.');
// 6. Setup Listeners for future changes // 6. Setup Listeners for future changes
this.setupListeners(); this.setupListeners();
} catch (error) { } catch (error) {
console.error("Initial sync failed:", error); console.error('Initial sync failed:', error);
} finally { } finally {
this.isSyncing = false; this.isSyncing = false;
} }
@ -87,18 +96,18 @@ export class SyncManager {
// Add all local items // Add all local items
if (Array.isArray(localItems)) { 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') { } else if (localItems && typeof localItems === 'object') {
// Handle case where cloud stores as object keys // 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) // Add/Overwrite with cloud items (Union Strategy)
if (cloudItems) { if (cloudItems) {
if (Array.isArray(cloudItems)) { if (Array.isArray(cloudItems)) {
cloudItems.forEach(item => map.set(item[idKey], item)); cloudItems.forEach((item) => map.set(item[idKey], item));
} else { } else {
Object.keys(cloudItems).forEach(key => { Object.keys(cloudItems).forEach((key) => {
const val = cloudItems[key]; const val = cloudItems[key];
if (typeof val === 'object') { if (typeof val === 'object') {
map.set(val[idKey] || key, val); map.set(val[idKey] || key, val);
@ -115,14 +124,20 @@ export class SyncManager {
tracks: this.arrayToObject(mergeStores(local.favorites_tracks, cloud.library?.tracks), 'id'), tracks: this.arrayToObject(mergeStores(local.favorites_tracks, cloud.library?.tracks), 'id'),
albums: this.arrayToObject(mergeStores(local.favorites_albums, cloud.library?.albums), 'id'), albums: this.arrayToObject(mergeStores(local.favorites_albums, cloud.library?.albums), 'id'),
artists: this.arrayToObject(mergeStores(local.favorites_artists, cloud.library?.artists), '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: { 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'), user_playlists: this.arrayToObject(mergeStores(local.user_playlists, cloud.user_playlists), 'id'),
// Settings are NOT synced (device specific) // Settings are NOT synced (device specific)
lastUpdated: Date.now() lastUpdated: Date.now(),
}; };
// Transform back to local structure for db.importData // Transform back to local structure for db.importData
@ -132,7 +147,7 @@ export class SyncManager {
// Helper to convert array to object with keys // Helper to convert array to object with keys
arrayToObject(arr, keyField) { arrayToObject(arr, keyField) {
const obj = {}; const obj = {};
arr.forEach(item => { arr.forEach((item) => {
if (item && item[keyField]) { if (item && item[keyField]) {
obj[item[keyField]] = item; obj[item[keyField]] = item;
} }
@ -145,7 +160,7 @@ export class SyncManager {
const libraryRef = child(this.userRef, 'library'); const libraryRef = child(this.userRef, 'library');
const unsubLibrary = onValue(libraryRef, (snapshot) => { const unsubLibrary = onValue(libraryRef, (snapshot) => {
if (this.isSyncing) return; if (this.isSyncing) return;
const val = snapshot.val(); const val = snapshot.val();
if (val) { if (val) {
@ -153,7 +168,7 @@ export class SyncManager {
favorites_tracks: val.tracks ? Object.values(val.tracks) : [], favorites_tracks: val.tracks ? Object.values(val.tracks) : [],
favorites_albums: val.albums ? Object.values(val.albums) : [], favorites_albums: val.albums ? Object.values(val.albums) : [],
favorites_artists: val.artists ? Object.values(val.artists) : [], 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(() => { db.importData(importData, true).then(() => {
// Notify UI to refresh // Notify UI to refresh
@ -161,7 +176,7 @@ export class SyncManager {
}); });
} }
}); });
this.unsubscribeFunctions.push(() => off(libraryRef, 'value', unsubLibrary)); this.unsubscribeFunctions.push(() => off(libraryRef, 'value', unsubLibrary));
// Listen for changes in history // Listen for changes in history
@ -173,7 +188,7 @@ export class SyncManager {
const val = snapshot.val(); const val = snapshot.val();
if (val) { if (val) {
const importData = { const importData = {
history_tracks: Object.values(val) history_tracks: Object.values(val),
}; };
db.importData(importData, true).then(() => { db.importData(importData, true).then(() => {
// Notify UI to refresh // Notify UI to refresh
@ -193,7 +208,7 @@ export class SyncManager {
const val = snapshot.val(); const val = snapshot.val();
if (val) { if (val) {
const importData = { const importData = {
user_playlists: Object.values(val) user_playlists: Object.values(val),
}; };
db.importData(importData, true).then(() => { db.importData(importData, true).then(() => {
// Notify UI to refresh library // Notify UI to refresh library
@ -215,10 +230,10 @@ export class SyncManager {
// isAdded: boolean // isAdded: boolean
const categoryMap = { const categoryMap = {
'track': 'tracks', track: 'tracks',
'album': 'albums', album: 'albums',
'artist': 'artists', artist: 'artists',
'playlist': 'playlists' playlist: 'playlists',
}; };
const category = categoryMap[type]; const category = categoryMap[type];
if (!category) return; 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. // we add it now. Ideally this matches local DB time, but a small diff is negligible.
const entry = { const entry = {
...minified, ...minified,
addedAt: item.addedAt || minified.addedAt || Date.now() addedAt: item.addedAt || minified.addedAt || Date.now(),
}; };
await set(itemRef, entry); await set(itemRef, entry);
} else { } else {
@ -250,7 +265,7 @@ export class SyncManager {
try { try {
await set(itemRef, track); await set(itemRef, track);
} catch (error) { } 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() { async clearCloudData() {
if (!this.user || !this.userRef) { if (!this.user || !this.userRef) {
throw new Error("Not authenticated"); throw new Error('Not authenticated');
} }
await remove(this.userRef); await remove(this.userRef);
} }
@ -278,12 +293,12 @@ export class SyncManager {
// Public Playlist API // Public Playlist API
async publishPlaylist(playlist) { 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 minified = db._minifyItem('playlist', playlist);
const playlistId = playlist.id || playlist.uuid; 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 // Ensure playlist has necessary data
const publicData = { const publicData = {
@ -291,7 +306,7 @@ export class SyncManager {
uid: this.user.uid, uid: this.user.uid,
originalId: playlistId, originalId: playlistId,
publishedAt: Date.now(), 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 // Use a global 'public_playlists' node
@ -300,14 +315,14 @@ export class SyncManager {
} }
async unpublishPlaylist(playlistId) { 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}`); const publicRef = ref(database, `public_playlists/${playlistId}`);
await remove(publicRef); await remove(publicRef);
} }
async getPublicPlaylist(playlistId) { async getPublicPlaylist(playlistId) {
if (!database) { if (!database) {
console.warn("[Sync] Database not initialized, cannot fetch public playlist"); console.warn('[Sync] Database not initialized, cannot fetch public playlist');
return null; return null;
} }
try { try {
@ -321,7 +336,7 @@ export class SyncManager {
console.log(`[Sync] Public playlist fetch for ${playlistId}: Found`); console.log(`[Sync] Public playlist fetch for ${playlistId}: Found`);
return data; return data;
} catch (error) { } catch (error) {
console.error("[Sync] Failed to fetch public playlist:", error); console.error('[Sync] Failed to fetch public playlist:', error);
return null; return null;
} }
} }

View file

@ -33,10 +33,13 @@ export class LastFMScrobbler {
saveSession(sessionKey, username) { saveSession(sessionKey, username) {
this.sessionKey = sessionKey; this.sessionKey = sessionKey;
this.username = username; this.username = username;
localStorage.setItem('lastfm-session', JSON.stringify({ localStorage.setItem(
key: sessionKey, 'lastfm-session',
name: username JSON.stringify({
})); key: sessionKey,
name: username,
})
);
} }
clearSession() { clearSession() {
@ -56,9 +59,7 @@ export class LastFMScrobbler {
const sortedKeys = Object.keys(filteredParams).sort(); const sortedKeys = Object.keys(filteredParams).sort();
const signatureString = sortedKeys const signatureString = sortedKeys.map((key) => `${key}${filteredParams[key]}`).join('') + this.API_SECRET;
.map(key => `${key}${filteredParams[key]}`)
.join('') + this.API_SECRET;
console.log('Signature string:', signatureString); console.log('Signature string:', signatureString);
@ -75,7 +76,7 @@ export class LastFMScrobbler {
const requestParams = { const requestParams = {
method, method,
api_key: this.API_KEY, api_key: this.API_KEY,
...params ...params,
}; };
if (requiresAuth && this.sessionKey) { if (requiresAuth && this.sessionKey) {
@ -87,7 +88,7 @@ export class LastFMScrobbler {
const formData = new URLSearchParams({ const formData = new URLSearchParams({
...requestParams, ...requestParams,
api_sig: signature, api_sig: signature,
format: 'json' format: 'json',
}); });
try { try {
@ -96,7 +97,7 @@ export class LastFMScrobbler {
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
body: formData body: formData,
}); });
const data = await response.json(); const data = await response.json();
@ -119,7 +120,7 @@ export class LastFMScrobbler {
return { return {
token, 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) { } catch (error) {
console.error('Failed to get auth URL:', error); console.error('Failed to get auth URL:', error);
@ -135,7 +136,7 @@ export class LastFMScrobbler {
this.saveSession(data.session.key, data.session.name); this.saveSession(data.session.key, data.session.name);
return { return {
success: true, success: true,
username: data.session.name username: data.session.name,
}; };
} }
@ -156,7 +157,7 @@ export class LastFMScrobbler {
try { try {
const params = { const params = {
artist: track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist', artist: track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist',
track: track.title track: track.title,
}; };
if (track.album?.title) { if (track.album?.title) {
@ -177,7 +178,6 @@ export class LastFMScrobbler {
this.scrobbleThreshold = Math.min(track.duration / 2, 240); this.scrobbleThreshold = Math.min(track.duration / 2, 240);
this.scheduleScrobble(this.scrobbleThreshold * 1000); this.scheduleScrobble(this.scrobbleThreshold * 1000);
} catch (error) { } catch (error) {
console.error('Failed to update now playing:', error); console.error('Failed to update now playing:', error);
} }
@ -207,7 +207,7 @@ export class LastFMScrobbler {
const params = { const params = {
artist: this.currentTrack.artist?.name || this.currentTrack.artists?.[0]?.name || 'Unknown Artist', artist: this.currentTrack.artist?.name || this.currentTrack.artists?.[0]?.name || 'Unknown Artist',
track: this.currentTrack.title, track: this.currentTrack.title,
timestamp: timestamp timestamp: timestamp,
}; };
if (this.currentTrack.album?.title) { if (this.currentTrack.album?.title) {
@ -226,7 +226,6 @@ export class LastFMScrobbler {
this.hasScrobbled = true; this.hasScrobbled = true;
console.log('Scrobbled:', this.currentTrack.title); console.log('Scrobbled:', this.currentTrack.title);
} catch (error) { } catch (error) {
console.error('Failed to scrobble:', error); console.error('Failed to scrobble:', error);
} }
@ -238,7 +237,7 @@ export class LastFMScrobbler {
try { try {
const params = { const params = {
artist: track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist', artist: track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist',
track: track.title track: track.title,
}; };
await this.makeRequest('track.love', params, true); await this.makeRequest('track.love', params, true);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -45,7 +45,7 @@ export class Player {
if (mode !== 'off' && this.currentRgValues) { if (mode !== 'off' && this.currentRgValues) {
const { trackReplayGain, trackPeakAmplitude, albumReplayGain, albumPeakAmplitude } = this.currentRgValues; const { trackReplayGain, trackPeakAmplitude, albumReplayGain, albumPeakAmplitude } = this.currentRgValues;
if (mode === 'album' && albumReplayGain !== undefined) { if (mode === 'album' && albumReplayGain !== undefined) {
gainDb = albumReplayGain; gainDb = albumReplayGain;
peak = albumPeakAmplitude || 1.0; peak = albumPeakAmplitude || 1.0;
@ -53,7 +53,7 @@ export class Player {
gainDb = trackReplayGain; gainDb = trackReplayGain;
peak = trackPeakAmplitude || 1.0; peak = trackPeakAmplitude || 1.0;
} }
// Apply Pre-Amp // Apply Pre-Amp
gainDb += replayGainSettings.getPreamp(); gainDb += replayGainSettings.getPreamp();
} }
@ -68,7 +68,7 @@ export class Player {
// Calculate effective volume // Calculate effective volume
const effectiveVolume = this.userVolume * scale; const effectiveVolume = this.userVolume * scale;
// Apply to audio element // Apply to audio element
this.audio.volume = Math.max(0, Math.min(1, effectiveVolume)); this.audio.volume = Math.max(0, Math.min(1, effectiveVolume));
} }
@ -82,17 +82,17 @@ export class Player {
this.currentQueueIndex = savedState.currentQueueIndex ?? -1; this.currentQueueIndex = savedState.currentQueueIndex ?? -1;
this.shuffleActive = savedState.shuffleActive || false; this.shuffleActive = savedState.shuffleActive || false;
this.repeatMode = savedState.repeatMode || REPEAT_MODE.OFF; this.repeatMode = savedState.repeatMode || REPEAT_MODE.OFF;
// Restore current track if queue exists and index is valid // Restore current track if queue exists and index is valid
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (this.currentQueueIndex >= 0 && this.currentQueueIndex < currentQueue.length) { if (this.currentQueueIndex >= 0 && this.currentQueueIndex < currentQueue.length) {
this.currentTrack = currentQueue[this.currentQueueIndex]; this.currentTrack = currentQueue[this.currentQueueIndex];
// Restore UI // Restore UI
const track = this.currentTrack; const track = this.currentTrack;
const trackTitle = getTrackTitle(track); const trackTitle = getTrackTitle(track);
const trackArtistsHTML = getTrackArtistsHTML(track); const trackArtistsHTML = getTrackArtistsHTML(track);
let yearDisplay = ''; let yearDisplay = '';
const releaseDate = track.album?.releaseDate || track.streamStartDate; const releaseDate = track.album?.releaseDate || track.streamStartDate;
if (releaseDate) { if (releaseDate) {
@ -112,11 +112,11 @@ export class Player {
const mixBtn = document.getElementById('now-playing-mix-btn'); const mixBtn = document.getElementById('now-playing-mix-btn');
if (mixBtn) { 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'); const totalDurationEl = document.getElementById('total-duration');
if (totalDurationEl) totalDurationEl.textContent = formatTime(track.duration); if (totalDurationEl) totalDurationEl.textContent = formatTime(track.duration);
document.title = `${trackTitle}${getTrackArtists(track)}`; document.title = `${trackTitle}${getTrackArtists(track)}`;
this.updatePlayingTrackIndicator(); this.updatePlayingTrackIndicator();
this.updateMediaSession(track); this.updateMediaSession(track);
@ -131,7 +131,7 @@ export class Player {
originalQueueBeforeShuffle: this.originalQueueBeforeShuffle, originalQueueBeforeShuffle: this.originalQueueBeforeShuffle,
currentQueueIndex: this.currentQueueIndex, currentQueueIndex: this.currentQueueIndex,
shuffleActive: this.shuffleActive, shuffleActive: this.shuffleActive,
repeatMode: this.repeatMode repeatMode: this.repeatMode,
}); });
} }
@ -230,7 +230,7 @@ export class Player {
const trackTitle = getTrackTitle(track); const trackTitle = getTrackTitle(track);
const trackArtistsHTML = getTrackArtistsHTML(track); const trackArtistsHTML = getTrackArtistsHTML(track);
let yearDisplay = ''; let yearDisplay = '';
const releaseDate = track.album?.releaseDate || track.streamStartDate; const releaseDate = track.album?.releaseDate || track.streamStartDate;
if (releaseDate) { if (releaseDate) {
@ -240,14 +240,13 @@ export class Player {
} }
} }
document.querySelector('.now-playing-bar .cover').src = document.querySelector('.now-playing-bar .cover').src = this.api.getCoverUrl(track.album?.cover);
this.api.getCoverUrl(track.album?.cover);
document.querySelector('.now-playing-bar .title').textContent = trackTitle; document.querySelector('.now-playing-bar .title').textContent = trackTitle;
document.querySelector('.now-playing-bar .artist').innerHTML = trackArtistsHTML + yearDisplay; document.querySelector('.now-playing-bar .artist').innerHTML = trackArtistsHTML + yearDisplay;
const mixBtn = document.getElementById('now-playing-mix-btn'); const mixBtn = document.getElementById('now-playing-mix-btn');
if (mixBtn) { 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)}`; document.title = `${trackTitle}${getTrackArtists(track)}`;
@ -262,7 +261,7 @@ export class Player {
trackReplayGain: trackData.info.trackReplayGain, trackReplayGain: trackData.info.trackReplayGain,
trackPeakAmplitude: trackData.info.trackPeakAmplitude, trackPeakAmplitude: trackData.info.trackPeakAmplitude,
albumReplayGain: trackData.info.albumReplayGain, albumReplayGain: trackData.info.albumReplayGain,
albumPeakAmplitude: trackData.info.albumPeakAmplitude albumPeakAmplitude: trackData.info.albumPeakAmplitude,
}; };
} else { } else {
this.currentRgValues = null; this.currentRgValues = null;
@ -344,9 +343,9 @@ export class Player {
} }
if (this.audio.paused) { if (this.audio.paused) {
this.audio.play().catch(e => { this.audio.play().catch((e) => {
if (e.name === 'NotAllowedError' || e.name === 'AbortError') return; 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) { if (this.currentTrack) {
this.playTrackFromQueue(); this.playTrackFromQueue();
} }
@ -377,7 +376,7 @@ export class Player {
this.originalQueueBeforeShuffle = [...this.queue]; this.originalQueueBeforeShuffle = [...this.queue];
const currentTrack = this.queue[this.currentQueueIndex]; const currentTrack = this.queue[this.currentQueueIndex];
this.shuffledQueue = [...this.queue].sort(() => Math.random() - 0.5); 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) { if (this.currentQueueIndex === -1 && currentTrack) {
this.shuffledQueue.unshift(currentTrack); this.shuffledQueue.unshift(currentTrack);
@ -386,7 +385,7 @@ export class Player {
} else { } else {
const currentTrack = this.shuffledQueue[this.currentQueueIndex]; const currentTrack = this.shuffledQueue[this.currentQueueIndex];
this.queue = [...this.originalQueueBeforeShuffle]; 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(); this.preloadCache.clear();
@ -421,17 +420,17 @@ export class Player {
addNextToQueue(track) { addNextToQueue(track) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
const insertIndex = this.currentQueueIndex + 1; const insertIndex = this.currentQueueIndex + 1;
// Insert after current track // Insert after current track
currentQueue.splice(insertIndex, 0, 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. // though syncing that is tricky. The standard logic often just appends to the active queue view.
if (this.shuffleActive) { if (this.shuffleActive) {
this.originalQueueBeforeShuffle.push(track); // Just append to end of main list? Or logic needed. this.originalQueueBeforeShuffle.push(track); // Just append to end of main list? Or logic needed.
// Simplest is to just modify the active playing queue. // Simplest is to just modify the active playing queue.
} else { } else {
// In linear mode, `currentQueue` IS `this.queue` // In linear mode, `currentQueue` IS `this.queue`
} }
this.saveQueueState(); this.saveQueueState();
@ -440,14 +439,14 @@ export class Player {
removeFromQueue(index) { removeFromQueue(index) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
// If removing current track // If removing current track
if (index === this.currentQueueIndex) { if (index === this.currentQueueIndex) {
// If playing, we might want to stop or just let it finish? // 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 it's the last track, playback will stop naturally or we handle it?
} }
if (index < this.currentQueueIndex) { if (index < this.currentQueueIndex) {
this.currentQueueIndex--; this.currentQueueIndex--;
} }
@ -456,12 +455,12 @@ export class Player {
if (this.shuffleActive) { if (this.shuffleActive) {
// Also remove from original queue // Also remove from original queue
const originalIndex = this.originalQueueBeforeShuffle.findIndex(t => t.id === removedTrack.id); // Simple ID check const originalIndex = this.originalQueueBeforeShuffle.findIndex((t) => t.id === removedTrack.id); // Simple ID check
if (originalIndex !== -1) { if (originalIndex !== -1) {
this.originalQueueBeforeShuffle.splice(originalIndex, 1); this.originalQueueBeforeShuffle.splice(originalIndex, 1);
} }
} }
this.saveQueueState(); this.saveQueueState();
this.preloadNextTracks(); this.preloadNextTracks();
} }
@ -513,13 +512,11 @@ export class Player {
updatePlayingTrackIndicator() { updatePlayingTrackIndicator() {
const currentTrack = this.getCurrentQueue()[this.currentQueueIndex]; const currentTrack = this.getCurrentQueue()[this.currentQueueIndex];
document.querySelectorAll('.track-item').forEach(item => { document.querySelectorAll('.track-item').forEach((item) => {
item.classList.toggle('playing', item.classList.toggle('playing', currentTrack && item.dataset.trackId == currentTrack.id);
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); const index = parseInt(item.dataset.queueIndex);
item.classList.toggle('playing', index === this.currentQueueIndex); item.classList.toggle('playing', index === this.currentQueueIndex);
}); });
@ -537,11 +534,11 @@ export class Player {
const trackTitle = getTrackTitle(track); const trackTitle = getTrackTitle(track);
if (coverId) { if (coverId) {
sizes.forEach(size => { sizes.forEach((size) => {
artwork.push({ artwork.push({
src: this.api.getCoverUrl(coverId, size), src: this.api.getCoverUrl(coverId, size),
sizes: `${size}x${size}`, sizes: `${size}x${size}`,
type: 'image/jpeg' type: 'image/jpeg',
}); });
}); });
} }
@ -550,7 +547,7 @@ export class Player {
title: trackTitle || 'Unknown Title', title: trackTitle || 'Unknown Title',
artist: getTrackArtists(track) || 'Unknown Artist', artist: getTrackArtists(track) || 'Unknown Artist',
album: track.album?.title || 'Unknown Album', album: track.album?.title || 'Unknown Album',
artwork: artwork.length > 0 ? artwork : undefined artwork: artwork.length > 0 ? artwork : undefined,
}); });
this.updateMediaSessionPlaybackState(); this.updateMediaSessionPlaybackState();
@ -576,7 +573,7 @@ export class Player {
navigator.mediaSession.setPositionState({ navigator.mediaSession.setPositionState({
duration: duration, duration: duration,
playbackRate: this.audio.playbackRate || 1, playbackRate: this.audio.playbackRate || 1,
position: Math.min(this.audio.currentTime, duration) position: Math.min(this.audio.currentTime, duration),
}); });
} catch (error) { } catch (error) {
console.debug('Failed to update Media Session position:', error); console.debug('Failed to update Media Session position:', error);
@ -587,13 +584,16 @@ export class Player {
setSleepTimer(minutes) { setSleepTimer(minutes) {
this.clearSleepTimer(); // Clear any existing timer this.clearSleepTimer(); // Clear any existing timer
this.sleepTimerEndTime = Date.now() + (minutes * 60 * 1000); this.sleepTimerEndTime = Date.now() + minutes * 60 * 1000;
this.sleepTimer = setTimeout(() => { this.sleepTimer = setTimeout(
this.audio.pause(); () => {
this.clearSleepTimer(); this.audio.pause();
this.updateSleepTimerUI(); this.clearSleepTimer();
}, minutes * 60 * 1000); this.updateSleepTimerUI();
},
minutes * 60 * 1000
);
// Update UI every second // Update UI every second
this.sleepTimerInterval = setInterval(() => { this.sleepTimerInterval = setInterval(() => {
@ -629,7 +629,7 @@ export class Player {
updateSleepTimerUI() { updateSleepTimerUI() {
const timerBtn = document.getElementById('sleep-timer-btn'); const timerBtn = document.getElementById('sleep-timer-btn');
const timerBtnDesktop = document.getElementById('sleep-timer-btn-desktop'); const timerBtnDesktop = document.getElementById('sleep-timer-btn-desktop');
const updateBtn = (btn) => { const updateBtn = (btn) => {
if (!btn) return; if (!btn) return;
if (this.isSleepTimerActive()) { if (this.isSleepTimerActive()) {

View file

@ -3,7 +3,7 @@ import { getTrackArtists } from './utils.js';
export function createRouter(ui) { export function createRouter(ui) {
const router = () => { const router = () => {
const path = window.location.hash.substring(1) || "home"; const path = window.location.hash.substring(1) || 'home';
const [page, param] = path.split('/'); const [page, param] = path.split('/');
switch (page) { switch (page) {

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,7 @@ export class SidePanelManager {
this.currentView = view; this.currentView = view;
this.titleElement.textContent = title; this.titleElement.textContent = title;
// Clear previous content // Clear previous content
this.controlsElement.innerHTML = ''; this.controlsElement.innerHTML = '';
this.contentElement.innerHTML = ''; this.contentElement.innerHTML = '';

View file

@ -1,57 +1,57 @@
//js/smooth-scrolling.js //js/smooth-scrolling.js
import { smoothScrollingSettings } from "./storage.js"; import { smoothScrollingSettings } from './storage.js';
let lenis = null; let lenis = null;
function initializeSmoothScrolling() { function initializeSmoothScrolling() {
if (lenis) return; // Already initialized if (lenis) return; // Already initialized
lenis = new Lenis({ lenis = new Lenis({
wrapper: document.querySelector('.main-content'), wrapper: document.querySelector('.main-content'),
content: document.querySelector('.main-content'), content: document.querySelector('.main-content'),
lerp: 0.1, lerp: 0.1,
smoothWheel: true, smoothWheel: true,
smoothTouch: false, smoothTouch: false,
normalizeWheel: true, normalizeWheel: true,
wheelMultiplier: 0.8, wheelMultiplier: 0.8,
}); });
function raf(time) {
lenis.raf(time);
requestAnimationFrame(raf);
}
function raf(time) {
lenis.raf(time);
requestAnimationFrame(raf); requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
} }
function destroySmoothScrolling() { function destroySmoothScrolling() {
if (lenis) { if (lenis) {
lenis.destroy(); lenis.destroy();
lenis = null; lenis = null;
} }
} }
function setupSmoothScrolling() { function setupSmoothScrolling() {
// Check if smooth scrolling is enabled // Check if smooth scrolling is enabled
const smoothScrollingEnabled = smoothScrollingSettings.isEnabled(); const smoothScrollingEnabled = smoothScrollingSettings.isEnabled();
if (smoothScrollingEnabled) { if (smoothScrollingEnabled) {
initializeSmoothScrolling(); initializeSmoothScrolling();
}
// Listen for toggle changes
window.addEventListener('smooth-scrolling-toggle', function(e) {
if (e.detail.enabled) {
initializeSmoothScrolling();
} else {
destroySmoothScrolling();
} }
});
// Listen for toggle changes
window.addEventListener('smooth-scrolling-toggle', function (e) {
if (e.detail.enabled) {
initializeSmoothScrolling();
} else {
destroySmoothScrolling();
}
});
} }
// Initialize when DOM is ready // Initialize when DOM is ready
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupSmoothScrolling); document.addEventListener('DOMContentLoaded', setupSmoothScrolling);
} else { } else {
setupSmoothScrolling(); setupSmoothScrolling();
} }

View file

@ -1,250 +1,249 @@
//storage.js //storage.js
export const apiSettings = { export const apiSettings = {
STORAGE_KEY: 'monochrome-api-instances-v2', STORAGE_KEY: 'monochrome-api-instances-v2',
INSTANCES_URL: "instances.json", INSTANCES_URL: 'instances.json',
SPEED_TEST_CACHE_KEY: 'monochrome-instance-speeds', SPEED_TEST_CACHE_KEY: 'monochrome-instance-speeds',
SPEED_TEST_CACHE_DURATION: 1000 * 60 * 60, SPEED_TEST_CACHE_DURATION: 1000 * 60 * 60,
defaultInstances: { api: [], streaming: [] }, defaultInstances: { api: [], streaming: [] },
instancesLoaded: false, instancesLoaded: false,
async loadInstancesFromGitHub() { async loadInstancesFromGitHub() {
if (this.instancesLoaded) { if (this.instancesLoaded) {
return this.defaultInstances; return this.defaultInstances;
} }
try { try {
const response = await fetch(this.INSTANCES_URL); const response = await fetch(this.INSTANCES_URL);
if (!response.ok) throw new Error('Failed to fetch instances'); if (!response.ok) throw new Error('Failed to fetch instances');
const data = await response.json(); const data = await response.json();
let groupedInstances = { api: [], streaming: [] }; let groupedInstances = { api: [], streaming: [] };
if (Array.isArray(data)) { if (Array.isArray(data)) {
// Legacy array format // Legacy array format
groupedInstances.api = [...data]; groupedInstances.api = [...data];
groupedInstances.streaming = [...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`;
} else { } else {
testUrl = url.endsWith('/') // New object format or legacy object format
? `${url}artist/?id=3532302` // Daft Punk if (data.api && Array.isArray(data.api)) {
: `${url}/artist/?id=3532302`; const isSimpleArray = data.api.length > 0 && typeof data.api[0] === 'string';
} if (isSimpleArray) {
groupedInstances.api = [...data.api];
const startTime = performance.now(); } else {
for (const [provider, config] of Object.entries(data.api)) {
try { if (config.cors === false && Array.isArray(config.urls)) {
const controller = new AbortController(); groupedInstances.api.push(...config.urls);
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(); if (data.streaming && Array.isArray(data.streaming)) {
const speed = endTime - startTime; groupedInstances.streaming = [...data.streaming];
} else if (groupedInstances.api.length > 0) {
return { url, type, speed, error: null }; groupedInstances.streaming = [...groupedInstances.api];
} 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() };
} }
}
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() }; return { speeds: {}, timestamp: Date.now() };
} }
},
return data;
updateSpeedCache(newResults) { } catch (e) {
const currentCache = this.getCachedSpeedTests(); return { speeds: {}, timestamp: Date.now() };
}
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) updateSpeedCache(newResults) {
const key = r.type === 'streaming' ? `${r.url}#streaming` : r.url; const currentCache = this.getCachedSpeedTests();
currentCache.speeds[key] = { speed: r.speed, error: r.error };
}); newResults.forEach((r) => {
// Use distinct keys for streaming tests to avoid overwriting API tests for same URL
currentCache.timestamp = Date.now(); // API tests use raw URL as key (for backward compatibility with UI)
const key = r.type === 'streaming' ? `${r.url}#streaming` : r.url;
try { currentCache.speeds[key] = { speed: r.speed, error: r.error };
localStorage.setItem(this.SPEED_TEST_CACHE_KEY, JSON.stringify(currentCache)); });
} catch (e) {
console.warn('[SpeedTest] Failed to cache results'); 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);
} }
} catch (e) {}
return currentCache;
}, if (!instancesObj) {
instancesObj = await this.loadInstancesFromGitHub();
async testSpecificUrls(urls, type) { }
if (!urls || urls.length === 0) return [];
console.log(`[SpeedTest] Testing ${urls.length} instances for ${type}...`); const targetUrls = instancesObj[type] || instancesObj.api || [];
if (targetUrls.length === 0) return [];
const results = await Promise.all(
urls.map(url => this.speedTestInstance(url, type)) const speedCache = this.getCachedSpeedTests();
); // Construct cache key based on type
const getCacheKey = (u) => (type === 'streaming' ? `${u}#streaming` : u);
const validResults = results.filter(r => r.speed !== Infinity);
console.log(`[SpeedTest] ${type} Results:`, validResults.map(r => `${r.url}: ${r.speed.toFixed(0)}ms`)); const urlsToTest = targetUrls.filter((url) => !speedCache.speeds[getCacheKey(url)]);
return results; if (urlsToTest.length > 0) {
}, const results = await this.testSpecificUrls(urlsToTest, type);
this.updateSpeedCache(results);
async getInstances(type = 'api') { Object.assign(speedCache, this.getCachedSpeedTests());
let instancesObj; }
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 { try {
const stored = localStorage.getItem(this.STORAGE_KEY); const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) { let fullObj = stored ? JSON.parse(stored) : { api: [], streaming: [] };
instancesObj = JSON.parse(stored); fullObj[type] = instances;
} localStorage.setItem(this.STORAGE_KEY, JSON.stringify(fullObj));
} catch (e) {} } catch (e) {
console.error('Failed to save instances:', e);
if (!instancesObj) {
instancesObj = await this.loadInstancesFromGitHub();
} }
} else {
const targetUrls = instancesObj[type] || instancesObj.api || []; localStorage.setItem(this.STORAGE_KEY, JSON.stringify(instances));
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));
}
} };
export const recentActivityManager = { export const recentActivityManager = {
STORAGE_KEY: 'monochrome-recent-activity', STORAGE_KEY: 'monochrome-recent-activity',
LIMIT: 10, LIMIT: 10,
@ -271,7 +270,7 @@ export const recentActivityManager = {
_add(type, item) { _add(type, item) {
const data = this._get(); 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].unshift(item);
data[type] = data[type].slice(0, this.LIMIT); data[type] = data[type].slice(0, this.LIMIT);
this._save(data); this._save(data);
@ -291,7 +290,7 @@ export const recentActivityManager = {
addMix(mix) { addMix(mix) {
this._add('mixes', mix); this._add('mixes', mix);
} },
}; };
export const themeManager = { export const themeManager = {
@ -308,7 +307,7 @@ export const themeManager = {
mocha: {}, mocha: {},
machiatto: {}, machiatto: {},
frappe: {}, frappe: {},
latte: {} latte: {},
}, },
getTheme() { getTheme() {
@ -329,10 +328,9 @@ export const themeManager = {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
} }
if (theme !== 'custom') { if (theme !== 'custom') {
const root = document.documentElement; 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}`); root.style.removeProperty(`--${key}`);
}); });
} else { } else {
@ -363,7 +361,7 @@ export const themeManager = {
for (const [key, value] of Object.entries(colors)) { for (const [key, value] of Object.entries(colors)) {
root.style.setProperty(`--${key}`, value); root.style.setProperty(`--${key}`, value);
} }
} },
}; };
export const lastFMStorage = { export const lastFMStorage = {
@ -392,7 +390,7 @@ export const lastFMStorage = {
setLoveOnLike(enabled) { setLoveOnLike(enabled) {
localStorage.setItem(this.LOVE_ON_LIKE_KEY, enabled ? 'true' : 'false'); localStorage.setItem(this.LOVE_ON_LIKE_KEY, enabled ? 'true' : 'false');
} },
}; };
export const nowPlayingSettings = { export const nowPlayingSettings = {
@ -408,7 +406,7 @@ export const nowPlayingSettings = {
setMode(mode) { setMode(mode) {
localStorage.setItem(this.STORAGE_KEY, mode); localStorage.setItem(this.STORAGE_KEY, mode);
} },
}; };
export const lyricsSettings = { export const lyricsSettings = {
@ -424,7 +422,7 @@ export const lyricsSettings = {
setDownloadLyrics(enabled) { setDownloadLyrics(enabled) {
localStorage.setItem(this.DOWNLOAD_WITH_TRACKS, enabled ? 'true' : 'false'); localStorage.setItem(this.DOWNLOAD_WITH_TRACKS, enabled ? 'true' : 'false');
} },
}; };
export const backgroundSettings = { export const backgroundSettings = {
@ -441,7 +439,7 @@ export const backgroundSettings = {
setEnabled(enabled) { setEnabled(enabled) {
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false'); localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
} },
}; };
export const trackListSettings = { export const trackListSettings = {
@ -460,7 +458,7 @@ export const trackListSettings = {
setMode(mode) { setMode(mode) {
localStorage.setItem(this.STORAGE_KEY, mode); localStorage.setItem(this.STORAGE_KEY, mode);
document.documentElement.setAttribute('data-track-actions-mode', mode); document.documentElement.setAttribute('data-track-actions-mode', mode);
} },
}; };
export const cardSettings = { export const cardSettings = {
@ -490,7 +488,7 @@ export const cardSettings = {
setCompactAlbum(enabled) { setCompactAlbum(enabled) {
localStorage.setItem(this.COMPACT_ALBUM_KEY, enabled ? 'true' : 'false'); localStorage.setItem(this.COMPACT_ALBUM_KEY, enabled ? 'true' : 'false');
} },
}; };
export const replayGainSettings = { export const replayGainSettings = {
@ -508,7 +506,7 @@ export const replayGainSettings = {
}, },
setPreamp(db) { setPreamp(db) {
localStorage.setItem(this.STORAGE_KEY_PREAMP, db); localStorage.setItem(this.STORAGE_KEY_PREAMP, db);
} },
}; };
export const downloadQualitySettings = { export const downloadQualitySettings = {
@ -522,7 +520,7 @@ export const downloadQualitySettings = {
}, },
setQuality(quality) { setQuality(quality) {
localStorage.setItem(this.STORAGE_KEY, quality); localStorage.setItem(this.STORAGE_KEY, quality);
} },
}; };
export const waveformSettings = { export const waveformSettings = {
@ -538,7 +536,7 @@ export const waveformSettings = {
setEnabled(enabled) { setEnabled(enabled) {
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false'); localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
} },
}; };
export const smoothScrollingSettings = { export const smoothScrollingSettings = {
@ -554,7 +552,7 @@ export const smoothScrollingSettings = {
setEnabled(enabled) { setEnabled(enabled) {
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false'); localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
} },
}; };
export const queueManager = { export const queueManager = {
@ -578,20 +576,18 @@ export const queueManager = {
originalQueueBeforeShuffle: queueState.originalQueueBeforeShuffle, originalQueueBeforeShuffle: queueState.originalQueueBeforeShuffle,
currentQueueIndex: queueState.currentQueueIndex, currentQueueIndex: queueState.currentQueueIndex,
shuffleActive: queueState.shuffleActive, shuffleActive: queueState.shuffleActive,
repeatMode: queueState.repeatMode repeatMode: queueState.repeatMode,
}; };
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(minimalState)); localStorage.setItem(this.STORAGE_KEY, JSON.stringify(minimalState));
} catch (e) { } catch (e) {
console.warn('Failed to save queue to localStorage:', e); console.warn('Failed to save queue to localStorage:', e);
} }
} },
}; };
// System theme listener // System theme listener
if (typeof window !== 'undefined' && window.matchMedia) { 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') { if (themeManager.getTheme() === 'system') {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light'); document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
} }

View file

@ -1,5 +1,15 @@
//js/ui-interactions.js //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'; import { sidePanelManager } from './side-panel.js';
export function initializeUIInteractions(player, api) { export function initializeUIInteractions(player, api) {
@ -23,7 +33,7 @@ export function initializeUIInteractions(player, api) {
sidebarOverlay.addEventListener('click', closeSidebar); sidebarOverlay.addEventListener('click', closeSidebar);
sidebar.addEventListener('click', e => { sidebar.addEventListener('click', (e) => {
if (e.target.closest('a')) { if (e.target.closest('a')) {
closeSidebar(); closeSidebar();
} }
@ -113,9 +123,13 @@ export function initializeUIInteractions(player, api) {
<div class="modal-content"> <div class="modal-content">
<h3>Add Queue to Playlist</h3> <h3>Add Queue to Playlist</h3>
<div class="modal-list"> <div class="modal-list">
${playlists.map(p => ` ${playlists
.map(
(p) => `
<div class="modal-option" data-id="${p.id}">${escapeHtml(p.name)}</div> <div class="modal-option" data-id="${p.id}">${escapeHtml(p.name)}</div>
`).join('')} `
)
.join('')}
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-secondary cancel-btn">Cancel</button> <button class="btn-secondary cancel-btn">Cancel</button>
@ -179,12 +193,13 @@ export function initializeUIInteractions(player, api) {
return; return;
} }
const html = currentQueue.map((track, index) => { const html = currentQueue
const isPlaying = index === player.currentQueueIndex; .map((track, index) => {
const trackTitle = getTrackTitle(track); const isPlaying = index === player.currentQueueIndex;
const trackArtists = getTrackArtists(track, { fallback: "Unknown" }); 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="queue-track-item ${isPlaying ? 'playing' : ''}" data-queue-index="${index}" data-track-id="${track.id}" draggable="true">
<div class="drag-handle"> <div class="drag-handle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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> </button>
</div> </div>
`; `;
}).join(''); })
.join('');
container.innerHTML = html; container.innerHTML = html;
@ -223,7 +239,9 @@ export function initializeUIInteractions(player, api) {
const { db } = await import('./db.js'); const { db } = await import('./db.js');
const isLiked = await db.isFavorite('track', track.id); const isLiked = await db.isFavorite('track', track.id);
likeBtn.classList.toggle('active', isLiked); 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) => { item.addEventListener('click', async (e) => {
@ -249,9 +267,13 @@ export function initializeUIInteractions(player, api) {
// Update button state // Update button state
likeBtn.classList.toggle('active', added); 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; return;
} }
@ -279,7 +301,6 @@ export function initializeUIInteractions(player, api) {
trackMixItem.style.display = hasMix ? 'block' : 'none'; trackMixItem.style.display = hasMix ? 'block' : 'none';
} }
const rect = item.getBoundingClientRect(); const rect = item.getBoundingClientRect();
const menuWidth = 150; const menuWidth = 150;
const menuHeight = 200; const menuHeight = 200;
@ -298,7 +319,6 @@ export function initializeUIInteractions(player, api) {
contextMenu.style.top = `${top}px`; contextMenu.style.top = `${top}px`;
contextMenu.style.display = 'block'; contextMenu.style.display = 'block';
contextMenu._contextTrack = track; contextMenu._contextTrack = track;
} }
} }
@ -345,16 +365,16 @@ export function initializeUIInteractions(player, api) {
}; };
// Search and Library tabs // Search and Library tabs
document.querySelectorAll('.search-tab').forEach(tab => { document.querySelectorAll('.search-tab').forEach((tab) => {
tab.addEventListener('click', () => { tab.addEventListener('click', () => {
const page = tab.closest('.page'); const page = tab.closest('.page');
if (!page) return; if (!page) return;
page.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active')); 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-content').forEach((c) => c.classList.remove('active'));
tab.classList.add('active'); tab.classList.add('active');
const prefix = page.id === 'page-library' ? 'library-tab-' : 'search-tab-'; const prefix = page.id === 'page-library' ? 'library-tab-' : 'search-tab-';
const contentId = `${prefix}${tab.dataset.tab}`; const contentId = `${prefix}${tab.dataset.tab}`;
document.getElementById(contentId)?.classList.add('active'); document.getElementById(contentId)?.classList.add('active');

660
js/ui.js

File diff suppressed because it is too large Load diff

View file

@ -5,37 +5,58 @@ export const QUALITY = 'LOSSLESS';
export const REPEAT_MODE = { export const REPEAT_MODE = {
OFF: 0, OFF: 0,
ALL: 1, ALL: 1,
ONE: 2 ONE: 2,
}; };
export const AUDIO_QUALITIES = { export const AUDIO_QUALITIES = {
HI_RES_LOSSLESS: 'HI_RES_LOSSLESS', HI_RES_LOSSLESS: 'HI_RES_LOSSLESS',
LOSSLESS: 'LOSSLESS', LOSSLESS: 'LOSSLESS',
HIGH: 'HIGH', HIGH: 'HIGH',
LOW: 'LOW' LOW: 'LOW',
}; };
export const QUALITY_PRIORITY = ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW']; export const QUALITY_PRIORITY = ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'];
export const QUALITY_TOKENS = { 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'], LOSSLESS: ['LOSSLESS', 'HIFI'],
HIGH: ['HIGH', 'HIGH_QUALITY'], 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 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_PLAY =
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>'; '<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_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_PAUSE =
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>'; '<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_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_VOLUME =
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>'; '<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_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_MUTE =
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>'; '<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_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_DOWNLOAD =
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>'; '<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) => { export const formatTime = (seconds) => {
if (isNaN(seconds)) return '0:00'; if (isNaN(seconds)) return '0:00';
@ -78,7 +99,7 @@ export const buildTrackFilename = (track, quality) => {
trackNumber: track.trackNumber, trackNumber: track.trackNumber,
artist: artistName, artist: artistName,
title: getTrackTitle(track), title: getTrackTitle(track),
album: track.album?.title album: track.album?.title,
}; };
return formatTemplate(template, data) + '.' + extension; return formatTemplate(template, data) + '.' + extension;
@ -86,7 +107,10 @@ export const buildTrackFilename = (track, quality) => {
const sanitizeToken = (value) => { const sanitizeToken = (value) => {
if (!value) return ''; 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) => { export const normalizeQualityToken = (value) => {
@ -142,13 +166,13 @@ export const deriveTrackQuality = (track) => {
const candidates = [ const candidates = [
deriveQualityFromTags(track.mediaMetadata?.tags), deriveQualityFromTags(track.mediaMetadata?.tags),
deriveQualityFromTags(track.album?.mediaMetadata?.tags), deriveQualityFromTags(track.album?.mediaMetadata?.tags),
normalizeQualityToken(track.audioQuality) normalizeQualityToken(track.audioQuality),
]; ];
return pickBestQuality(candidates); 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) => { export const hasExplicitContent = (item) => {
return item?.explicit === true || item?.explicitLyrics === true; return item?.explicit === true || item?.explicitLyrics === true;
@ -169,11 +193,11 @@ export const debounce = (func, wait) => {
export const escapeHtml = (unsafe) => { export const escapeHtml = (unsafe) => {
if (typeof unsafe !== 'string') return unsafe; if (typeof unsafe !== 'string') return unsafe;
return unsafe return unsafe
.replace(/&/g, "&amp;") .replace(/&/g, '&amp;')
.replace(/</g, "&lt;") .replace(/</g, '&lt;')
.replace(/>/g, "&gt;") .replace(/>/g, '&gt;')
.replace(/"/g, "&quot;") .replace(/"/g, '&quot;')
.replace(/'/g, "&#039;"); .replace(/'/g, '&#039;');
}; };
export const getTrackTitle = (track, { fallback = 'Unknown Title' } = {}) => { 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' } = {}) => { export const getTrackArtists = (track = {}, { fallback = 'Unknown Artist' } = {}) => {
if (track?.artists?.length) { if (track?.artists?.length) {
return track.artists.map(artist => artist?.name).join(', '); return track.artists.map((artist) => artist?.name).join(', ');
} }
return fallback; return fallback;
@ -191,9 +215,9 @@ export const getTrackArtists = (track = {}, { fallback = 'Unknown Artist' } = {}
export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } = {}) => { export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } = {}) => {
if (track?.artists?.length) { if (track?.artists?.length) {
return track.artists.map(artist => return track.artists
`<span class="artist-link" data-artist-id="${artist.id}">${artist.name}</span>` .map((artist) => `<span class="artist-link" data-artist-id="${artist.id}">${artist.name}</span>`)
).join(', '); .join(', ');
} }
return fallback; return fallback;
@ -275,4 +299,3 @@ export async function getCoverBlob(api, coverId) {
} }
return null; return null;
} }

View file

@ -10,7 +10,9 @@ function rgbToHsl(r, g, b) {
const max = Math.max(r, g, b); const max = Math.max(r, g, b);
const min = Math.min(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) { if (max === min) {
h = s = 0; // achromatic h = s = 0; // achromatic
@ -19,9 +21,15 @@ function rgbToHsl(r, g, b) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min); s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) { switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break; case r:
case g: h = (b - r) / d + 2; break; h = (g - b) / d + (g < b ? 6 : 0);
case b: h = (r - g) / d + 4; break; break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
} }
h /= 6; h /= 6;
} }
@ -41,20 +49,20 @@ function hslToHex(h, s, l) {
const hue2rgb = (p, q, t) => { const hue2rgb = (p, q, t) => {
if (t < 0) t += 1; if (t < 0) t += 1;
if (t > 1) t -= 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t; if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1/2) return q; if (t < 1 / 2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p; return p;
}; };
const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q; const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3); r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h); 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); const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex; return hex.length === 1 ? '0' + hex : hex;
}; };
@ -89,7 +97,7 @@ export function getVibrantColorFromImage(imgElement) {
const imageData = ctx.getImageData(0, 0, w, h); const imageData = ctx.getImageData(0, 0, w, h);
const pixels = imageData.data; const pixels = imageData.data;
const candidates = []; const candidates = [];
// Iterate through pixels // Iterate through pixels
for (let i = 0; i < pixels.length; i += 4) { for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i]; const r = pixels[i];
@ -110,7 +118,7 @@ export function getVibrantColorFromImage(imgElement) {
// If no candidates found with strict criteria, relax criteria // If no candidates found with strict criteria, relax criteria
if (candidates.length === 0) { 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 r = pixels[i];
const g = pixels[i + 1]; const g = pixels[i + 1];
const b = pixels[i + 2]; const b = pixels[i + 2];
@ -129,15 +137,14 @@ export function getVibrantColorFromImage(imgElement) {
// Sort by saturation (descending) then lightness (proximity to 0.5) // Sort by saturation (descending) then lightness (proximity to 0.5)
candidates.sort((c1, c2) => { 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) // Pick the top candidate (most vibrant)
// Optionally averaging top N could be done, but simplified "best single pixel" is usually sufficient for "Vibrant" // Optionally averaging top N could be done, but simplified "best single pixel" is usually sufficient for "Vibrant"
const best = candidates[0]; const best = candidates[0];
return hslToHex(best.h, best.s, best.l);
return hslToHex(best.h, best.s, best.l);
} catch (e) { } catch (e) {
throw e; // Re-throw to allow UI to handle CORS retry throw e; // Re-throw to allow UI to handle CORS retry
} }

View file

@ -15,7 +15,7 @@ export class WaveformGenerator {
const response = await fetch(url); const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
const peaks = this.extractPeaks(audioBuffer); const peaks = this.extractPeaks(audioBuffer);
const result = { peaks, duration: audioBuffer.duration }; const result = { peaks, duration: audioBuffer.duration };
this.cache.set(trackId, result); this.cache.set(trackId, result);
@ -28,7 +28,7 @@ export class WaveformGenerator {
extractPeaks(audioBuffer) { extractPeaks(audioBuffer) {
const { length, duration } = 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 peaks = new Float32Array(numPeaks);
const chanData = audioBuffer.getChannelData(0); // Use first channel const chanData = audioBuffer.getChannelData(0); // Use first channel
const step = Math.floor(length / numPeaks); const step = Math.floor(length / numPeaks);
@ -71,13 +71,13 @@ export class WaveformGenerator {
const height = canvas.height; const height = canvas.height;
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
const step = width / peaks.length; const step = width / peaks.length;
const centerY = height / 2; const centerY = height / 2;
ctx.fillStyle = '#000'; // Mask color (opaque part) ctx.fillStyle = '#000'; // Mask color (opaque part)
ctx.beginPath(); ctx.beginPath();
// Draw top half // Draw top half
ctx.moveTo(0, centerY); ctx.moveTo(0, centerY);
for (let i = 0; i < peaks.length; i++) { 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); const barHeight = Math.max(1.5, peak * height * 0.9);
ctx.lineTo(i * step, centerY + barHeight / 2); ctx.lineTo(i * step, centerY + barHeight / 2);
} }
ctx.closePath(); ctx.closePath();
ctx.fill(); ctx.fill();
} }

View file

@ -1,45 +1,45 @@
{ {
"name": "monochrome", "name": "monochrome",
"type": "module", "type": "module",
"version": "1.0.0", "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)", "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", "main": "sw.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint:js": "eslint .", "lint:js": "eslint .",
"lint:css": "stylelint \"**/*.css\"", "lint:css": "stylelint \"**/*.css\"",
"lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"", "lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"",
"lint": "npm run lint:js && npm run lint:css && npm run lint:html", "lint": "npm run lint:js && npm run lint:css && npm run lint:html",
"format": "prettier --write .", "format": "prettier --write .",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/SamidyFR/monochrome.git" "url": "git+https://github.com/SamidyFR/monochrome.git"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"bugs": { "bugs": {
"url": "https://github.com/SamidyFR/monochrome/issues" "url": "https://github.com/SamidyFR/monochrome/issues"
}, },
"homepage": "https://github.com/SamidyFR/monochrome#readme", "homepage": "https://github.com/SamidyFR/monochrome#readme",
"devDependencies": { "devDependencies": {
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"globals": "^17.0.0", "globals": "^17.0.0",
"htmlhint": "^1.8.0", "htmlhint": "^1.8.0",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"stylelint": "^16.26.1", "stylelint": "^16.26.1",
"stylelint-config-standard": "^39.0.1", "stylelint-config-standard": "^39.0.1",
"stylelint-config-standard-scss": "^16.0.0", "stylelint-config-standard-scss": "^16.0.0",
"vite": "^7.3.0", "vite": "^7.3.0",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0"
}, },
"overrides": { "overrides": {
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@^1.4.14", "sourcemap-codec": "npm:@jridgewell/sourcemap-codec@^1.4.14",
"source-map": "^0.7.4" "source-map": "^0.7.4"
} }
} }

View file

@ -1,16 +1,16 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<link rel="icon" href="favicon.ico" type="image/x-icon"> <link rel="icon" href="favicon.ico" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redirecting...</title> <title>Redirecting...</title>
<meta http-equiv="refresh" content="0; URL='https://discord.gg/4DYm4artsN'"> <meta http-equiv="refresh" content="0; URL='https://discord.gg/4DYm4artsN'" />
<script> <script>
window.location.href = "https://discord.gg/4DYm4artsN"; window.location.href = 'https://discord.gg/4DYm4artsN';
</script> </script>
</head> </head>
<body> <body>
<p>If you are not redirected, <a href="https://discord.gg/4DYm4artsN">click here</a>.</p> <p>If you are not redirected, <a href="https://discord.gg/4DYm4artsN">click here</a>.</p>
</body> </body>
</html> </html>

View file

@ -1,16 +1,13 @@
{ {
"api": [ "api": ["https://tidal-api.binimum.org", "https://monochrome-api.samidy.com"],
"https://tidal-api.binimum.org", "streaming": [
"https://monochrome-api.samidy.com" "https://triton.squid.wtf",
], "https://wolf.qqdl.site",
"streaming": [ "https://maus.qqdl.site",
"https://triton.squid.wtf", "https://vogel.qqdl.site",
"https://wolf.qqdl.site", "https://katze.qqdl.site",
"https://maus.qqdl.site", "https://hund.qqdl.site",
"https://vogel.qqdl.site", "https://tidal.kinoplus.online",
"https://katze.qqdl.site", "https://tidal-api.binimum.org"
"https://hund.qqdl.site", ]
"https://tidal.kinoplus.online", }
"https://tidal-api.binimum.org"
]
}

View file

@ -1,52 +1,47 @@
{ {
"name": "Monochrome Music", "name": "Monochrome Music",
"short_name": "Monochrome", "short_name": "Monochrome",
"description": "A minimalist music streaming application", "description": "A minimalist music streaming application",
"start_url": "./", "start_url": "./",
"display": "standalone", "display": "standalone",
"display_override": [ "display_override": ["window-controls-overlay"],
"window-controls-overlay" "background_color": "#000000",
], "theme_color": "#000000",
"background_color": "#000000", "orientation": "portrait-primary",
"theme_color": "#000000", "icons": [
"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": [
{ {
"src": "assets/96.png", "src": "assets/appicon.png",
"sizes": "96x96", "sizes": "192x192",
"type": "image/png" "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"
}
]
}
]
}

View file

@ -1,5 +1,6 @@
:root { :root {
color-scheme: light dark; color-scheme: light dark;
--spacing-xs: 0.4rem; --spacing-xs: 0.4rem;
--spacing-sm: 0.5rem; --spacing-sm: 0.5rem;
--spacing-md: 1rem; --spacing-md: 1rem;
@ -8,15 +9,16 @@
--spacing-2xl: 3rem; --spacing-2xl: 3rem;
--radius: 0.5rem; --radius: 0.5rem;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.15); --shadow-sm: 0 4px 12px rgb(0, 0, 0, 0.15);
--shadow-md: 0 6px 16px rgba(0, 0, 0, 0.2); --shadow-md: 0 6px 16px rgb(0, 0, 0, 0.2);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5); --shadow-lg: 0 10px 30px rgb(0, 0, 0, 0.5);
--shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.8); --shadow-xl: 0 20px 60px rgb(0, 0, 0, 0.8);
--cover-filter: blur(50px) brightness(0.4); --cover-filter: blur(50px) brightness(0.4);
} }
:root[data-theme="monochrome"] { :root[data-theme='monochrome'] {
color-scheme: dark; color-scheme: dark;
--background: #000; --background: #000;
--foreground: #fafafa; --foreground: #fafafa;
--card: #111; --card: #111;
@ -36,8 +38,9 @@
--explicit-badge: #fafafa; --explicit-badge: #fafafa;
} }
:root[data-theme="dark"] { :root[data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
--background: #0a0a0a; --background: #0a0a0a;
--foreground: #ededed; --foreground: #ededed;
--card: #1a1a1a; --card: #1a1a1a;
@ -57,8 +60,9 @@
--explicit-badge: #750a0a; --explicit-badge: #750a0a;
} }
:root[data-theme="ocean"] { :root[data-theme='ocean'] {
color-scheme: dark; color-scheme: dark;
--background: #0c1821; --background: #0c1821;
--foreground: #e0f4ff; --foreground: #e0f4ff;
--card: #1b2838; --card: #1b2838;
@ -78,8 +82,9 @@
--explicit-badge: #f43f5e; --explicit-badge: #f43f5e;
} }
:root[data-theme="purple"] { :root[data-theme='purple'] {
color-scheme: dark; color-scheme: dark;
--background: #0f0514; --background: #0f0514;
--foreground: #f3e8ff; --foreground: #f3e8ff;
--card: #1e0a2e; --card: #1e0a2e;
@ -99,8 +104,9 @@
--explicit-badge: #ec4899; --explicit-badge: #ec4899;
} }
:root[data-theme="forest"] { :root[data-theme='forest'] {
color-scheme: dark; color-scheme: dark;
--background: #0a1409; --background: #0a1409;
--foreground: #e8f5e9; --foreground: #e8f5e9;
--card: #1a2e1a; --card: #1a2e1a;
@ -120,8 +126,9 @@
--explicit-badge: #f59e0b; --explicit-badge: #f59e0b;
} }
:root[data-theme="mocha"] { :root[data-theme='mocha'] {
color-scheme: dark; color-scheme: dark;
--background: #1e1e2e; --background: #1e1e2e;
--foreground: #cdd6f4; --foreground: #cdd6f4;
--card: #313244; --card: #313244;
@ -141,8 +148,9 @@
--explicit-badge: #f9e2af; --explicit-badge: #f9e2af;
} }
:root[data-theme="machiatto"] { :root[data-theme='machiatto'] {
color-scheme: dark; color-scheme: dark;
--background: #24273a; --background: #24273a;
--foreground: #cad3f5; --foreground: #cad3f5;
--card: #363a4f; --card: #363a4f;
@ -162,8 +170,9 @@
--explicit-badge: #eed49f; --explicit-badge: #eed49f;
} }
:root[data-theme="frappe"] { :root[data-theme='frappe'] {
color-scheme: dark; color-scheme: dark;
--background: #303446; --background: #303446;
--foreground: #c6d0f5; --foreground: #c6d0f5;
--card: #414559; --card: #414559;
@ -183,8 +192,9 @@
--explicit-badge: #e5c890; --explicit-badge: #e5c890;
} }
:root[data-theme="latte"] { :root[data-theme='latte'] {
color-scheme: light; color-scheme: light;
--background: #eff1f5; --background: #eff1f5;
--foreground: #4c4f69; --foreground: #4c4f69;
--card: #ccd0da; --card: #ccd0da;
@ -204,16 +214,17 @@
--explicit-badge: #df8e1d; --explicit-badge: #df8e1d;
} }
:root[data-theme="light"] { :root[data-theme='light'] {
color-scheme: light; color-scheme: light;
--background: #ffffff;
--foreground: #000000; --background: #fff;
--foreground: #000;
--card: #f4f4f5; --card: #f4f4f5;
--card-foreground: #000000; --card-foreground: #000;
--primary: #2563eb; --primary: #2563eb;
--primary-foreground: #ffffff; --primary-foreground: #fff;
--secondary: #e4e4e7; --secondary: #e4e4e7;
--secondary-foreground: #000000; --secondary-foreground: #000;
--muted: #e4e4e7; --muted: #e4e4e7;
--muted-foreground: #71717a; --muted-foreground: #71717a;
--border: #e4e4e7; --border: #e4e4e7;
@ -226,7 +237,9 @@
--cover-filter: blur(50px) brightness(1.6) opacity(0.35); --cover-filter: blur(50px) brightness(1.6) opacity(0.35);
} }
*, *::before, *::after { *,
*::before,
*::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -243,9 +256,11 @@ html {
body { body {
background-color: var(--background); background-color: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: 'Inter', sans-serif; font-family: Inter, sans-serif;
overflow: hidden; overflow: hidden;
transition: background-color 0.3s ease, color 0.3s ease; transition:
background-color 0.3s ease,
color 0.3s ease;
height: 100%; height: 100%;
position: fixed; position: fixed;
width: 100%; width: 100%;
@ -280,7 +295,7 @@ kbd {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
font-family: 'Courier New', monospace; 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 { .app-container {
@ -288,8 +303,8 @@ kbd {
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
grid-template: grid-template:
"sidebar main" 1fr 'sidebar main' 1fr
"player player" auto / 190px 1fr; 'player player' auto / 190px 1fr;
} }
.sidebar { .sidebar {
@ -333,8 +348,8 @@ kbd {
transition: opacity 0.5s ease-in-out; transition: opacity 0.5s ease-in-out;
/* Fade out at the bottom */ /* 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%); 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%);
-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%);
/* Blur effect */ /* Blur effect */
filter: var(--cover-filter); filter: var(--cover-filter);
@ -346,9 +361,9 @@ kbd {
} }
/* Light mode adjustments */ /* Light mode adjustments */
:root[data-theme="light"] #page-background { :root[data-theme='light'] #page-background {
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%); mask-image: linear-gradient(to bottom, rgb(0, 0, 0, 1) 0%, rgb(0, 0, 0, 0) 100%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(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 { .now-playing-bar {
@ -414,7 +429,7 @@ kbd {
display: none; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.5); background: rgb(0, 0, 0, 0.5);
z-index: 1999; z-index: 1999;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
@ -579,7 +594,6 @@ body.has-page-background .track-item:hover {
} }
@keyframes pulse { @keyframes pulse {
0%, 0%,
100% { 100% {
opacity: 1; opacity: 1;
@ -680,9 +694,9 @@ body.has-page-background .track-item:hover {
position: absolute; position: absolute;
top: 2%; top: 2%;
right: 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); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 50% !important; border-radius: 50% !important;
width: 32px !important; width: 32px !important;
height: 32px !important; height: 32px !important;
@ -705,7 +719,7 @@ body.has-page-background .track-item:hover {
} }
.card-like-btn: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; transform: scale(1.1) !important;
} }
@ -717,9 +731,9 @@ body.has-page-background .track-item:hover {
.delete-playlist-btn { .delete-playlist-btn {
position: absolute; position: absolute;
top: 2%; 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); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 50% !important; border-radius: 50% !important;
width: 32px !important; width: 32px !important;
height: 32px !important; height: 32px !important;
@ -752,7 +766,7 @@ body.has-page-background .track-item:hover {
.edit-playlist-btn:hover, .edit-playlist-btn:hover,
.delete-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; transform: scale(1.1) !important;
} }
@ -821,7 +835,9 @@ body.has-page-background .track-item:hover {
} }
.heart-icon { .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 { .like-btn:hover .heart-icon {
@ -893,7 +909,7 @@ body.has-page-background .track-item:hover {
} }
.track-item.playing { .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); border-left: 3px solid var(--highlight);
padding-left: calc(var(--spacing-sm) - 3px); padding-left: calc(var(--spacing-sm) - 3px);
} }
@ -984,7 +1000,7 @@ body.has-page-background .track-item:hover {
z-index: 10; z-index: 10;
} }
[data-track-actions-mode="dropdown"] .track-menu-btn { [data-track-actions-mode='dropdown'] .track-menu-btn {
display: flex; display: flex;
} }
@ -992,10 +1008,8 @@ body.has-page-background .track-item:hover {
opacity: 1; opacity: 1;
} }
.track-menu-btn:hover { .track-menu-btn:hover {
background-color: rgba(var(--highlight-rgb), 0.2); background-color: rgb(var(--highlight-rgb), 0.2);
color: var(--foreground); color: var(--foreground);
} }
@ -1010,7 +1024,7 @@ body.has-page-background .track-item:hover {
opacity: 1; opacity: 1;
} }
[data-track-actions-mode="inline"] .track-actions-inline { [data-track-actions-mode='inline'] .track-actions-inline {
display: flex; display: flex;
} }
@ -1018,8 +1032,6 @@ body.has-page-background .track-item:hover {
opacity: 1; opacity: 1;
} }
.track-action-btn { .track-action-btn {
background: transparent; background: transparent;
border: none; border: none;
@ -1035,7 +1047,7 @@ body.has-page-background .track-item:hover {
} }
.track-action-btn:hover { .track-action-btn:hover {
background-color: rgba(var(--highlight-rgb), 0.2); background-color: rgb(var(--highlight-rgb), 0.2);
color: var(--foreground); color: var(--foreground);
} }
@ -1257,7 +1269,7 @@ body.has-page-background .track-item:hover {
} }
.setting-item select, .setting-item select,
.setting-item input[type="number"] { .setting-item input[type='number'] {
background-color: var(--input); background-color: var(--input);
color: var(--foreground); color: var(--foreground);
border: 1px solid var(--border); border: 1px solid var(--border);
@ -1265,7 +1277,7 @@ body.has-page-background .track-item:hover {
padding: 0.5rem; padding: 0.5rem;
} }
.setting-item input[type="number"] { .setting-item input[type='number'] {
width: 100px; width: 100px;
} }
@ -1310,7 +1322,7 @@ body.has-page-background .track-item:hover {
.slider::before { .slider::before {
position: absolute; position: absolute;
content: ""; content: '';
height: 16px; height: 16px;
width: 16px; width: 16px;
left: 4px; left: 4px;
@ -1479,7 +1491,7 @@ input:checked + .slider::before {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
-webkit-user-select: none; user-select: none;
touch-action: none; touch-action: none;
} }
@ -1519,8 +1531,9 @@ input:checked + .slider::before {
height: 12px; height: 12px;
background-color: var(--highlight); background-color: var(--highlight);
border-radius: 50%; 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 { .progress-bar.has-waveform.waveform-loaded {
height: 28px; height: 28px;
} }
@ -1607,7 +1620,7 @@ input:checked + .slider::before {
height: 12px; height: 12px;
background-color: var(--highlight); background-color: var(--highlight);
border-radius: 50%; 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 */ /* Sleep Timer Button */
@ -1624,7 +1637,7 @@ input:checked + .slider::before {
#sleep-timer-btn.active { #sleep-timer-btn.active {
color: var(--primary); 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 { #sleep-timer-btn svg {
@ -1672,7 +1685,7 @@ input:checked + .slider::before {
display: none; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
background-color: rgba(0, 0, 0, 0.7); background-color: rgb(0, 0, 0, 0.7);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
z-index: 3000; z-index: 3000;
justify-content: center; justify-content: center;
@ -1691,13 +1704,15 @@ input:checked + .slider::before {
animation: fadeIn 0.3s ease; animation: fadeIn 0.3s ease;
overflow: hidden; overflow: hidden;
background-color: var(--background); background-color: var(--background);
/* Use a CSS variable for the image so we can set it in JS */ /* Use a CSS variable for the image so we can set it in JS */
--bg-image: none; --bg-image: none;
padding-bottom: 90px; /* Account for desktop player bar */ padding-bottom: 90px; /* Account for desktop player bar */
} }
#fullscreen-cover-overlay::before { #fullscreen-cover-overlay::before {
content: ""; content: '';
position: absolute; position: absolute;
inset: -20px; inset: -20px;
background-size: cover; background-size: cover;
@ -1716,6 +1731,7 @@ input:checked + .slider::before {
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
/* Remove fixed padding to allow flex centering to work within the overlay's padded box */ /* Remove fixed padding to allow flex centering to work within the overlay's padded box */
padding: 1rem; padding: 1rem;
position: relative; position: relative;
@ -1749,7 +1765,7 @@ input:checked + .slider::before {
max-width: 80vw; max-width: 80vw;
max-height: 60vh; max-height: 60vh;
border-radius: var(--radius); 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; object-fit: contain;
margin-bottom: 2rem; margin-bottom: 2rem;
z-index: 1; z-index: 1;
@ -1805,8 +1821,6 @@ input:checked + .slider::before {
color: var(--foreground); color: var(--foreground);
} }
#queue-modal { #queue-modal {
background-color: var(--card); background-color: var(--card);
width: 90%; width: 90%;
@ -1892,7 +1906,7 @@ input:checked + .slider::before {
} }
.queue-track-item .queue-like-btn:hover { .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); color: var(--foreground);
} }
@ -1914,7 +1928,7 @@ input:checked + .slider::before {
} }
.queue-track-item.playing { .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); border-left: 3px solid var(--highlight);
padding-left: calc(var(--spacing-sm) - 3px); padding-left: calc(var(--spacing-sm) - 3px);
} }
@ -1930,7 +1944,6 @@ input:checked + .slider::before {
.queue-track-item .queue-remove-btn { .queue-track-item .queue-remove-btn {
background: transparent; background: transparent;
border: none; border: none;
color: var(--muted-foreground);
cursor: pointer; cursor: pointer;
padding: 0.5rem; padding: 0.5rem;
border-radius: var(--radius); border-radius: var(--radius);
@ -1947,8 +1960,6 @@ input:checked + .slider::before {
opacity: 1; opacity: 1;
} }
.queue-track-item .queue-remove-btn:hover { .queue-track-item .queue-remove-btn:hover {
background-color: var(--background); background-color: var(--background);
color: var(--foreground); color: var(--foreground);
@ -1969,10 +1980,7 @@ input:checked + .slider::before {
} }
.skeleton { .skeleton {
background: linear-gradient(90deg, background: linear-gradient(90deg, var(--secondary) 0%, var(--muted) 50%, var(--secondary) 100%);
var(--secondary) 0%,
var(--muted) 50%,
var(--secondary) 100%);
background-size: 200% 100%; background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite; animation: skeleton-loading 1.5s ease-in-out infinite;
border-radius: var(--radius); border-radius: var(--radius);
@ -2173,7 +2181,7 @@ input:checked + .slider::before {
color: var(--muted-foreground); color: var(--muted-foreground);
} }
.theme-color-input input[type="color"] { .theme-color-input input[type='color'] {
width: 100%; width: 100%;
height: 40px; height: 40px;
border: 1px solid var(--border); border: 1px solid var(--border);
@ -2223,7 +2231,7 @@ input:checked + .slider::before {
} }
.about-features li::before { .about-features li::before {
content: "✓"; content: '✓';
position: absolute; position: absolute;
left: 0; left: 0;
color: var(--highlight); color: var(--highlight);
@ -2466,7 +2474,6 @@ input:checked + .slider::before {
border-bottom: none; border-bottom: none;
} }
#playlist-detail-description, #playlist-detail-description,
#mix-detail-description { #mix-detail-description {
color: var(--foreground); color: var(--foreground);
@ -2497,21 +2504,6 @@ input:checked + .slider::before {
/* Responsive Design */ /* Responsive Design */
@supports (padding-top: env(safe-area-inset-top)) { @supports (padding-top: env(safe-area-inset-top)) {
.now-playing-bar { .now-playing-bar {
padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom)); padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom));
@ -2542,7 +2534,9 @@ input:checked + .slider::before {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transform: translateX(100%); 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; box-shadow: none;
} }
@ -2604,7 +2598,6 @@ input:checked + .slider::before {
/* Inherits side-panel */ /* Inherits side-panel */
} }
/* Synced lyrics styling with Apple Music animations */ /* Synced lyrics styling with Apple Music animations */
.synced-line { .synced-line {
padding: 0.5rem 0; padding: 0.5rem 0;
@ -2624,7 +2617,7 @@ input:checked + .slider::before {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
filter: blur(0); 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 { .synced-line.upcoming {
@ -2708,7 +2701,6 @@ input:checked + .slider::before {
/* Hide play button initially on desktop (hover capable), show on hover */ /* Hide play button initially on desktop (hover capable), show on hover */
/* Adjust like button for compact size */ /* Adjust like button for compact size */
.card.compact .card-like-btn { .card.compact .card-like-btn {
width: 24px !important; width: 24px !important;
@ -2728,7 +2720,6 @@ input:checked + .slider::before {
/* Mobile adjustments */ /* Mobile adjustments */
/* Clickable album cover indicator */ /* Clickable album cover indicator */
.now-playing-bar .cover { .now-playing-bar .cover {
cursor: pointer; cursor: pointer;
@ -2747,7 +2738,7 @@ input:checked + .slider::before {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(0, 0, 0, 0.7); background: rgb(0, 0, 0, 0.7);
opacity: 0; opacity: 0;
transition: opacity var(--transition); transition: opacity var(--transition);
font-size: 1.5rem; font-size: 1.5rem;
@ -2759,7 +2750,6 @@ input:checked + .slider::before {
/* Window Controls Overlay */ /* Window Controls Overlay */
.now-playing-bar .artist .artist-link { .now-playing-bar .artist .artist-link {
cursor: pointer; cursor: pointer;
transition: color var(--transition); 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 */ /* Show a grey box for images with no source, hiding the broken icon */
img:not([src]), img[src=''] { img:not([src]),
content: url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"); img[src=''] {
content: url('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
background-color: var(--muted); background-color: var(--muted);
} }
@ -2885,7 +2876,6 @@ img:not([src]), img[src=''] {
max-width: 100%; max-width: 100%;
} }
.fullscreen-cover-content { .fullscreen-cover-content {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -2908,7 +2898,7 @@ img:not([src]), img[src=''] {
position: absolute; position: absolute;
top: 1rem; top: 1rem;
right: 4rem; right: 4rem;
background: rgba(0, 0, 0, 0.5); background: rgb(0, 0, 0, 0.5);
border: none; border: none;
color: white; color: white;
padding: 0.75rem; padding: 0.75rem;
@ -2922,7 +2912,7 @@ img:not([src]), img[src=''] {
} }
.fullscreen-lyrics-toggle:hover { .fullscreen-lyrics-toggle:hover {
background: rgba(0, 0, 0, 0.7); background: rgb(0, 0, 0, 0.7);
transform: scale(1.1); transform: scale(1.1);
} }
@ -2947,7 +2937,7 @@ img:not([src]), img[src=''] {
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.7); background: rgb(0, 0, 0, 0.7);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
z-index: -1; z-index: -1;
} }
@ -3029,8 +3019,14 @@ img:not([src]), img[src=''] {
} }
@keyframes scaleIn { @keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; } from {
to { transform: scale(1); opacity: 1; } transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
} }
#playlist-modal { #playlist-modal {
@ -3045,7 +3041,8 @@ img:not([src]), img[src=''] {
0% { 0% {
opacity: 0; opacity: 0;
} }
100% {
100% {
opacity: 1; opacity: 1;
} }
} }
@ -3071,7 +3068,7 @@ img:not([src]), img[src=''] {
} }
.csv-import-progress .progress-header h4 { .csv-import-progress .progress-header h4 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
color: var(--foreground); color: var(--foreground);
@ -3121,18 +3118,17 @@ img:not([src]), img[src=''] {
left: 0; left: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
background: linear-gradient( background: linear-gradient(90deg, transparent 0%, rgb(255, 255, 255, 0.3) 50%, transparent 100%);
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
animation: shimmer 2s infinite; animation: shimmer 2s infinite;
} }
@keyframes shimmer { @keyframes shimmer {
0% { transform: translateX(-100%); } 0% {
100% { transform: translateX(100%); } transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
} }
.csv-import-progress .progress-text { .csv-import-progress .progress-text {
@ -3146,13 +3142,11 @@ img:not([src]), img[src=''] {
color: var(--foreground); color: var(--foreground);
} }
.missing-tracks-header { .missing-tracks-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 2rem 2rem 1.5rem 2rem; padding: 2rem 2rem 1.5rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@ -3218,7 +3212,7 @@ img:not([src]), img[src=''] {
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
color: var(--foreground); color: var(--foreground);
font-family: 'Inter', sans-serif; font-family: Inter, sans-serif;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 400; font-weight: 400;
line-height: 1.4; line-height: 1.4;
@ -3229,11 +3223,11 @@ img:not([src]), img[src=''] {
} }
.missing-tracks-list li:nth-child(even) { .missing-tracks-list li:nth-child(even) {
background: rgba(255, 255, 255, 0.02); background: rgb(255, 255, 255, 0.02);
} }
.missing-tracks-actions { .missing-tracks-actions {
padding: 1.5rem 2rem 2rem 2rem; padding: 1.5rem 2rem 2rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@ -3246,10 +3240,6 @@ img:not([src]), img[src=''] {
font-weight: 500; font-weight: 500;
} }
/* Default responsive classes */ /* Default responsive classes */
.mobile-only { .mobile-only {
display: none !important; display: none !important;
@ -3306,19 +3296,19 @@ img:not([src]), img[src=''] {
/* --- Responsive & Media Queries --- */ /* --- Responsive & Media Queries --- */
@media (min-width: 1440px) { @media (min-width: 1440px) {
.card-grid { .card-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
} }
} }
@media (min-width: 1920px) { @media (min-width: 1920px) {
.card-grid { .card-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
} }
} }
@media (min-width: 769px) and (max-width: 1024px) { @media (min-width: 769px) and (max-width: 1024px) {
.now-playing-bar { .now-playing-bar {
grid-template-columns: 1fr 2fr auto; grid-template-columns: 1fr 2fr auto;
padding: var(--spacing-md); padding: var(--spacing-md);
} }
@ -3333,7 +3323,7 @@ img:not([src]), img[src=''] {
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.app-container { .app-container {
grid-template-columns: 160px 1fr; grid-template-columns: 160px 1fr;
} }
@ -3360,13 +3350,11 @@ img:not([src]), img[src=''] {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.player-controls .progress-container {
order: -1;
}
#fullscreen-cover-overlay {
.player-controls .progress-container {
order: -1;
}
#fullscreen-cover-overlay {
padding-bottom: 160px; /* Account for taller mobile player bar */ padding-bottom: 160px; /* Account for taller mobile player bar */
} }
@ -3383,11 +3371,11 @@ img:not([src]), img[src=''] {
font-size: 1rem; font-size: 1rem;
} }
.app-container { .app-container {
grid-template: grid-template:
"header" auto 'header' auto
"main" 1fr 'main' 1fr
"player" auto / 1fr; 'player' auto / 1fr;
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
} }
@ -3409,12 +3397,14 @@ img:not([src]), img[src=''] {
height: 100%; height: 100%;
transform: translateX(-100%); transform: translateX(-100%);
z-index: 2000; 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 { .sidebar.is-open {
transform: translateX(0); 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 { .hamburger-menu {
@ -3509,8 +3499,8 @@ img:not([src]), img[src=''] {
.now-playing-bar { .now-playing-bar {
grid-template: grid-template:
"track controls" auto 'track controls' auto
"progress progress" auto / 1fr auto; 'progress progress' auto / 1fr auto;
gap: var(--spacing-sm); gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
height: var(--player-bar-height-mobile); height: var(--player-bar-height-mobile);
@ -3699,7 +3689,7 @@ img:not([src]), img[src=''] {
bottom: 10px; bottom: 10px;
} }
.side-panel { .side-panel {
width: 100vw; width: 100vw;
max-width: 100vw; max-width: 100vw;
bottom: var(--player-bar-height-mobile); bottom: var(--player-bar-height-mobile);
@ -3810,7 +3800,7 @@ img:not([src]), img[src=''] {
} }
.detail-header-info .title.long-title { .detail-header-info .title.long-title {
font-size: 1.10rem; font-size: 1.1rem;
} }
.detail-header-info .title.very-long-title { .detail-header-info .title.very-long-title {
@ -3914,7 +3904,7 @@ img:not([src]), img[src=''] {
height: 32px; 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; display: none;
} }
@ -3922,8 +3912,9 @@ img:not([src]), img[src=''] {
gap: 0.25rem; gap: 0.25rem;
} }
} }
@media (max-width: 360px) { @media (max-width: 360px) {
.player-controls .buttons { .player-controls .buttons {
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
} }
@ -3935,7 +3926,7 @@ img:not([src]), img[src=''] {
} }
@media (display-mode: window-controls-overlay) { @media (display-mode: window-controls-overlay) {
.app-container { .app-container {
margin-top: env(titlebar-area-height, 0); margin-top: env(titlebar-area-height, 0);
} }
@ -3953,7 +3944,7 @@ img:not([src]), img[src=''] {
} }
@media (hover: hover) { @media (hover: hover) {
.card.compact .card-play-btn { .card.compact .card-play-btn {
opacity: 0; opacity: 0;
} }
@ -3963,15 +3954,15 @@ img:not([src]), img[src=''] {
} }
@media (hover: none) { @media (hover: none) {
.track-menu-btn { .track-menu-btn {
opacity: 1; opacity: 1;
} }
.track-actions-inline { .track-actions-inline {
opacity: 1; opacity: 1;
} }
.queue-track-item .queue-remove-btn { .queue-track-item .queue-remove-btn {
opacity: 1; opacity: 1;
} }
@ -3982,7 +3973,7 @@ img:not([src]), img[src=''] {
} }
@media (hover: none) and (pointer: coarse) { @media (hover: none) and (pointer: coarse) {
.main-content { .main-content {
padding: var(--spacing-sm); padding: var(--spacing-sm);
grid-area: main; grid-area: main;
} }
@ -4003,7 +3994,7 @@ img:not([src]), img[src=''] {
height: 16px; height: 16px;
background-color: var(--highlight); background-color: var(--highlight);
border-radius: 50%; 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 { button {
@ -4043,4 +4034,4 @@ img:not([src]), img[src=''] {
.player-controls .progress-container { .player-controls .progress-container {
order: -1; order: -1;
} }
} }

View file

@ -1,49 +1,47 @@
import { defineConfig } from "vite"; import { defineConfig } from 'vite';
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({ export default defineConfig({
base: "./", base: './',
build: { build: {
outDir: "dist", outDir: 'dist',
emptyOutDir: true, emptyOutDir: true,
}, },
plugins: [ plugins: [
VitePWA({ VitePWA({
registerType: "prompt", registerType: 'prompt',
workbox: { workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,json}"], globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'],
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
// Define runtime caching strategies // Define runtime caching strategies
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: ({ request }) => request.destination === "image", urlPattern: ({ request }) => request.destination === 'image',
handler: "CacheFirst", handler: 'CacheFirst',
options: { options: {
cacheName: "images", cacheName: 'images',
expiration: { expiration: {
maxEntries: 100, maxEntries: 100,
maxAgeSeconds: 60 * 24 * 60 * 60, // 60 Days 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
},
},
],
}, },
}, includeAssets: ['instances.json', 'discord.html'],
{ manifest: false, // Use existing public/manifest.json
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
}),
],
}); });