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

150
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,
}; };
} }
@ -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);
@ -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);
@ -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;
} }
@ -464,7 +467,7 @@ 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,7 +477,7 @@ 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) {
@ -492,7 +495,7 @@ export class LosslessAPI {
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;
@ -541,7 +543,7 @@ export class LosslessAPI {
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,
@ -549,7 +551,7 @@ export class LosslessAPI {
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,7 +565,7 @@ 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();
@ -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,10 +606,10 @@ 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 {
@ -617,8 +619,9 @@ export class LosslessAPI {
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;
@ -663,7 +661,7 @@ export class LosslessAPI {
// 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;
@ -683,7 +681,7 @@ export class LosslessAPI {
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;
@ -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,
}; };
} }
} }

687
js/app.js
View file

@ -1,10 +1,22 @@
//js/app.js //js/app.js
import { LosslessAPI } from './api.js'; import { LosslessAPI } from './api.js';
import { apiSettings, themeManager, nowPlayingSettings, trackListSettings, downloadQualitySettings } from './storage.js'; import {
apiSettings,
themeManager,
nowPlayingSettings,
trackListSettings,
downloadQualitySettings,
} from './storage.js';
import { UIRenderer } from './ui.js'; import { UIRenderer } from './ui.js';
import { Player } from './player.js'; import { Player } from './player.js';
import { LastFMScrobbler } from './lastfm.js'; import { LastFMScrobbler } from './lastfm.js';
import { LyricsManager, openLyricsPanel, clearLyricsPanelSync, renderLyricsInFullscreen, clearFullscreenLyricsSync } from './lyrics.js'; import {
LyricsManager,
openLyricsPanel,
clearLyricsPanelSync,
renderLyricsInFullscreen,
clearFullscreenLyricsSync,
} from './lyrics.js';
import { createRouter, updateTabTitle } from './router.js'; import { createRouter, updateTabTitle } from './router.js';
import { initializeSettings } from './settings.js'; import { initializeSettings } from './settings.js';
import { initializePlayerEvents, initializeTrackInteractions, handleTrackAction } from './events.js'; import { initializePlayerEvents, initializeTrackInteractions, handleTrackAction } from './events.js';
@ -17,29 +29,30 @@ import { syncManager } from './firebase/sync.js';
import { registerSW } from 'virtual:pwa-register'; import { registerSW } from 'virtual:pwa-register';
import './smooth-scrolling.js'; import './smooth-scrolling.js';
function initializeCasting(audioPlayer, castBtn) { function initializeCasting(audioPlayer, castBtn) {
if (!castBtn) return; if (!castBtn) return;
if ('remote' in audioPlayer) { if ('remote' in audioPlayer) {
audioPlayer.remote.watchAvailability((available) => { audioPlayer.remote
if (available) { .watchAvailability((available) => {
castBtn.style.display = 'flex'; if (available) {
castBtn.classList.add('available'); castBtn.style.display = 'flex';
} castBtn.classList.add('available');
}).catch(err => { }
console.log('Remote playback not available:', err); })
if (window.innerWidth > 768) { .catch((err) => {
castBtn.style.display = 'flex'; console.log('Remote playback not available:', err);
} if (window.innerWidth > 768) {
}); castBtn.style.display = 'flex';
}
});
castBtn.addEventListener('click', () => { castBtn.addEventListener('click', () => {
if (!audioPlayer.src) { if (!audioPlayer.src) {
alert('Please play a track first to enable casting.'); alert('Please play a track first to enable casting.');
return; return;
} }
audioPlayer.remote.prompt().catch(err => { audioPlayer.remote.prompt().catch((err) => {
if (err.name === 'NotAllowedError') return; if (err.name === 'NotAllowedError') return;
if (err.name === 'NotFoundError') { if (err.name === 'NotFoundError') {
alert('No remote playback devices (Chromecast/AirPlay) were found on your network.'); alert('No remote playback devices (Chromecast/AirPlay) were found on your network.');
@ -60,8 +73,7 @@ function initializeCasting(audioPlayer, castBtn) {
castBtn.classList.remove('connected'); castBtn.classList.remove('connected');
} }
}); });
} } else if (audioPlayer.webkitShowPlaybackTargetPicker) {
else if (audioPlayer.webkitShowPlaybackTargetPicker) {
castBtn.style.display = 'flex'; castBtn.style.display = 'flex';
castBtn.classList.add('available'); castBtn.classList.add('available');
@ -82,8 +94,7 @@ function initializeCasting(audioPlayer, castBtn) {
castBtn.classList.remove('connected'); castBtn.classList.remove('connected');
} }
}); });
} } else if (window.innerWidth > 768) {
else if (window.innerWidth > 768) {
castBtn.style.display = 'flex'; castBtn.style.display = 'flex';
castBtn.addEventListener('click', () => { castBtn.addEventListener('click', () => {
alert('Casting is not supported in this browser. Try Chrome for Chromecast or Safari for AirPlay.'); alert('Casting is not supported in this browser. Try Chrome for Chromecast or Safari for AirPlay.');
@ -91,12 +102,11 @@ function initializeCasting(audioPlayer, castBtn) {
} }
} }
function initializeKeyboardShortcuts(player, audioPlayer) { function initializeKeyboardShortcuts(player, audioPlayer) {
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea')) return; if (e.target.matches('input, textarea')) return;
switch(e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case ' ': case ' ':
e.preventDefault(); e.preventDefault();
player.handlePlayPause(); player.handlePlayPause();
@ -105,10 +115,7 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
if (e.shiftKey) { if (e.shiftKey) {
player.playNext(); player.playNext();
} else { } else {
audioPlayer.currentTime = Math.min( audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10);
audioPlayer.duration,
audioPlayer.currentTime + 10
);
} }
break; break;
case 'arrowleft': case 'arrowleft':
@ -193,7 +200,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const lyricsManager = new LyricsManager(api); const lyricsManager = new LyricsManager(api);
// Pre-load Kuroshiro for romaji conversion in background (always load so it's ready instantly) // Pre-load Kuroshiro for romaji conversion in background (always load so it's ready instantly)
lyricsManager.loadKuroshiro().catch(err => { lyricsManager.loadKuroshiro().catch((err) => {
console.warn('Failed to pre-load Kuroshiro:', err); console.warn('Failed to pre-load Kuroshiro:', err);
}); });
@ -203,15 +210,21 @@ document.addEventListener('DOMContentLoaded', async () => {
initializeSettings(scrobbler, player, api, ui); initializeSettings(scrobbler, player, api, ui);
initializePlayerEvents(player, audioPlayer, scrobbler, ui); initializePlayerEvents(player, audioPlayer, scrobbler, ui);
initializeTrackInteractions(player, api, document.querySelector('.main-content'), document.getElementById('context-menu'), lyricsManager, ui, scrobbler); initializeTrackInteractions(
player,
api,
document.querySelector('.main-content'),
document.getElementById('context-menu'),
lyricsManager,
ui,
scrobbler
);
initializeUIInteractions(player, api); initializeUIInteractions(player, api);
initializeKeyboardShortcuts(player, audioPlayer); initializeKeyboardShortcuts(player, audioPlayer);
const castBtn = document.getElementById('cast-btn'); const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn); initializeCasting(audioPlayer, castBtn);
// Restore UI state for the current track (like button, theme) // Restore UI state for the current track (like button, theme)
if (player.currentTrack) { if (player.currentTrack) {
ui.setCurrentTrack(player.currentTrack); ui.setCurrentTrack(player.currentTrack);
@ -234,7 +247,6 @@ document.addEventListener('DOMContentLoaded', async () => {
} else { } else {
openLyricsPanel(player.currentTrack, audioPlayer, lyricsManager); openLyricsPanel(player.currentTrack, audioPlayer, lyricsManager);
} }
} else if (mode === 'cover') { } else if (mode === 'cover') {
const overlay = document.getElementById('fullscreen-cover-overlay'); const overlay = document.getElementById('fullscreen-cover-overlay');
if (overlay && overlay.style.display === 'flex') { if (overlay && overlay.style.display === 'flex') {
@ -317,8 +329,8 @@ document.addEventListener('DOMContentLoaded', async () => {
// Update Fullscreen/Enlarged Cover if it's open // Update Fullscreen/Enlarged Cover if it's open
const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay'); const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay');
if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') { if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') {
const nextTrack = player.getNextTrack(); const nextTrack = player.getNextTrack();
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer); ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer);
} }
}); });
@ -351,7 +363,8 @@ document.addEventListener('DOMContentLoaded', async () => {
btn.disabled = true; btn.disabled = true;
const originalHTML = btn.innerHTML; const originalHTML = btn.innerHTML;
btn.innerHTML = '<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>'; btn.innerHTML =
'<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>';
try { try {
const { mix, tracks } = await api.getMix(mixId); const { mix, tracks } = await api.getMix(mixId);
@ -366,292 +379,294 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
if (e.target.closest('#download-playlist-btn')) { if (e.target.closest('#download-playlist-btn')) {
const btn = e.target.closest('#download-playlist-btn'); const btn = e.target.closest('#download-playlist-btn');
if (btn.disabled) return; if (btn.disabled) return;
const playlistId = window.location.hash.split('/')[1]; const playlistId = window.location.hash.split('/')[1];
if (!playlistId) return; if (!playlistId) return;
btn.disabled = true; btn.disabled = true;
const originalHTML = btn.innerHTML; const originalHTML = btn.innerHTML;
btn.innerHTML = '<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>'; btn.innerHTML =
'<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>';
try { try {
let playlist, tracks; let playlist, tracks;
let userPlaylist = await db.getPlaylist(playlistId); let userPlaylist = await db.getPlaylist(playlistId);
if (!userPlaylist) { if (!userPlaylist) {
try {
userPlaylist = await syncManager.getPublicPlaylist(playlistId);
} catch (e) {
// Not a public playlist
}
}
if (userPlaylist) {
playlist = { ...userPlaylist, title: userPlaylist.name || userPlaylist.title };
tracks = userPlaylist.tracks || [];
} else {
const data = await api.getPlaylist(playlistId);
playlist = data.playlist;
tracks = data.tracks;
}
await downloadPlaylistAsZip(playlist, tracks, api, downloadQualitySettings.getQuality(), lyricsManager);
} catch (error) {
console.error('Playlist download failed:', error);
alert('Failed to download playlist: ' + error.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
if (e.target.closest('#create-playlist-btn')) {
const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Create Playlist';
document.getElementById('playlist-name-input').value = '';
document.getElementById('playlist-cover-input').value = '';
modal.dataset.editingId = '';
document.getElementById('csv-import-section').style.display = 'block';
document.getElementById('csv-file-input').value = '';
// Reset Public Toggle
const publicToggle = document.getElementById('playlist-public-toggle');
const shareBtn = document.getElementById('playlist-share-btn');
if (publicToggle) publicToggle.checked = false;
if (shareBtn) shareBtn.style.display = 'none';
modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
}
if (e.target.closest('#playlist-modal-save')) {
const name = document.getElementById('playlist-name-input').value.trim();
const isPublic = document.getElementById('playlist-public-toggle')?.checked;
if (name) {
const modal = document.getElementById('playlist-modal');
const editingId = modal.dataset.editingId;
const handlePublicStatus = async (playlist) => {
playlist.isPublic = isPublic;
if (isPublic) {
try { try {
await syncManager.publishPlaylist(playlist); userPlaylist = await syncManager.getPublicPlaylist(playlistId);
} catch (e) { } catch (e) {
console.error('Failed to publish playlist:', e); // Not a public playlist
alert('Failed to publish playlist. Please ensure you are logged in.');
} }
}
if (userPlaylist) {
playlist = { ...userPlaylist, title: userPlaylist.name || userPlaylist.title };
tracks = userPlaylist.tracks || [];
} else { } else {
try { const data = await api.getPlaylist(playlistId);
await syncManager.unpublishPlaylist(playlist.id); playlist = data.playlist;
} catch (e) { tracks = data.tracks;
// Ignore error if it wasn't public
}
} }
return playlist;
};
if (editingId) { await downloadPlaylistAsZip(playlist, tracks, api, downloadQualitySettings.getQuality(), lyricsManager);
// Edit } catch (error) {
const cover = document.getElementById('playlist-cover-input').value.trim(); console.error('Playlist download failed:', error);
db.getPlaylist(editingId).then(async (playlist) => { alert('Failed to download playlist: ' + error.message);
if (playlist) { } finally {
playlist.name = name; btn.disabled = false;
playlist.cover = cover; btn.innerHTML = originalHTML;
await handlePublicStatus(playlist); }
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); }
syncManager.syncUserPlaylist(playlist, 'update');
ui.renderLibraryPage(); if (e.target.closest('#create-playlist-btn')) {
// Also update current page if we are on it const modal = document.getElementById('playlist-modal');
if (window.location.hash === `#userplaylist/${editingId}`) { document.getElementById('playlist-modal-title').textContent = 'Create Playlist';
ui.renderPlaylistPage(editingId, 'user'); document.getElementById('playlist-name-input').value = '';
document.getElementById('playlist-cover-input').value = '';
modal.dataset.editingId = '';
document.getElementById('csv-import-section').style.display = 'block';
document.getElementById('csv-file-input').value = '';
// Reset Public Toggle
const publicToggle = document.getElementById('playlist-public-toggle');
const shareBtn = document.getElementById('playlist-share-btn');
if (publicToggle) publicToggle.checked = false;
if (shareBtn) shareBtn.style.display = 'none';
modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
}
if (e.target.closest('#playlist-modal-save')) {
const name = document.getElementById('playlist-name-input').value.trim();
const isPublic = document.getElementById('playlist-public-toggle')?.checked;
if (name) {
const modal = document.getElementById('playlist-modal');
const editingId = modal.dataset.editingId;
const handlePublicStatus = async (playlist) => {
playlist.isPublic = isPublic;
if (isPublic) {
try {
await syncManager.publishPlaylist(playlist);
} catch (e) {
console.error('Failed to publish playlist:', e);
alert('Failed to publish playlist. Please ensure you are logged in.');
}
} else {
try {
await syncManager.unpublishPlaylist(playlist.id);
} catch (e) {
// Ignore error if it wasn't public
} }
modal.classList.remove('active');
delete modal.dataset.editingId;
} }
}); return playlist;
} else { };
// Create
const csvFileInput = document.getElementById('csv-file-input');
let tracks = [];
if (csvFileInput.files.length > 0) { if (editingId) {
// Import from CSV // Edit
const file = csvFileInput.files[0]; const cover = document.getElementById('playlist-cover-input').value.trim();
const progressElement = document.getElementById('csv-import-progress'); db.getPlaylist(editingId).then(async (playlist) => {
const progressFill = document.getElementById('csv-progress-fill'); if (playlist) {
const progressCurrent = document.getElementById('csv-progress-current'); playlist.name = name;
const progressTotal = document.getElementById('csv-progress-total'); playlist.cover = cover;
const currentTrackElement = progressElement.querySelector('.current-track'); await handlePublicStatus(playlist);
const currentArtistElement = progressElement.querySelector('.current-artist'); await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
syncManager.syncUserPlaylist(playlist, 'update');
ui.renderLibraryPage();
// Also update current page if we are on it
if (window.location.hash === `#userplaylist/${editingId}`) {
ui.renderPlaylistPage(editingId, 'user');
}
modal.classList.remove('active');
delete modal.dataset.editingId;
}
});
} else {
// Create
const csvFileInput = document.getElementById('csv-file-input');
let tracks = [];
try { if (csvFileInput.files.length > 0) {
// Show progress bar // Import from CSV
progressElement.style.display = 'block'; const file = csvFileInput.files[0];
progressFill.style.width = '0%'; const progressElement = document.getElementById('csv-import-progress');
progressCurrent.textContent = '0'; const progressFill = document.getElementById('csv-progress-fill');
currentTrackElement.textContent = 'Reading CSV file...'; const progressCurrent = document.getElementById('csv-progress-current');
if (currentArtistElement) currentArtistElement.textContent = ''; const progressTotal = document.getElementById('csv-progress-total');
const currentTrackElement = progressElement.querySelector('.current-track');
const currentArtistElement = progressElement.querySelector('.current-artist');
const csvText = await file.text(); try {
const lines = csvText.trim().split('\n'); // Show progress bar
const totalTracks = Math.max(0, lines.length - 1); progressElement.style.display = 'block';
progressTotal.textContent = totalTracks.toString(); progressFill.style.width = '0%';
progressCurrent.textContent = '0';
currentTrackElement.textContent = 'Reading CSV file...';
if (currentArtistElement) currentArtistElement.textContent = '';
const result = await parseCSV(csvText, api, (progress) => { const csvText = await file.text();
const percentage = totalTracks > 0 ? (progress.current / totalTracks) * 100 : 0; const lines = csvText.trim().split('\n');
progressFill.style.width = `${Math.min(percentage, 100)}%`; const totalTracks = Math.max(0, lines.length - 1);
progressCurrent.textContent = progress.current.toString(); progressTotal.textContent = totalTracks.toString();
currentTrackElement.textContent = progress.currentTrack;
if (currentArtistElement) currentArtistElement.textContent = progress.currentArtist || '';
});
tracks = result.tracks; const result = await parseCSV(csvText, api, (progress) => {
const missingTracks = result.missingTracks; const percentage = totalTracks > 0 ? (progress.current / totalTracks) * 100 : 0;
progressFill.style.width = `${Math.min(percentage, 100)}%`;
progressCurrent.textContent = progress.current.toString();
currentTrackElement.textContent = progress.currentTrack;
if (currentArtistElement)
currentArtistElement.textContent = progress.currentArtist || '';
});
if (tracks.length === 0) { tracks = result.tracks;
alert('No valid tracks found in the CSV file! Please check the format.'); const missingTracks = result.missingTracks;
if (tracks.length === 0) {
alert('No valid tracks found in the CSV file! Please check the format.');
progressElement.style.display = 'none';
return;
}
console.log(`Imported ${tracks.length} tracks from CSV`);
// if theres missing songs, warn the user
if (missingTracks.length > 0) {
setTimeout(() => {
showMissingTracksNotification(missingTracks);
}, 500);
}
} catch (error) {
console.error('Failed to parse CSV!', error);
alert('Failed to parse CSV file! ' + error.message);
progressElement.style.display = 'none'; progressElement.style.display = 'none';
return; return;
} } finally {
console.log(`Imported ${tracks.length} tracks from CSV`); // Hide progress bar
// if theres missing songs, warn the user
if (missingTracks.length > 0) {
setTimeout(() => { setTimeout(() => {
showMissingTracksNotification(missingTracks); progressElement.style.display = 'none';
}, 500); }, 1000);
} }
} catch (error) {
console.error('Failed to parse CSV!', error);
alert('Failed to parse CSV file! ' + error.message);
progressElement.style.display = 'none';
return;
} finally {
// Hide progress bar
setTimeout(() => {
progressElement.style.display = 'none';
}, 1000);
} }
}
const cover = document.getElementById('playlist-cover-input').value.trim(); const cover = document.getElementById('playlist-cover-input').value.trim();
db.createPlaylist(name, tracks, cover).then(async playlist => { db.createPlaylist(name, tracks, cover).then(async (playlist) => {
await handlePublicStatus(playlist); await handlePublicStatus(playlist);
// Update DB again with isPublic flag // Update DB again with isPublic flag
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
syncManager.syncUserPlaylist(playlist, 'create'); syncManager.syncUserPlaylist(playlist, 'create');
ui.renderLibraryPage();
modal.classList.remove('active');
});
}
}
}
if (e.target.closest('#playlist-modal-cancel')) {
document.getElementById('playlist-modal').classList.remove('active');
}
if (e.target.closest('.edit-playlist-btn')) {
const card = e.target.closest('.user-playlist');
const playlistId = card.dataset.userPlaylistId;
db.getPlaylist(playlistId).then(async (playlist) => {
if (playlist) {
const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
document.getElementById('playlist-name-input').value = playlist.name;
document.getElementById('playlist-cover-input').value = playlist.cover || '';
// Set Public Toggle
const publicToggle = document.getElementById('playlist-public-toggle');
const shareBtn = document.getElementById('playlist-share-btn');
// Check if actually public in Firebase to be sure (async) or trust local flag
// We trust local flag for UI speed, but could verify.
if (publicToggle) publicToggle.checked = !!playlist.isPublic;
if (shareBtn) {
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
shareBtn.onclick = () => {
const url = `${window.location.origin}${window.location.pathname}#userplaylist/${playlist.id}`;
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!'));
};
}
modal.dataset.editingId = playlistId;
document.getElementById('csv-import-section').style.display = 'none';
modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
}
});
}
if (e.target.closest('.delete-playlist-btn')) {
const card = e.target.closest('.user-playlist');
const playlistId = card.dataset.userPlaylistId;
if (confirm('Are you sure you want to delete this playlist?')) {
db.deletePlaylist(playlistId).then(() => {
syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
ui.renderLibraryPage(); ui.renderLibraryPage();
modal.classList.remove('active');
}); });
} }
} }
}
if (e.target.closest('#playlist-modal-cancel')) { if (e.target.closest('#edit-playlist-btn')) {
document.getElementById('playlist-modal').classList.remove('active'); const playlistId = window.location.hash.split('/')[1];
} db.getPlaylist(playlistId).then((playlist) => {
if (playlist) {
const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
document.getElementById('playlist-name-input').value = playlist.name;
document.getElementById('playlist-cover-input').value = playlist.cover || '';
if (e.target.closest('.edit-playlist-btn')) { const publicToggle = document.getElementById('playlist-public-toggle');
const card = e.target.closest('.user-playlist'); const shareBtn = document.getElementById('playlist-share-btn');
const playlistId = card.dataset.userPlaylistId;
db.getPlaylist(playlistId).then(async playlist => {
if (playlist) {
const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
document.getElementById('playlist-name-input').value = playlist.name;
document.getElementById('playlist-cover-input').value = playlist.cover || '';
// Set Public Toggle if (publicToggle) publicToggle.checked = !!playlist.isPublic;
const publicToggle = document.getElementById('playlist-public-toggle'); if (shareBtn) {
const shareBtn = document.getElementById('playlist-share-btn'); shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
shareBtn.onclick = () => {
const url = `${window.location.origin}${window.location.pathname}#userplaylist/${playlist.id}`;
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!'));
};
}
// Check if actually public in Firebase to be sure (async) or trust local flag modal.dataset.editingId = playlistId;
// We trust local flag for UI speed, but could verify. document.getElementById('csv-import-section').style.display = 'none';
if (publicToggle) publicToggle.checked = !!playlist.isPublic; modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
if (shareBtn) {
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
shareBtn.onclick = () => {
const url = `${window.location.origin}${window.location.pathname}#userplaylist/${playlist.id}`;
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!'));
};
} }
modal.dataset.editingId = playlistId;
document.getElementById('csv-import-section').style.display = 'none';
modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
}
});
}
if (e.target.closest('.delete-playlist-btn')) {
const card = e.target.closest('.user-playlist');
const playlistId = card.dataset.userPlaylistId;
if (confirm('Are you sure you want to delete this playlist?')) {
db.deletePlaylist(playlistId).then(() => {
syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
ui.renderLibraryPage();
}); });
} }
}
if (e.target.closest('#edit-playlist-btn')) { if (e.target.closest('#delete-playlist-btn')) {
const playlistId = window.location.hash.split('/')[1]; const playlistId = window.location.hash.split('/')[1];
db.getPlaylist(playlistId).then(playlist => { if (confirm('Are you sure you want to delete this playlist?')) {
if (playlist) { db.deletePlaylist(playlistId).then(() => {
const modal = document.getElementById('playlist-modal'); syncManager.syncUserPlaylist({ id: playlistId }, 'delete');
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist'; window.location.hash = '#library';
document.getElementById('playlist-name-input').value = playlist.name; });
document.getElementById('playlist-cover-input').value = playlist.cover || '';
const publicToggle = document.getElementById('playlist-public-toggle');
const shareBtn = document.getElementById('playlist-share-btn');
if (publicToggle) publicToggle.checked = !!playlist.isPublic;
if (shareBtn) {
shareBtn.style.display = playlist.isPublic ? 'flex' : 'none';
shareBtn.onclick = () => {
const url = `${window.location.origin}${window.location.pathname}#userplaylist/${playlist.id}`;
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!'));
};
}
modal.dataset.editingId = playlistId;
document.getElementById('csv-import-section').style.display = 'none';
modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
} }
}); }
}
if (e.target.closest('#delete-playlist-btn')) { if (e.target.closest('.remove-from-playlist-btn')) {
const playlistId = window.location.hash.split('/')[1]; e.stopPropagation();
if (confirm('Are you sure you want to delete this playlist?')) { const btn = e.target.closest('.remove-from-playlist-btn');
db.deletePlaylist(playlistId).then(() => { const index = parseInt(btn.dataset.trackIndex);
syncManager.syncUserPlaylist({ id: playlistId }, 'delete'); const playlistId = window.location.hash.split('/')[1];
window.location.hash = '#library'; db.getPlaylist(playlistId).then(async (playlist) => {
if (playlist && playlist.tracks[index]) {
const trackId = playlist.tracks[index].id;
const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
ui.renderPlaylistPage(playlistId, 'user');
}
}); });
} }
}
if (e.target.closest('.remove-from-playlist-btn')) {
e.stopPropagation();
const btn = e.target.closest('.remove-from-playlist-btn');
const index = parseInt(btn.dataset.trackIndex);
const playlistId = window.location.hash.split('/')[1];
db.getPlaylist(playlistId).then(async (playlist) => {
if (playlist && playlist.tracks[index]) {
const trackId = playlist.tracks[index].id;
const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
ui.renderPlaylistPage(playlistId, 'user');
}
});
}
if (e.target.closest('#play-playlist-btn')) { if (e.target.closest('#play-playlist-btn')) {
const btn = e.target.closest('#play-playlist-btn'); const btn = e.target.closest('#play-playlist-btn');
@ -673,7 +688,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} catch (e) { } catch (e) {
const publicPlaylist = await syncManager.getPublicPlaylist(playlistId); const publicPlaylist = await syncManager.getPublicPlaylist(playlistId);
if (publicPlaylist) { if (publicPlaylist) {
tracks = publicPlaylist.tracks; tracks = publicPlaylist.tracks;
} else { } else {
throw e; throw e;
} }
@ -699,7 +714,8 @@ document.addEventListener('DOMContentLoaded', async () => {
btn.disabled = true; btn.disabled = true;
const originalHTML = btn.innerHTML; const originalHTML = btn.innerHTML;
btn.innerHTML = '<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>'; btn.innerHTML =
'<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>';
try { try {
const { album, tracks } = await api.getAlbum(albumId); const { album, tracks } = await api.getAlbum(albumId);
@ -722,14 +738,15 @@ document.addEventListener('DOMContentLoaded', async () => {
btn.disabled = true; btn.disabled = true;
const originalHTML = btn.innerHTML; const originalHTML = btn.innerHTML;
btn.innerHTML = '<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Loading...</span>'; btn.innerHTML =
'<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Loading...</span>';
try { try {
const artist = await api.getArtist(artistId); const artist = await api.getArtist(artistId);
const allReleases = [...(artist.albums || []), ...(artist.eps || [])]; const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
if (allReleases.length === 0) { if (allReleases.length === 0) {
throw new Error("No albums or EPs found for this artist"); throw new Error('No albums or EPs found for this artist');
} }
const trackSet = new Set(); const trackSet = new Set();
@ -744,19 +761,21 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
for (const chunk of chunks) { for (const chunk of chunks) {
await Promise.all(chunk.map(async (album) => { await Promise.all(
try { chunk.map(async (album) => {
const { tracks } = await api.getAlbum(album.id); try {
tracks.forEach(track => { const { tracks } = await api.getAlbum(album.id);
if (!trackSet.has(track.id)) { tracks.forEach((track) => {
trackSet.add(track.id); if (!trackSet.has(track.id)) {
allTracks.push(track); trackSet.add(track.id);
} allTracks.push(track);
}); }
} catch (err) { });
console.warn(`Failed to fetch tracks for album ${album.title}:`, err); } catch (err) {
} console.warn(`Failed to fetch tracks for album ${album.title}:`, err);
})); }
})
);
} }
if (allTracks.length > 0) { if (allTracks.length > 0) {
@ -768,9 +787,8 @@ document.addEventListener('DOMContentLoaded', async () => {
player.setQueue(allTracks, 0); player.setQueue(allTracks, 0);
player.playTrackFromQueue(); player.playTrackFromQueue();
} else { } else {
throw new Error("No tracks found across all albums"); throw new Error('No tracks found across all albums');
} }
} catch (error) { } catch (error) {
console.error('Artist radio failed:', error); console.error('Artist radio failed:', error);
alert('Failed to start artist radio: ' + error.message); alert('Failed to start artist radio: ' + error.message);
@ -815,7 +833,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
}); });
searchForm.addEventListener('submit', e => { searchForm.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
const query = searchInput.value.trim(); const query = searchInput.value.trim();
if (query) { if (query) {
@ -976,14 +994,19 @@ function showMissingTracksNotification(missingTracks) {
const modal = document.getElementById('missing-tracks-modal'); const modal = document.getElementById('missing-tracks-modal');
const listUl = document.getElementById('missing-tracks-list-ul'); const listUl = document.getElementById('missing-tracks-list-ul');
listUl.innerHTML = missingTracks.map(track => `<li>${track}</li>`).join(''); listUl.innerHTML = missingTracks.map((track) => `<li>${track}</li>`).join('');
const closeModal = () => modal.classList.remove('active'); const closeModal = () => modal.classList.remove('active');
// Remove old listeners if any (though usually these functions are called once per instance, // Remove old listeners if any (though usually these functions are called once per instance,
// but since we reuse the same modal element we should be careful or use a one-time listener) // but since we reuse the same modal element we should be careful or use a one-time listener)
const handleClose = (e) => { const handleClose = (e) => {
if (e.target === modal || e.target.closest('.close-missing-tracks') || e.target.id === 'close-missing-tracks-btn' || e.target.classList.contains('modal-overlay')) { if (
e.target === modal ||
e.target.closest('.close-missing-tracks') ||
e.target.id === 'close-missing-tracks-btn' ||
e.target.classList.contains('modal-overlay')
) {
closeModal(); closeModal();
modal.removeEventListener('click', handleClose); modal.removeEventListener('click', handleClose);
} }
@ -1018,7 +1041,7 @@ async function parseCSV(csvText, api, onProgress) {
values.push(current); values.push(current);
// Clean up quotes: remove surrounding quotes and unescape double quotes if any // Clean up quotes: remove surrounding quotes and unescape double quotes if any
return values.map(v => v.trim().replace(/^"|"$/g, '').replace(/""/g, '"').trim()); return values.map((v) => v.trim().replace(/^"|"$/g, '').replace(/""/g, '"').trim());
}; };
const headers = parseLine(lines[0]); const headers = parseLine(lines[0]);
@ -1071,14 +1094,14 @@ async function parseCSV(csvText, api, onProgress) {
current: i, current: i,
total: totalTracks, total: totalTracks,
currentTrack: trackTitle || 'Unknown track', currentTrack: trackTitle || 'Unknown track',
currentArtist: artistNames || '' currentArtist: artistNames || '',
}); });
} }
// Search for the track in hifi tidal api's catalog // Search for the track in hifi tidal api's catalog
if (trackTitle && (artistNames || isrc)) { if (trackTitle && (artistNames || isrc)) {
// Add a small delay to prevent rate limiting // Add a small delay to prevent rate limiting
await new Promise(resolve => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
try { try {
let foundTrack = null; let foundTrack = null;
@ -1148,14 +1171,22 @@ async function parseCSV(csvText, api, onProgress) {
if (foundTrack) { if (foundTrack) {
tracks.push(foundTrack); tracks.push(foundTrack);
console.log(`Found track: "${trackTitle}" by ${artistNames}${albumName ? ' (album: ' + albumName + ')' : ''}`); console.log(
`Found track: "${trackTitle}" by ${artistNames}${albumName ? ' (album: ' + albumName + ')' : ''}`
);
} else { } else {
console.warn(`Track not found: "${trackTitle}" by ${artistNames} ${albumName ? '(album: ' + albumName + ')' : ''}`); console.warn(
missingTracks.push(`${trackTitle} - ${artistNames}${albumName ? ' (album: ' + albumName + ')' : ''}`); `Track not found: "${trackTitle}" by ${artistNames} ${albumName ? '(album: ' + albumName + ')' : ''}`
);
missingTracks.push(
`${trackTitle} - ${artistNames}${albumName ? ' (album: ' + albumName + ')' : ''}`
);
} }
} catch (error) { } catch (error) {
console.error(`Error searching for track "${trackTitle}":`, error); console.error(`Error searching for track "${trackTitle}":`, error);
missingTracks.push(`${trackTitle} - ${artistNames}${albumName ? ' (album: ' + albumName + ')' : ''}`); missingTracks.push(
`${trackTitle} - ${artistNames}${albumName ? ' (album: ' + albumName + ')' : ''}`
);
} }
} }
} }
@ -1166,7 +1197,7 @@ async function parseCSV(csvText, api, onProgress) {
onProgress({ onProgress({
current: totalTracks, current: totalTracks,
total: totalTracks, total: totalTracks,
currentTrack: 'Import complete' currentTrack: 'Import complete',
}); });
} }
@ -1178,8 +1209,8 @@ function showDiscographyDownloadModal(artist, api, quality, lyricsManager, trigg
document.getElementById('discography-artist-name').textContent = artist.name; document.getElementById('discography-artist-name').textContent = artist.name;
document.getElementById('albums-count').textContent = artist.albums?.length || 0; document.getElementById('albums-count').textContent = artist.albums?.length || 0;
document.getElementById('eps-count').textContent = (artist.eps || []).filter(a => a.type === 'EP').length; document.getElementById('eps-count').textContent = (artist.eps || []).filter((a) => a.type === 'EP').length;
document.getElementById('singles-count').textContent = (artist.eps || []).filter(a => a.type === 'SINGLE').length; document.getElementById('singles-count').textContent = (artist.eps || []).filter((a) => a.type === 'SINGLE').length;
// Reset checkboxes // Reset checkboxes
document.getElementById('download-albums').checked = true; document.getElementById('download-albums').checked = true;
@ -1191,7 +1222,12 @@ function showDiscographyDownloadModal(artist, api, quality, lyricsManager, trigg
}; };
const handleClose = (e) => { const handleClose = (e) => {
if (e.target === modal || e.target.classList.contains('modal-overlay') || e.target.closest('.close-modal-btn') || e.target.id === 'cancel-discography-download') { if (
e.target === modal ||
e.target.classList.contains('modal-overlay') ||
e.target.closest('.close-modal-btn') ||
e.target.id === 'cancel-discography-download'
) {
closeModal(); closeModal();
} }
}; };
@ -1216,15 +1252,16 @@ function showDiscographyDownloadModal(artist, api, quality, lyricsManager, trigg
selectedReleases = selectedReleases.concat(artist.albums || []); selectedReleases = selectedReleases.concat(artist.albums || []);
} }
if (includeEPs) { if (includeEPs) {
selectedReleases = selectedReleases.concat((artist.eps || []).filter(a => a.type === 'EP')); selectedReleases = selectedReleases.concat((artist.eps || []).filter((a) => a.type === 'EP'));
} }
if (includeSingles) { if (includeSingles) {
selectedReleases = selectedReleases.concat((artist.eps || []).filter(a => a.type === 'SINGLE')); selectedReleases = selectedReleases.concat((artist.eps || []).filter((a) => a.type === 'SINGLE'));
} }
triggerBtn.disabled = true; triggerBtn.disabled = true;
const originalHTML = triggerBtn.innerHTML; const originalHTML = triggerBtn.innerHTML;
triggerBtn.innerHTML = '<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>'; triggerBtn.innerHTML =
'<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>';
try { try {
await downloadDiscography(artist, selectedReleases, api, quality, lyricsManager); await downloadDiscography(artist, selectedReleases, api, quality, lyricsManager);
@ -1241,35 +1278,25 @@ function showDiscographyDownloadModal(artist, api, quality, lyricsManager, trigg
} }
function showKeyboardShortcuts() { function showKeyboardShortcuts() {
const modal = document.getElementById('shortcuts-modal'); const modal = document.getElementById('shortcuts-modal');
const closeModal = () => { const closeModal = () => {
modal.classList.remove('active'); modal.classList.remove('active');
modal.removeEventListener('click', handleClose); modal.removeEventListener('click', handleClose);
}; };
const handleClose = (e) => { const handleClose = (e) => {
if (
if (e.target === modal || e.target.classList.contains('close-shortcuts') || e.target.classList.contains('modal-overlay')) { e.target === modal ||
e.target.classList.contains('close-shortcuts') ||
e.target.classList.contains('modal-overlay')
) {
closeModal(); closeModal();
} }
}; };
modal.addEventListener('click', handleClose); modal.addEventListener('click', handleClose);
modal.classList.add('active'); modal.classList.add('active');
} }

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);
}; };
@ -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}%)`;
} }
@ -223,27 +228,27 @@ async function generateAndDownloadZip(zip, filename, notification, progressTotal
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);
@ -409,22 +427,27 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
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 releaseDateStr =
fullAlbum.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 albumFolder = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', { const albumFolder = formatTemplate(
albumTitle: fullAlbum.title, localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}',
albumArtist: fullAlbum.artist?.name, {
year: year 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,9 +601,8 @@ 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';
@ -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
@ -196,7 +206,8 @@ 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'
@ -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,23 +585,24 @@ 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);
@ -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');
@ -792,7 +832,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
} }
}); });
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 {
@ -18,10 +24,10 @@ export class AuthManager {
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;
} }
} }
@ -105,7 +111,6 @@ export class AuthManager {
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');

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.');
@ -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 {
@ -16,19 +26,19 @@ export class SyncManager {
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() {
@ -36,7 +46,7 @@ export class SyncManager {
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);
@ -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;
} }
@ -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
@ -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

View file

@ -68,11 +68,13 @@ async function addFlacMetadata(flacBlob, track, api) {
function isFlacFile(dataView) { function isFlacFile(dataView) {
// Check for "fLaC" signature at the beginning // Check for "fLaC" signature at the beginning
return dataView.byteLength >= 4 && return (
dataView.getUint8(0) === 0x66 && // 'f' dataView.byteLength >= 4 &&
dataView.getUint8(1) === 0x4C && // 'L' dataView.getUint8(0) === 0x66 && // 'f'
dataView.getUint8(2) === 0x61 && // 'a' dataView.getUint8(1) === 0x4c && // 'L'
dataView.getUint8(3) === 0x43; // 'C' dataView.getUint8(2) === 0x61 && // 'a'
dataView.getUint8(3) === 0x43
); // 'C'
} }
function parseFlacBlocks(dataView) { function parseFlacBlocks(dataView) {
@ -82,11 +84,12 @@ function parseFlacBlocks(dataView) {
while (offset + 4 <= dataView.byteLength) { while (offset + 4 <= dataView.byteLength) {
const header = dataView.getUint8(offset); const header = dataView.getUint8(offset);
const isLast = (header & 0x80) !== 0; const isLast = (header & 0x80) !== 0;
const blockType = header & 0x7F; const blockType = header & 0x7f;
const blockSize = (dataView.getUint8(offset + 1) << 16) | const blockSize =
(dataView.getUint8(offset + 2) << 8) | (dataView.getUint8(offset + 1) << 16) |
dataView.getUint8(offset + 3); (dataView.getUint8(offset + 2) << 8) |
dataView.getUint8(offset + 3);
// Validate block size // Validate block size
if (offset + 4 + blockSize > dataView.byteLength) { if (offset + 4 + blockSize > dataView.byteLength) {
@ -99,7 +102,7 @@ function parseFlacBlocks(dataView) {
isLast: isLast, isLast: isLast,
size: blockSize, size: blockSize,
offset: offset + 4, offset: offset + 4,
headerOffset: offset headerOffset: offset,
}); });
offset += 4 + blockSize; offset += 4 + blockSize;
@ -138,7 +141,8 @@ function createVorbisCommentBlock(track) {
comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]); comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]);
} }
const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : ''); const releaseDateStr =
track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : '');
if (releaseDateStr) { if (releaseDateStr) {
try { try {
const year = new Date(releaseDateStr).getFullYear(); const year = new Date(releaseDateStr).getFullYear();
@ -217,14 +221,18 @@ async function createFlacPictureBlock(coverId, api) {
const descBytes = new TextEncoder().encode(description); const descBytes = new TextEncoder().encode(description);
// Calculate total size // Calculate total size
const totalSize = 4 + // picture type const totalSize =
4 + mimeBytes.length + // mime length + mime 4 + // picture type
4 + descBytes.length + // desc length + desc 4 +
4 + // width mimeBytes.length + // mime length + mime
4 + // height 4 +
4 + // color depth descBytes.length + // desc length + desc
4 + // indexed colors 4 + // width
4 + imageBytes.length; // image length + image 4 + // height
4 + // color depth
4 + // indexed colors
4 +
imageBytes.length; // image length + image
const buffer = new ArrayBuffer(totalSize); const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer); const view = new DataView(buffer);
@ -288,7 +296,7 @@ function rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBl
const originalArray = new Uint8Array(dataView.buffer); const originalArray = new Uint8Array(dataView.buffer);
// Remove old Vorbis comment and picture blocks // Remove old Vorbis comment and picture blocks
const filteredBlocks = blocks.filter(b => b.type !== 4 && b.type !== 6); // 4 = Vorbis, 6 = Picture const filteredBlocks = blocks.filter((b) => b.type !== 4 && b.type !== 6); // 4 = Vorbis, 6 = Picture
// Calculate new file size // Calculate new file size
let newSize = 4; // "fLaC" signature let newSize = 4; // "fLaC" signature
@ -320,7 +328,7 @@ function rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBl
// Write "fLaC" signature // Write "fLaC" signature
newFile[offset++] = 0x66; // 'f' newFile[offset++] = 0x66; // 'f'
newFile[offset++] = 0x4C; // 'L' newFile[offset++] = 0x4c; // 'L'
newFile[offset++] = 0x61; // 'a' newFile[offset++] = 0x61; // 'a'
newFile[offset++] = 0x43; // 'C' newFile[offset++] = 0x43; // 'C'
@ -332,9 +340,9 @@ function rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBl
// Write block header // Write block header
const header = (isLast ? 0x80 : 0x00) | block.type; const header = (isLast ? 0x80 : 0x00) | block.type;
newFile[offset++] = header; newFile[offset++] = header;
newFile[offset++] = (block.size >> 16) & 0xFF; newFile[offset++] = (block.size >> 16) & 0xff;
newFile[offset++] = (block.size >> 8) & 0xFF; newFile[offset++] = (block.size >> 8) & 0xff;
newFile[offset++] = block.size & 0xFF; newFile[offset++] = block.size & 0xff;
// Write block data // Write block data
newFile.set(originalArray.subarray(block.offset, block.offset + block.size), offset); newFile.set(originalArray.subarray(block.offset, block.offset + block.size), offset);
@ -345,9 +353,9 @@ function rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBl
const vorbisHeaderOffset = offset; const vorbisHeaderOffset = offset;
const vorbisHeader = 0x04; // Vorbis comment type const vorbisHeader = 0x04; // Vorbis comment type
newFile[offset++] = vorbisHeader; newFile[offset++] = vorbisHeader;
newFile[offset++] = (vorbisCommentBlock.length >> 16) & 0xFF; newFile[offset++] = (vorbisCommentBlock.length >> 16) & 0xff;
newFile[offset++] = (vorbisCommentBlock.length >> 8) & 0xFF; newFile[offset++] = (vorbisCommentBlock.length >> 8) & 0xff;
newFile[offset++] = vorbisCommentBlock.length & 0xFF; newFile[offset++] = vorbisCommentBlock.length & 0xff;
newFile.set(vorbisCommentBlock, offset); newFile.set(vorbisCommentBlock, offset);
offset += vorbisCommentBlock.length; offset += vorbisCommentBlock.length;
@ -358,9 +366,9 @@ function rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBl
const pictureHeaderOffset = offset; const pictureHeaderOffset = offset;
const pictureHeader = 0x06; // Picture type const pictureHeader = 0x06; // Picture type
newFile[offset++] = pictureHeader; newFile[offset++] = pictureHeader;
newFile[offset++] = (pictureBlock.length >> 16) & 0xFF; newFile[offset++] = (pictureBlock.length >> 16) & 0xff;
newFile[offset++] = (pictureBlock.length >> 8) & 0xFF; newFile[offset++] = (pictureBlock.length >> 8) & 0xff;
newFile[offset++] = pictureBlock.length & 0xFF; newFile[offset++] = pictureBlock.length & 0xff;
newFile.set(pictureBlock, offset); newFile.set(pictureBlock, offset);
offset += pictureBlock.length; offset += pictureBlock.length;
lastBlockHeaderOffset = pictureHeaderOffset; lastBlockHeaderOffset = pictureHeaderOffset;
@ -399,7 +407,7 @@ async function addM4aMetadata(m4aBlob, track, api) {
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer()); const imageBytes = new Uint8Array(await imageBlob.arrayBuffer());
metadataAtoms.cover = { metadataAtoms.cover = {
type: 'covr', type: 'covr',
data: imageBytes data: imageBytes,
}; };
} }
} catch (error) { } catch (error) {
@ -458,7 +466,7 @@ function parseMp4Atoms(dataView) {
atoms.push({ atoms.push({
type: type, type: type,
offset: offset, offset: offset,
size: size size: size,
}); });
offset += size; offset += size;
@ -475,14 +483,15 @@ function createMp4MetadataAtoms(track) {
'©nam': track.title || DEFAULT_TITLE, '©nam': track.title || DEFAULT_TITLE,
'©ART': track.artist?.name || DEFAULT_ARTIST, '©ART': track.artist?.name || DEFAULT_ARTIST,
'©alb': track.album?.title || DEFAULT_ALBUM, '©alb': track.album?.title || DEFAULT_ALBUM,
'aART': track.album?.artist?.name || DEFAULT_ARTIST, aART: track.album?.artist?.name || DEFAULT_ARTIST,
}; };
if (track.trackNumber) { if (track.trackNumber) {
tags['trkn'] = track.trackNumber; tags['trkn'] = track.trackNumber;
} }
const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : ''); const releaseDateStr =
track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : '');
if (releaseDateStr) { if (releaseDateStr) {
try { try {
const year = new Date(releaseDateStr).getFullYear(); const year = new Date(releaseDateStr).getFullYear();
@ -501,7 +510,7 @@ function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) {
const originalArray = new Uint8Array(dataView.buffer); const originalArray = new Uint8Array(dataView.buffer);
// Find moov atom // Find moov atom
const moovAtom = atoms.find(a => a.type === 'moov'); const moovAtom = atoms.find((a) => a.type === 'moov');
if (!moovAtom) { if (!moovAtom) {
console.warn('No moov atom found in M4A file'); console.warn('No moov atom found in M4A file');
return originalArray; return originalArray;
@ -524,7 +533,7 @@ function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) {
const moovChildren = parseMp4Atoms(new DataView(originalArray.buffer, moovAtom.offset + 8, moovAtom.size - 8)); const moovChildren = parseMp4Atoms(new DataView(originalArray.buffer, moovAtom.offset + 8, moovAtom.size - 8));
// Filter out existing udta to replace it // Filter out existing udta to replace it
const filteredMoovChildren = moovChildren.filter(a => a.type !== 'udta'); const filteredMoovChildren = moovChildren.filter((a) => a.type !== 'udta');
// Calculate new moov size // Calculate new moov size
// Header (8) + Sum of other children sizes + New Metadata Block Size // Header (8) + Sum of other children sizes + New Metadata Block Size
@ -542,7 +551,7 @@ function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) {
let originalOffset = 0; let originalOffset = 0;
// Copy atoms before moov // Copy atoms before moov
const atomsBeforeMoov = atoms.filter(a => a.offset < moovAtom.offset); const atomsBeforeMoov = atoms.filter((a) => a.offset < moovAtom.offset);
for (const atom of atomsBeforeMoov) { for (const atom of atomsBeforeMoov) {
newFile.set(originalArray.subarray(atom.offset, atom.offset + atom.size), offset); newFile.set(originalArray.subarray(atom.offset, atom.offset + atom.size), offset);
offset += atom.size; offset += atom.size;
@ -551,15 +560,15 @@ function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) {
// Write new moov atom // Write new moov atom
// Size // Size
newFile[offset++] = (newMoovSize >> 24) & 0xFF; newFile[offset++] = (newMoovSize >> 24) & 0xff;
newFile[offset++] = (newMoovSize >> 16) & 0xFF; newFile[offset++] = (newMoovSize >> 16) & 0xff;
newFile[offset++] = (newMoovSize >> 8) & 0xFF; newFile[offset++] = (newMoovSize >> 8) & 0xff;
newFile[offset++] = newMoovSize & 0xFF; newFile[offset++] = newMoovSize & 0xff;
// Type 'moov' // Type 'moov'
newFile[offset++] = 0x6D; newFile[offset++] = 0x6d;
newFile[offset++] = 0x6F; newFile[offset++] = 0x6f;
newFile[offset++] = 0x6F; newFile[offset++] = 0x6f;
newFile[offset++] = 0x76; newFile[offset++] = 0x76;
// Write preserved children of moov // Write preserved children of moov
@ -592,7 +601,7 @@ function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) {
// If moov is BEFORE mdat, we need to shift offsets. // If moov is BEFORE mdat, we need to shift offsets.
// Most streaming optimized files have moov before mdat. // Most streaming optimized files have moov before mdat.
const mdatAtom = atoms.find(a => a.type === 'mdat'); const mdatAtom = atoms.find((a) => a.type === 'mdat');
const moovBeforeMdat = mdatAtom && moovAtom.offset < mdatAtom.offset; const moovBeforeMdat = mdatAtom && moovAtom.offset < mdatAtom.offset;
if (moovBeforeMdat) { if (moovBeforeMdat) {
@ -664,20 +673,38 @@ function createMetadataBlock(metadataAtoms) {
// hdlr size: 4 (size) + 4 (type) + 4 (ver/flags) + 4 (pre_defined) + 4 (handler_type) + 12 (reserved) + name (string) // hdlr size: 4 (size) + 4 (type) + 4 (ver/flags) + 4 (pre_defined) + 4 (handler_type) + 12 (reserved) + name (string)
// Minimal valid hdlr for iTunes metadata: // Minimal valid hdlr for iTunes metadata:
const hdlrContent = new Uint8Array([ const hdlrContent = new Uint8Array([
0, 0, 0, 0, // Version/Flags 0,
0, 0, 0, 0, // Pre-defined 0,
0x6D, 0x64, 0x69, 0x72, // 'mdir' 0,
0x61, 0x70, 0x70, 0x6C, // 'appl' 0, // Version/Flags
0, 0, 0, 0, // Reserved 0,
0, 0, 0, 0, 0,
0, 0 // Name (empty null-term) check spec? usually simple 0 is enough 0,
0, // Pre-defined
0x6d,
0x64,
0x69,
0x72, // 'mdir'
0x61,
0x70,
0x70,
0x6c, // 'appl'
0,
0,
0,
0, // Reserved
0,
0,
0,
0,
0,
0, // Name (empty null-term) check spec? usually simple 0 is enough
]); ]);
const hdlrSize = 8 + hdlrContent.length; const hdlrSize = 8 + hdlrContent.length;
const hdlr = new Uint8Array(hdlrSize); const hdlr = new Uint8Array(hdlrSize);
writeAtomHeader(hdlr, 0, hdlrSize, 'hdlr'); writeAtomHeader(hdlr, 0, hdlrSize, 'hdlr');
hdlr.set(hdlrContent, 8); hdlr.set(hdlrContent, 8);
// Construct udta atom // Construct udta atom
// udta contains meta. meta usually should contain hdlr before ilst? // udta contains meta. meta usually should contain hdlr before ilst?
// Actually, QuickTime spec says meta contains hdlr then ilst. // Actually, QuickTime spec says meta contains hdlr then ilst.
@ -765,8 +792,8 @@ function createIntAtom(type, value) {
buf[offset++] = 0; buf[offset++] = 0;
// Track num // Track num
const trk = parseInt(value) || 0; const trk = parseInt(value) || 0;
buf[offset++] = (trk >> 8) & 0xFF; buf[offset++] = (trk >> 8) & 0xff;
buf[offset++] = trk & 0xFF; buf[offset++] = trk & 0xff;
// Total (0 for now) // Total (0 for now)
buf[offset++] = 0; buf[offset++] = 0;
buf[offset++] = 0; buf[offset++] = 0;
@ -792,7 +819,8 @@ function createCoverAtom(imageBytes) {
// Data Type (13 = JPEG, 14 = PNG) // Data Type (13 = JPEG, 14 = PNG)
// We try to detect or default to JPEG (13) // We try to detect or default to JPEG (13)
let type = 13; let type = 13;
if (imageBytes[0] === 0x89 && imageBytes[1] === 0x50) { // PNG signature if (imageBytes[0] === 0x89 && imageBytes[1] === 0x50) {
// PNG signature
type = 14; type = 14;
} }
@ -811,10 +839,10 @@ function createCoverAtom(imageBytes) {
} }
function writeAtomHeader(buf, offset, size, type) { function writeAtomHeader(buf, offset, size, type) {
buf[offset++] = (size >> 24) & 0xFF; buf[offset++] = (size >> 24) & 0xff;
buf[offset++] = (size >> 16) & 0xFF; buf[offset++] = (size >> 16) & 0xff;
buf[offset++] = (size >> 8) & 0xFF; buf[offset++] = (size >> 8) & 0xff;
buf[offset++] = size & 0xFF; buf[offset++] = size & 0xff;
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
buf[offset++] = type.charCodeAt(i); buf[offset++] = type.charCodeAt(i);
@ -856,7 +884,7 @@ function findAndShiftOffsets(view, start, end, shift) {
// Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 4) // Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 4)
const count = view.getUint32(offset + 12, false); const count = view.getUint32(offset + 12, false);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const entryOffset = offset + 16 + (i * 4); const entryOffset = offset + 16 + i * 4;
const oldVal = view.getUint32(entryOffset, false); const oldVal = view.getUint32(entryOffset, false);
view.setUint32(entryOffset, oldVal + shift, false); view.setUint32(entryOffset, oldVal + shift, false);
} }
@ -865,7 +893,7 @@ function findAndShiftOffsets(view, start, end, shift) {
// Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 8) // Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 8)
const count = view.getUint32(offset + 12, false); const count = view.getUint32(offset + 12, false);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const entryOffset = offset + 16 + (i * 8); const entryOffset = offset + 16 + i * 8;
// Read 64-bit int // Read 64-bit int
const oldHigh = view.getUint32(entryOffset, false); const oldHigh = view.getUint32(entryOffset, false);
const oldLow = view.getUint32(entryOffset + 4, false); const oldLow = view.getUint32(entryOffset + 4, false);
@ -881,7 +909,7 @@ function findAndShiftOffsets(view, start, end, shift) {
let newLow = oldLow + shift; let newLow = oldLow + shift;
let carry = 0; let carry = 0;
if (newLow > 0xFFFFFFFF) { if (newLow > 0xffffffff) {
carry = Math.floor(newLow / 0x100000000); carry = Math.floor(newLow / 0x100000000);
newLow = newLow >>> 0; newLow = newLow >>> 0;
} }

View file

@ -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,
}); });
} }
@ -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();
@ -428,10 +427,10 @@ export class Player {
// 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();
@ -456,10 +455,10 @@ 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();
@ -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(() => {

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

@ -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) {
return { speeds: {}, timestamp: Date.now() };
} }
},
updateSpeedCache(newResults) { this.defaultInstances = groupedInstances;
const currentCache = this.getCachedSpeedTests(); this.instancesLoaded = true;
newResults.forEach(r => { return groupedInstances;
// Use distinct keys for streaming tests to avoid overwriting API tests for same URL } catch (error) {
// API tests use raw URL as key (for backward compatibility with UI) console.error('Failed to load instances from GitHub:', error);
const key = r.type === 'streaming' ? `${r.url}#streaming` : r.url; this.defaultInstances = {
currentCache.speeds[key] = { speed: r.speed, error: r.error }; 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',
}); });
currentCache.timestamp = Date.now(); clearTimeout(timeout);
try { if (!response.ok) {
localStorage.setItem(this.SPEED_TEST_CACHE_KEY, JSON.stringify(currentCache)); return { url, type, speed: Infinity, error: `HTTP ${response.status}` };
} catch (e) {
console.warn('[SpeedTest] Failed to cache results');
} }
return currentCache; const endTime = performance.now();
}, const speed = endTime - startTime;
async testSpecificUrls(urls, type) { return { url, type, speed, error: null };
if (!urls || urls.length === 0) return []; } catch (error) {
console.log(`[SpeedTest] Testing ${urls.length} instances for ${type}...`); return { url, type, speed: Infinity, error: error.message };
}
},
const results = await Promise.all( getCachedSpeedTests() {
urls.map(url => this.speedTestInstance(url, type)) try {
); const cached = localStorage.getItem(this.SPEED_TEST_CACHE_KEY);
if (!cached) return { speeds: {}, timestamp: Date.now() };
const validResults = results.filter(r => r.speed !== Infinity); const data = JSON.parse(cached);
console.log(`[SpeedTest] ${type} Results:`, validResults.map(r => `${r.url}: ${r.speed.toFixed(0)}ms`));
return results; if (Date.now() - data.timestamp > this.SPEED_TEST_CACHE_DURATION) {
}, return { speeds: {}, timestamp: Date.now() };
}
async getInstances(type = 'api') { return data;
let instancesObj; } catch (e) {
return { speeds: {}, timestamp: Date.now() };
}
},
updateSpeedCache(newResults) {
const currentCache = this.getCachedSpeedTests();
newResults.forEach((r) => {
// Use distinct keys for streaming tests to avoid overwriting API tests for same URL
// API tests use raw URL as key (for backward compatibility with UI)
const key = r.type === 'streaming' ? `${r.url}#streaming` : r.url;
currentCache.speeds[key] = { speed: r.speed, error: r.error };
});
currentCache.timestamp = Date.now();
try {
localStorage.setItem(this.SPEED_TEST_CACHE_KEY, JSON.stringify(currentCache));
} catch (e) {
console.warn('[SpeedTest] Failed to cache results');
}
return currentCache;
},
async testSpecificUrls(urls, type) {
if (!urls || urls.length === 0) return [];
console.log(`[SpeedTest] Testing ${urls.length} instances for ${type}...`);
const results = await Promise.all(urls.map((url) => this.speedTestInstance(url, type)));
const validResults = results.filter((r) => r.speed !== Infinity);
console.log(
`[SpeedTest] ${type} Results:`,
validResults.map((r) => `${r.url}: ${r.speed.toFixed(0)}ms`)
);
return results;
},
async getInstances(type = 'api') {
let instancesObj;
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
instancesObj = JSON.parse(stored);
}
} catch (e) {}
if (!instancesObj) {
instancesObj = await this.loadInstancesFromGitHub();
}
const targetUrls = instancesObj[type] || instancesObj.api || [];
if (targetUrls.length === 0) return [];
const speedCache = this.getCachedSpeedTests();
// Construct cache key based on type
const getCacheKey = (u) => (type === 'streaming' ? `${u}#streaming` : u);
const urlsToTest = targetUrls.filter((url) => !speedCache.speeds[getCacheKey(url)]);
if (urlsToTest.length > 0) {
const results = await this.testSpecificUrls(urlsToTest, type);
this.updateSpeedCache(results);
Object.assign(speedCache, this.getCachedSpeedTests());
}
const sortList = (list) => {
return [...list].sort((a, b) => {
const speedA = speedCache.speeds[getCacheKey(a)]?.speed ?? Infinity;
const speedB = speedCache.speeds[getCacheKey(b)]?.speed ?? Infinity;
return speedA - speedB;
});
};
const sortedList = sortList(targetUrls);
// Persist the sorted order
instancesObj[type] = sortedList;
this.saveInstances(instancesObj);
return sortedList;
},
async refreshSpeedTests() {
const instances = await this.loadInstancesFromGitHub();
const promises = [];
if (instances.api && instances.api.length) {
promises.push(this.testSpecificUrls(instances.api, 'api'));
}
if (instances.streaming && instances.streaming.length) {
promises.push(this.testSpecificUrls(instances.streaming, 'streaming'));
}
const resultsArray = await Promise.all(promises);
const allResults = resultsArray.flat();
this.updateSpeedCache(allResults);
// Return API instances for the UI to render (default view)
return this.getInstances('api');
},
saveInstances(instances, type) {
if (type) {
try { 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,13 +365,13 @@ 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');

576
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;
}; };
@ -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,7 +137,7 @@ 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)
@ -137,7 +145,6 @@ export function getVibrantColorFromImage(imgElement) {
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

@ -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);

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 {

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
}),
],
}); });