style: auto-fix linting issues
This commit is contained in:
parent
caea2fc707
commit
dc3ae80d9f
35 changed files with 5193 additions and 4240 deletions
|
|
@ -1,11 +1,13 @@
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -17,6 +19,7 @@ This project uses [Vite](https://vitejs.dev/) for local development and optimize
|
||||||
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.
|
||||||
|
|
@ -41,12 +44,14 @@ We use a standard stack to ensure code quality and consistency:
|
||||||
> 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]
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,3 @@ UI:
|
||||||
| 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 |
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
[](https://github.com/SamidyFR/monochrome/stargazers)
|
[](https://github.com/SamidyFR/monochrome/stargazers)
|
||||||
[](https://github.com/SamidyFR/monochrome/forks)
|
[](https://github.com/SamidyFR/monochrome/forks)
|
||||||
[](https://github.com/SamidyFR/monochrome/issues)
|
[](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)
|
||||||
|
|
|
||||||
|
|
@ -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'] }],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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,22 +18,27 @@ 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": {
|
||||||
|
|
@ -50,29 +57,32 @@ Firebase will block login attempts from unknown domains.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
* **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.
|
||||||
|
|
|
||||||
1156
index.html
1156
index.html
File diff suppressed because it is too large
Load diff
92
js/api.js
92
js/api.js
|
|
@ -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.cache.clearExpired();
|
||||||
this.pruneStreamCache();
|
this.pruneStreamCache();
|
||||||
}, 1000 * 60 * 5);
|
},
|
||||||
|
1000 * 60 * 5
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pruneStreamCache() {
|
pruneStreamCache() {
|
||||||
|
|
@ -39,9 +42,7 @@ 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 {
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -704,7 +702,7 @@ export class LosslessAPI {
|
||||||
// fabricate the track object expected by parseTrackLookup
|
// fabricate the track object expected by parseTrackLookup
|
||||||
const trackStub = {
|
const trackStub = {
|
||||||
duration: raw.duration ?? 0,
|
duration: raw.duration ?? 0,
|
||||||
id: raw.trackId ?? null
|
id: raw.trackId ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// return exactly what parseTrackLookup expects
|
// return exactly what parseTrackLookup expects
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
159
js/app.js
159
js/app.js
|
|
@ -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,17 +29,18 @@ 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
|
||||||
|
.watchAvailability((available) => {
|
||||||
if (available) {
|
if (available) {
|
||||||
castBtn.style.display = 'flex';
|
castBtn.style.display = 'flex';
|
||||||
castBtn.classList.add('available');
|
castBtn.classList.add('available');
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
})
|
||||||
|
.catch((err) => {
|
||||||
console.log('Remote playback not available:', err);
|
console.log('Remote playback not available:', err);
|
||||||
if (window.innerWidth > 768) {
|
if (window.innerWidth > 768) {
|
||||||
castBtn.style.display = 'flex';
|
castBtn.style.display = 'flex';
|
||||||
|
|
@ -39,7 +52,7 @@ function initializeCasting(audioPlayer, castBtn) {
|
||||||
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,7 +102,6 @@ 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;
|
||||||
|
|
@ -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') {
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -374,7 +387,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 {
|
||||||
let playlist, tracks;
|
let playlist, tracks;
|
||||||
|
|
@ -505,7 +519,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
progressFill.style.width = `${Math.min(percentage, 100)}%`;
|
progressFill.style.width = `${Math.min(percentage, 100)}%`;
|
||||||
progressCurrent.textContent = progress.current.toString();
|
progressCurrent.textContent = progress.current.toString();
|
||||||
currentTrackElement.textContent = progress.currentTrack;
|
currentTrackElement.textContent = progress.currentTrack;
|
||||||
if (currentArtistElement) currentArtistElement.textContent = progress.currentArtist || '';
|
if (currentArtistElement)
|
||||||
|
currentArtistElement.textContent = progress.currentArtist || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
tracks = result.tracks;
|
tracks = result.tracks;
|
||||||
|
|
@ -538,7 +553,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
||||||
|
|
@ -557,7 +572,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
if (e.target.closest('.edit-playlist-btn')) {
|
if (e.target.closest('.edit-playlist-btn')) {
|
||||||
const card = e.target.closest('.user-playlist');
|
const card = e.target.closest('.user-playlist');
|
||||||
const playlistId = card.dataset.userPlaylistId;
|
const playlistId = card.dataset.userPlaylistId;
|
||||||
db.getPlaylist(playlistId).then(async playlist => {
|
db.getPlaylist(playlistId).then(async (playlist) => {
|
||||||
if (playlist) {
|
if (playlist) {
|
||||||
const modal = document.getElementById('playlist-modal');
|
const modal = document.getElementById('playlist-modal');
|
||||||
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
|
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
|
||||||
|
|
@ -601,7 +616,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
if (e.target.closest('#edit-playlist-btn')) {
|
if (e.target.closest('#edit-playlist-btn')) {
|
||||||
const playlistId = window.location.hash.split('/')[1];
|
const playlistId = window.location.hash.split('/')[1];
|
||||||
db.getPlaylist(playlistId).then(playlist => {
|
db.getPlaylist(playlistId).then((playlist) => {
|
||||||
if (playlist) {
|
if (playlist) {
|
||||||
const modal = document.getElementById('playlist-modal');
|
const modal = document.getElementById('playlist-modal');
|
||||||
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
|
document.getElementById('playlist-modal-title').textContent = 'Edit Playlist';
|
||||||
|
|
@ -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,10 +761,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
await Promise.all(chunk.map(async (album) => {
|
await Promise.all(
|
||||||
|
chunk.map(async (album) => {
|
||||||
try {
|
try {
|
||||||
const { tracks } = await api.getAlbum(album.id);
|
const { tracks } = await api.getAlbum(album.id);
|
||||||
tracks.forEach(track => {
|
tracks.forEach((track) => {
|
||||||
if (!trackSet.has(track.id)) {
|
if (!trackSet.has(track.id)) {
|
||||||
trackSet.add(track.id);
|
trackSet.add(track.id);
|
||||||
allTracks.push(track);
|
allTracks.push(track);
|
||||||
|
|
@ -756,7 +774,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`Failed to fetch tracks for album ${album.title}:`, 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');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
js/cache.js
10
js/cache.js
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
js/db.js
53
js/db.js
|
|
@ -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,
|
id: item.album.id,
|
||||||
cover: item.album.cover,
|
cover: item.album.cover,
|
||||||
releaseDate: item.album.releaseDate || null,
|
releaseDate: item.album.releaseDate || null,
|
||||||
vibrantColor: item.album.vibrantColor || null
|
vibrantColor: item.album.vibrantColor || null,
|
||||||
} : 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +228,7 @@ 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);
|
||||||
|
|
@ -243,7 +248,7 @@ async function generateAndDownloadZip(zip, filename, notification, progressTotal
|
||||||
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',
|
description: 'ZIP Archive',
|
||||||
accept: { 'application/zip': ['.zip'] }
|
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,15 +427,20 @@ 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(
|
||||||
|
localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}',
|
||||||
|
{
|
||||||
albumTitle: fullAlbum.title,
|
albumTitle: fullAlbum.title,
|
||||||
albumArtist: fullAlbum.artist?.name,
|
albumArtist: fullAlbum.artist?.name,
|
||||||
year: year
|
year: year,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
||||||
addCoverBlobToZip(zip, fullFolderPath, coverBlob);
|
addCoverBlobToZip(zip, fullFolderPath, coverBlob);
|
||||||
|
|
@ -449,7 +472,6 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
161
js/events.js
161
js/events.js
|
|
@ -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';
|
||||||
|
|
@ -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,16 +450,18 @@ 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(
|
||||||
|
'wheel',
|
||||||
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||||
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
|
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
|
||||||
|
|
@ -461,9 +474,13 @@ function initializeSmoothSliders(audioPlayer, player) {
|
||||||
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(
|
||||||
|
'wheel',
|
||||||
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||||
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
|
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
|
||||||
|
|
@ -476,10 +493,21 @@ function initializeSmoothSliders(audioPlayer, player) {
|
||||||
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') {
|
||||||
|
|
@ -512,7 +540,7 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
|
||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracks = playlist ? playlist.tracks : (item.tracks || []);
|
tracks = playlist ? playlist.tracks : item.tracks || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tracks.length > 0) {
|
if (tracks.length > 0) {
|
||||||
|
|
@ -541,7 +569,8 @@ 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 =
|
||||||
|
type === 'track'
|
||||||
? `[data-track-id="${id}"] .like-btn`
|
? `[data-track-id="${id}"] .like-btn`
|
||||||
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
|
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
|
||||||
|
|
||||||
|
|
@ -556,7 +585,7 @@ 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);
|
||||||
|
|
@ -570,7 +599,8 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
|
||||||
|
|
||||||
// Handle Library Page Update
|
// Handle Library Page Update
|
||||||
if (window.location.hash === '#library') {
|
if (window.location.hash === '#library') {
|
||||||
const itemSelector = type === 'track'
|
const itemSelector =
|
||||||
|
type === 'track'
|
||||||
? `.track-item[data-track-id="${id}"]`
|
? `.track-item[data-track-id="${id}"]`
|
||||||
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
|
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
|
||||||
|
|
||||||
|
|
@ -625,14 +655,16 @@ 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
|
||||||
|
.map((p) => {
|
||||||
const alreadyContains = playlistsWithTrack.has(p.id);
|
const alreadyContains = playlistsWithTrack.has(p.id);
|
||||||
return `
|
return `
|
||||||
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
|
<div class="modal-option ${alreadyContains ? 'already-contains' : ''}" data-id="${p.id}">
|
||||||
|
|
@ -640,7 +672,8 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
|
||||||
${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,13 +935,20 @@ 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');
|
||||||
|
|
||||||
|
|
@ -891,12 +956,19 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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,9 +170,12 @@ 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
|
||||||
|
.writeText(link)
|
||||||
|
.then(() => {
|
||||||
alert('Magic Link copied to clipboard! Send it to your other device.');
|
alert('Magic Link copied to clipboard! Send it to your other device.');
|
||||||
}).catch(err => {
|
})
|
||||||
|
.catch((err) => {
|
||||||
console.error('Clipboard error:', err);
|
console.error('Clipboard error:', err);
|
||||||
prompt('Copy this link:', link);
|
prompt('Copy this link:', link);
|
||||||
});
|
});
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
js/lastfm.js
31
js/lastfm.js
|
|
@ -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(
|
||||||
|
'lastfm-session',
|
||||||
|
JSON.stringify({
|
||||||
key: sessionKey,
|
key: sessionKey,
|
||||||
name: username
|
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);
|
||||||
|
|
|
||||||
236
js/lyrics.js
236
js/lyrics.js
|
|
@ -1,16 +1,10 @@
|
||||||
//js/lyrics.js
|
//js/lyrics.js
|
||||||
import {
|
import { getTrackTitle, getTrackArtists, buildTrackFilename, SVG_DOWNLOAD, SVG_CLOSE } from './utils.js';
|
||||||
getTrackTitle,
|
import { sidePanelManager } from './side-panel.js';
|
||||||
getTrackArtists,
|
|
||||||
buildTrackFilename,
|
|
||||||
SVG_DOWNLOAD,
|
|
||||||
SVG_CLOSE,
|
|
||||||
} from "./utils.js";
|
|
||||||
import { sidePanelManager } from "./side-panel.js";
|
|
||||||
|
|
||||||
// Dictionary path for kuromoji
|
// Dictionary path for kuromoji
|
||||||
// Using CDN - the kuroshiro-analyzer loaded from unpkg will use this as base for fetching dict files
|
// Using CDN - the kuroshiro-analyzer loaded from unpkg will use this as base for fetching dict files
|
||||||
const KUROMOJI_DICT_PATH = "https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/";
|
const KUROMOJI_DICT_PATH = 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/';
|
||||||
|
|
||||||
export class LyricsManager {
|
export class LyricsManager {
|
||||||
constructor(api) {
|
constructor(api) {
|
||||||
|
|
@ -85,38 +79,35 @@ export class LyricsManager {
|
||||||
|
|
||||||
// Load Kuroshiro from CDN
|
// Load Kuroshiro from CDN
|
||||||
if (!window.Kuroshiro) {
|
if (!window.Kuroshiro) {
|
||||||
await this.loadScript(
|
await this.loadScript('https://unpkg.com/kuroshiro@1.2.0/dist/kuroshiro.min.js');
|
||||||
"https://unpkg.com/kuroshiro@1.2.0/dist/kuroshiro.min.js",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Kuromoji analyzer from CDN
|
// Load Kuromoji analyzer from CDN
|
||||||
if (!window.KuromojiAnalyzer) {
|
if (!window.KuromojiAnalyzer) {
|
||||||
await this.loadScript(
|
await this.loadScript(
|
||||||
"https://unpkg.com/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js",
|
'https://unpkg.com/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Kuroshiro (CDN version exports as .default)
|
// Initialize Kuroshiro (CDN version exports as .default)
|
||||||
const Kuroshiro = window.Kuroshiro.default || window.Kuroshiro;
|
const Kuroshiro = window.Kuroshiro.default || window.Kuroshiro;
|
||||||
const KuromojiAnalyzer =
|
const KuromojiAnalyzer = window.KuromojiAnalyzer.default || window.KuromojiAnalyzer;
|
||||||
window.KuromojiAnalyzer.default || window.KuromojiAnalyzer;
|
|
||||||
|
|
||||||
this.kuroshiro = new Kuroshiro();
|
this.kuroshiro = new Kuroshiro();
|
||||||
|
|
||||||
// Initialize with a dummy path - our fetch interceptor will redirect to CDN
|
// Initialize with a dummy path - our fetch interceptor will redirect to CDN
|
||||||
await this.kuroshiro.init(
|
await this.kuroshiro.init(
|
||||||
new KuromojiAnalyzer({
|
new KuromojiAnalyzer({
|
||||||
dictPath: "/dict/", // This gets mangled but our interceptor fixes it
|
dictPath: '/dict/', // This gets mangled but our interceptor fixes it
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.kuroshiroLoaded = true;
|
this.kuroshiroLoaded = true;
|
||||||
this.kuroshiroLoading = false;
|
this.kuroshiroLoading = false;
|
||||||
console.log("✓ Kuroshiro loaded and initialized successfully");
|
console.log('✓ Kuroshiro loaded and initialized successfully');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("✗ Failed to load Kuroshiro:", error);
|
console.error('✗ Failed to load Kuroshiro:', error);
|
||||||
this.kuroshiroLoaded = false;
|
this.kuroshiroLoaded = false;
|
||||||
this.kuroshiroLoading = false;
|
this.kuroshiroLoading = false;
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -131,7 +122,7 @@ export class LyricsManager {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const script = document.createElement("script");
|
const script = document.createElement('script');
|
||||||
script.src = src;
|
script.src = src;
|
||||||
script.onload = resolve;
|
script.onload = resolve;
|
||||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
||||||
|
|
@ -159,32 +150,28 @@ export class LyricsManager {
|
||||||
if (!this.kuroshiroLoaded) {
|
if (!this.kuroshiroLoaded) {
|
||||||
const success = await this.loadKuroshiro();
|
const success = await this.loadKuroshiro();
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.warn("Kuroshiro not available, skipping conversion");
|
console.warn('Kuroshiro not available, skipping conversion');
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.kuroshiro) {
|
if (!this.kuroshiro) {
|
||||||
console.warn("Kuroshiro not available, skipping conversion");
|
console.warn('Kuroshiro not available, skipping conversion');
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert to Romaji using Kuroshiro (handles Kanji, Hiragana, Katakana)
|
// Convert to Romaji using Kuroshiro (handles Kanji, Hiragana, Katakana)
|
||||||
const result = await this.kuroshiro.convert(text, {
|
const result = await this.kuroshiro.convert(text, {
|
||||||
to: "romaji",
|
to: 'romaji',
|
||||||
mode: "spaced",
|
mode: 'spaced',
|
||||||
romajiSystem: "hepburn",
|
romajiSystem: 'hepburn',
|
||||||
});
|
});
|
||||||
// Cache the result
|
// Cache the result
|
||||||
this.romajiTextCache.set(text, result);
|
this.romajiTextCache.set(text, result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn('Romaji conversion failed for text:', text.substring(0, 30), error);
|
||||||
"Romaji conversion failed for text:",
|
|
||||||
text.substring(0, 30),
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,16 +180,16 @@ export class LyricsManager {
|
||||||
setRomajiMode(enabled) {
|
setRomajiMode(enabled) {
|
||||||
this.isRomajiMode = enabled;
|
this.isRomajiMode = enabled;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("lyricsRomajiMode", enabled ? "true" : "false");
|
localStorage.setItem('lyricsRomajiMode', enabled ? 'true' : 'false');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to save Romaji mode preference:", e);
|
console.warn('Failed to save Romaji mode preference:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get saved Romaji mode preference
|
// Get saved Romaji mode preference
|
||||||
getRomajiMode() {
|
getRomajiMode() {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem("lyricsRomajiMode") === "true";
|
return localStorage.getItem('lyricsRomajiMode') === 'true';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -211,24 +198,20 @@ export class LyricsManager {
|
||||||
async ensureComponentLoaded() {
|
async ensureComponentLoaded() {
|
||||||
if (this.componentLoaded) return;
|
if (this.componentLoaded) return;
|
||||||
|
|
||||||
if (
|
if (typeof customElements !== 'undefined' && customElements.get('am-lyrics')) {
|
||||||
typeof customElements !== "undefined" &&
|
|
||||||
customElements.get("am-lyrics")
|
|
||||||
) {
|
|
||||||
this.componentLoaded = true;
|
this.componentLoaded = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement('script');
|
||||||
script.type = "module";
|
script.type = 'module';
|
||||||
script.src =
|
script.src = 'https://cdn.jsdelivr.net/npm/@uimaxbai/am-lyrics@0.6.2/dist/src/am-lyrics.min.js';
|
||||||
"https://cdn.jsdelivr.net/npm/@uimaxbai/am-lyrics@0.6.2/dist/src/am-lyrics.min.js";
|
|
||||||
|
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
if (typeof customElements !== "undefined") {
|
if (typeof customElements !== 'undefined') {
|
||||||
customElements
|
customElements
|
||||||
.whenDefined("am-lyrics")
|
.whenDefined('am-lyrics')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.componentLoaded = true;
|
this.componentLoaded = true;
|
||||||
resolve();
|
resolve();
|
||||||
|
|
@ -239,8 +222,7 @@ export class LyricsManager {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
script.onerror = () =>
|
script.onerror = () => reject(new Error('Failed to load lyrics component'));
|
||||||
reject(new Error("Failed to load lyrics component"));
|
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -253,14 +235,14 @@ export class LyricsManager {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const artist = Array.isArray(track.artists)
|
const artist = Array.isArray(track.artists)
|
||||||
? track.artists.map((a) => a.name || a).join(", ")
|
? track.artists.map((a) => a.name || a).join(', ')
|
||||||
: track.artist?.name || "";
|
: track.artist?.name || '';
|
||||||
const title = track.title || "";
|
const title = track.title || '';
|
||||||
const album = track.album?.title || "";
|
const album = track.album?.title || '';
|
||||||
const duration = track.duration ? Math.round(track.duration) : null;
|
const duration = track.duration ? Math.round(track.duration) : null;
|
||||||
|
|
||||||
if (!title || !artist) {
|
if (!title || !artist) {
|
||||||
console.warn("Missing required fields for LRCLIB");
|
console.warn('Missing required fields for LRCLIB');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,12 +251,10 @@ export class LyricsManager {
|
||||||
artist_name: artist,
|
artist_name: artist,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (album) params.append("album_name", album);
|
if (album) params.append('album_name', album);
|
||||||
if (duration) params.append("duration", duration.toString());
|
if (duration) params.append('duration', duration.toString());
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(`https://lrclib.net/api/get?${params.toString()}`);
|
||||||
`https://lrclib.net/api/get?${params.toString()}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
@ -282,7 +262,7 @@ export class LyricsManager {
|
||||||
if (data.syncedLyrics) {
|
if (data.syncedLyrics) {
|
||||||
const lyricsData = {
|
const lyricsData = {
|
||||||
subtitles: data.syncedLyrics,
|
subtitles: data.syncedLyrics,
|
||||||
lyricsProvider: "LRCLIB",
|
lyricsProvider: 'LRCLIB',
|
||||||
};
|
};
|
||||||
|
|
||||||
this.lyricsCache.set(trackId, lyricsData);
|
this.lyricsCache.set(trackId, lyricsData);
|
||||||
|
|
@ -290,7 +270,7 @@ export class LyricsManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("LRCLIB fetch failed:", error);
|
console.warn('LRCLIB fetch failed:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,16 +279,13 @@ export class LyricsManager {
|
||||||
|
|
||||||
parseSyncedLyrics(subtitles) {
|
parseSyncedLyrics(subtitles) {
|
||||||
if (!subtitles) return [];
|
if (!subtitles) return [];
|
||||||
const lines = subtitles.split("\n").filter((line) => line.trim());
|
const lines = subtitles.split('\n').filter((line) => line.trim());
|
||||||
return lines
|
return lines
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
const match = line.match(/\[(\d+):(\d+)\.(\d+)\]\s*(.+)/);
|
const match = line.match(/\[(\d+):(\d+)\.(\d+)\]\s*(.+)/);
|
||||||
if (match) {
|
if (match) {
|
||||||
const [, minutes, seconds, centiseconds, text] = match;
|
const [, minutes, seconds, centiseconds, text] = match;
|
||||||
const timeInSeconds =
|
const timeInSeconds = parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100;
|
||||||
parseInt(minutes) * 60 +
|
|
||||||
parseInt(seconds) +
|
|
||||||
parseInt(centiseconds) / 100;
|
|
||||||
return { time: timeInSeconds, text: text.trim() };
|
return { time: timeInSeconds, text: text.trim() };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -324,9 +301,9 @@ export class LyricsManager {
|
||||||
|
|
||||||
let lrc = `[ti:${trackTitle}]\n`;
|
let lrc = `[ti:${trackTitle}]\n`;
|
||||||
lrc += `[ar:${trackArtist}]\n`;
|
lrc += `[ar:${trackArtist}]\n`;
|
||||||
lrc += `[al:${track.album?.title || "Unknown Album"}]\n`;
|
lrc += `[al:${track.album?.title || 'Unknown Album'}]\n`;
|
||||||
lrc += `[by:${lyricsData.lyricsProvider || "Unknown"}]\n`;
|
lrc += `[by:${lyricsData.lyricsProvider || 'Unknown'}]\n`;
|
||||||
lrc += "\n";
|
lrc += '\n';
|
||||||
lrc += lyricsData.subtitles;
|
lrc += lyricsData.subtitles;
|
||||||
|
|
||||||
return lrc;
|
return lrc;
|
||||||
|
|
@ -335,18 +312,15 @@ export class LyricsManager {
|
||||||
downloadLRC(lyricsData, track) {
|
downloadLRC(lyricsData, track) {
|
||||||
const lrcContent = this.generateLRCContent(lyricsData, track);
|
const lrcContent = this.generateLRCContent(lyricsData, track);
|
||||||
if (!lrcContent) {
|
if (!lrcContent) {
|
||||||
alert("No synced lyrics available for this track");
|
alert('No synced lyrics available for this track');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = new Blob([lrcContent], { type: "application/octet-stream" });
|
const blob = new Blob([lrcContent], { type: 'application/octet-stream' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = buildTrackFilename(track, "LOSSLESS").replace(
|
a.download = buildTrackFilename(track, 'LOSSLESS').replace(/\.flac$/, '.lrc');
|
||||||
/\.flac$/,
|
|
||||||
".lrc",
|
|
||||||
);
|
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
|
|
@ -379,11 +353,11 @@ export class LyricsManager {
|
||||||
// Check if any relevant mutation occurred
|
// Check if any relevant mutation occurred
|
||||||
const hasRelevantChange = mutations.some((mutation) => {
|
const hasRelevantChange = mutations.some((mutation) => {
|
||||||
// New nodes added
|
// New nodes added
|
||||||
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
|
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Text content changed
|
// Text content changed
|
||||||
if (mutation.type === "characterData" && mutation.target.textContent) {
|
if (mutation.type === 'characterData' && mutation.target.textContent) {
|
||||||
// Only trigger if the text contains Japanese
|
// Only trigger if the text contains Japanese
|
||||||
return this.containsJapanese(mutation.target.textContent);
|
return this.containsJapanese(mutation.target.textContent);
|
||||||
}
|
}
|
||||||
|
|
@ -431,19 +405,14 @@ export class LyricsManager {
|
||||||
if (!this.kuroshiroLoaded) {
|
if (!this.kuroshiroLoaded) {
|
||||||
const success = await this.loadKuroshiro();
|
const success = await this.loadKuroshiro();
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.warn("Cannot convert lyrics - Kuroshiro load failed");
|
console.warn('Cannot convert lyrics - Kuroshiro load failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all text nodes in the component
|
// Find all text nodes in the component
|
||||||
const textNodes = [];
|
const textNodes = [];
|
||||||
const walker = document.createTreeWalker(
|
const walker = document.createTreeWalker(rootToTraverse, NodeFilter.SHOW_TEXT, null, false);
|
||||||
rootToTraverse,
|
|
||||||
NodeFilter.SHOW_TEXT,
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
let node;
|
let node;
|
||||||
while ((node = walker.nextNode())) {
|
while ((node = walker.nextNode())) {
|
||||||
|
|
@ -457,10 +426,10 @@ export class LyricsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentTag = textNode.parentElement.tagName?.toLowerCase();
|
const parentTag = textNode.parentElement.tagName?.toLowerCase();
|
||||||
const parentClass = String(textNode.parentElement.className || "");
|
const parentClass = String(textNode.parentElement.className || '');
|
||||||
|
|
||||||
// Skip elements that shouldn't be converted
|
// Skip elements that shouldn't be converted
|
||||||
const skipTags = ["script", "style", "code", "input", "textarea", "time"];
|
const skipTags = ['script', 'style', 'code', 'input', 'textarea', 'time'];
|
||||||
if (skipTags.includes(parentTag)) {
|
if (skipTags.includes(parentTag)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -469,11 +438,9 @@ export class LyricsManager {
|
||||||
|
|
||||||
// Skip progress indicators and timestamps (but NOT progress-text which is the actual lyrics!)
|
// Skip progress indicators and timestamps (but NOT progress-text which is the actual lyrics!)
|
||||||
if (
|
if (
|
||||||
(parentClass.includes("progress") &&
|
(parentClass.includes('progress') && !parentClass.includes('progress-text')) ||
|
||||||
!parentClass.includes("progress-text")) ||
|
(parentClass.includes('time') && !parentClass.includes('progress-text')) ||
|
||||||
(parentClass.includes("time") &&
|
parentClass.includes('timestamp')
|
||||||
!parentClass.includes("progress-text")) ||
|
|
||||||
parentClass.includes("timestamp")
|
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -543,7 +510,7 @@ export async function openLyricsPanel(track, audioPlayer, lyricsManager) {
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, load in background
|
// Otherwise, load in background
|
||||||
manager.loadKuroshiro().catch((err) => {
|
manager.loadKuroshiro().catch((err) => {
|
||||||
console.warn("Failed to load Kuroshiro for Romaji conversion:", err);
|
console.warn('Failed to load Kuroshiro for Romaji conversion:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -556,7 +523,7 @@ export async function openLyricsPanel(track, audioPlayer, lyricsManager) {
|
||||||
<button id="close-side-panel-btn" class="btn-icon" title="Close">
|
<button id="close-side-panel-btn" class="btn-icon" title="Close">
|
||||||
${SVG_CLOSE}
|
${SVG_CLOSE}
|
||||||
</button>
|
</button>
|
||||||
<button id="romaji-toggle-btn" class="btn-icon" title="Toggle Romaji (Japanese to Latin)" data-enabled="${isRomajiMode}" style="color: ${isRomajiMode ? "var(--primary)" : ""}">
|
<button id="romaji-toggle-btn" class="btn-icon" title="Toggle Romaji (Japanese to Latin)" data-enabled="${isRomajiMode}" style="color: ${isRomajiMode ? 'var(--primary)' : ''}">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||||
|
|
@ -564,25 +531,23 @@ export async function openLyricsPanel(track, audioPlayer, lyricsManager) {
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container
|
container.querySelector('#close-side-panel-btn').addEventListener('click', () => {
|
||||||
.querySelector("#close-side-panel-btn")
|
|
||||||
.addEventListener("click", () => {
|
|
||||||
sidePanelManager.close();
|
sidePanelManager.close();
|
||||||
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
|
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Romaji toggle button handler
|
// Romaji toggle button handler
|
||||||
const romajiBtn = container.querySelector("#romaji-toggle-btn");
|
const romajiBtn = container.querySelector('#romaji-toggle-btn');
|
||||||
if (romajiBtn) {
|
if (romajiBtn) {
|
||||||
const updateRomajiBtn = () => {
|
const updateRomajiBtn = () => {
|
||||||
const enabled = manager.isRomajiMode;
|
const enabled = manager.isRomajiMode;
|
||||||
romajiBtn.setAttribute("data-enabled", enabled);
|
romajiBtn.setAttribute('data-enabled', enabled);
|
||||||
romajiBtn.style.color = enabled ? "var(--primary)" : "";
|
romajiBtn.style.color = enabled ? 'var(--primary)' : '';
|
||||||
};
|
};
|
||||||
updateRomajiBtn();
|
updateRomajiBtn();
|
||||||
|
|
||||||
romajiBtn.addEventListener("click", async () => {
|
romajiBtn.addEventListener('click', async () => {
|
||||||
const amLyrics = sidePanelManager.panel.querySelector("am-lyrics");
|
const amLyrics = sidePanelManager.panel.querySelector('am-lyrics');
|
||||||
if (amLyrics) {
|
if (amLyrics) {
|
||||||
const newMode = await manager.toggleRomajiMode(amLyrics);
|
const newMode = await manager.toggleRomajiMode(amLyrics);
|
||||||
updateRomajiBtn();
|
updateRomajiBtn();
|
||||||
|
|
@ -596,15 +561,10 @@ export async function openLyricsPanel(track, audioPlayer, lyricsManager) {
|
||||||
await renderLyricsComponent(container, track, audioPlayer, manager);
|
await renderLyricsComponent(container, track, audioPlayer, manager);
|
||||||
};
|
};
|
||||||
|
|
||||||
sidePanelManager.open("lyrics", "Lyrics", renderControls, renderContent);
|
sidePanelManager.open('lyrics', 'Lyrics', renderControls, renderContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderLyricsComponent(
|
async function renderLyricsComponent(container, track, audioPlayer, lyricsManager) {
|
||||||
container,
|
|
||||||
track,
|
|
||||||
audioPlayer,
|
|
||||||
lyricsManager,
|
|
||||||
) {
|
|
||||||
container.innerHTML = '<div class="lyrics-loading">Loading lyrics...</div>';
|
container.innerHTML = '<div class="lyrics-loading">Loading lyrics...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -617,26 +577,24 @@ async function renderLyricsComponent(
|
||||||
const title = track.title;
|
const title = track.title;
|
||||||
const artist = getTrackArtists(track);
|
const artist = getTrackArtists(track);
|
||||||
const album = track.album?.title;
|
const album = track.album?.title;
|
||||||
const durationMs = track.duration
|
const durationMs = track.duration ? Math.round(track.duration * 1000) : undefined;
|
||||||
? Math.round(track.duration * 1000)
|
const isrc = track.isrc || '';
|
||||||
: undefined;
|
|
||||||
const isrc = track.isrc || "";
|
|
||||||
|
|
||||||
container.innerHTML = "";
|
container.innerHTML = '';
|
||||||
const amLyrics = document.createElement("am-lyrics");
|
const amLyrics = document.createElement('am-lyrics');
|
||||||
amLyrics.setAttribute("song-title", title);
|
amLyrics.setAttribute('song-title', title);
|
||||||
amLyrics.setAttribute("song-artist", artist);
|
amLyrics.setAttribute('song-artist', artist);
|
||||||
if (album) amLyrics.setAttribute("song-album", album);
|
if (album) amLyrics.setAttribute('song-album', album);
|
||||||
if (durationMs) amLyrics.setAttribute("song-duration", durationMs);
|
if (durationMs) amLyrics.setAttribute('song-duration', durationMs);
|
||||||
amLyrics.setAttribute("query", `${title} ${artist}`.trim());
|
amLyrics.setAttribute('query', `${title} ${artist}`.trim());
|
||||||
if (isrc) amLyrics.setAttribute("isrc", isrc);
|
if (isrc) amLyrics.setAttribute('isrc', isrc);
|
||||||
|
|
||||||
amLyrics.setAttribute("highlight-color", "#93c5fd");
|
amLyrics.setAttribute('highlight-color', '#93c5fd');
|
||||||
amLyrics.setAttribute("hover-background-color", "rgba(59, 130, 246, 0.14)");
|
amLyrics.setAttribute('hover-background-color', 'rgba(59, 130, 246, 0.14)');
|
||||||
amLyrics.setAttribute("autoscroll", "");
|
amLyrics.setAttribute('autoscroll', '');
|
||||||
amLyrics.setAttribute("interpolate", "");
|
amLyrics.setAttribute('interpolate', '');
|
||||||
amLyrics.style.height = "100%";
|
amLyrics.style.height = '100%';
|
||||||
amLyrics.style.width = "100%";
|
amLyrics.style.width = '100%';
|
||||||
|
|
||||||
container.appendChild(amLyrics);
|
container.appendChild(amLyrics);
|
||||||
|
|
||||||
|
|
@ -696,9 +654,8 @@ async function renderLyricsComponent(
|
||||||
|
|
||||||
return amLyrics;
|
return amLyrics;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load lyrics:", error);
|
console.error('Failed to load lyrics:', error);
|
||||||
container.innerHTML =
|
container.innerHTML = '<div class="lyrics-error">Failed to load lyrics</div>';
|
||||||
'<div class="lyrics-error">Failed to load lyrics</div>';
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -745,11 +702,11 @@ function setupSync(track, audioPlayer, amLyrics) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
audioPlayer.addEventListener("timeupdate", updateTime);
|
audioPlayer.addEventListener('timeupdate', updateTime);
|
||||||
audioPlayer.addEventListener("play", onPlay);
|
audioPlayer.addEventListener('play', onPlay);
|
||||||
audioPlayer.addEventListener("pause", onPause);
|
audioPlayer.addEventListener('pause', onPause);
|
||||||
audioPlayer.addEventListener("seeked", updateTime);
|
audioPlayer.addEventListener('seeked', updateTime);
|
||||||
amLyrics.addEventListener("line-click", onLineClick);
|
amLyrics.addEventListener('line-click', onLineClick);
|
||||||
|
|
||||||
if (!audioPlayer.paused) {
|
if (!audioPlayer.paused) {
|
||||||
tick();
|
tick();
|
||||||
|
|
@ -759,20 +716,15 @@ function setupSync(track, audioPlayer, amLyrics) {
|
||||||
if (animationFrameId) {
|
if (animationFrameId) {
|
||||||
cancelAnimationFrame(animationFrameId);
|
cancelAnimationFrame(animationFrameId);
|
||||||
}
|
}
|
||||||
audioPlayer.removeEventListener("timeupdate", updateTime);
|
audioPlayer.removeEventListener('timeupdate', updateTime);
|
||||||
audioPlayer.removeEventListener("play", onPlay);
|
audioPlayer.removeEventListener('play', onPlay);
|
||||||
audioPlayer.removeEventListener("pause", onPause);
|
audioPlayer.removeEventListener('pause', onPause);
|
||||||
audioPlayer.removeEventListener("seeked", updateTime);
|
audioPlayer.removeEventListener('seeked', updateTime);
|
||||||
amLyrics.removeEventListener("line-click", onLineClick);
|
amLyrics.removeEventListener('line-click', onLineClick);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderLyricsInFullscreen(
|
export async function renderLyricsInFullscreen(track, audioPlayer, lyricsManager, container) {
|
||||||
track,
|
|
||||||
audioPlayer,
|
|
||||||
lyricsManager,
|
|
||||||
container,
|
|
||||||
) {
|
|
||||||
return renderLyricsComponent(container, track, audioPlayer, lyricsManager);
|
return renderLyricsComponent(container, track, audioPlayer, lyricsManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
138
js/metadata.js
138
js/metadata.js
|
|
@ -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.byteLength >= 4 &&
|
||||||
dataView.getUint8(0) === 0x66 && // 'f'
|
dataView.getUint8(0) === 0x66 && // 'f'
|
||||||
dataView.getUint8(1) === 0x4C && // 'L'
|
dataView.getUint8(1) === 0x4c && // 'L'
|
||||||
dataView.getUint8(2) === 0x61 && // 'a'
|
dataView.getUint8(2) === 0x61 && // 'a'
|
||||||
dataView.getUint8(3) === 0x43; // 'C'
|
dataView.getUint8(3) === 0x43
|
||||||
|
); // 'C'
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFlacBlocks(dataView) {
|
function parseFlacBlocks(dataView) {
|
||||||
|
|
@ -82,9 +84,10 @@ 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 + 1) << 16) |
|
||||||
(dataView.getUint8(offset + 2) << 8) |
|
(dataView.getUint8(offset + 2) << 8) |
|
||||||
dataView.getUint8(offset + 3);
|
dataView.getUint8(offset + 3);
|
||||||
|
|
||||||
|
|
@ -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 +
|
||||||
|
mimeBytes.length + // mime length + mime
|
||||||
|
4 +
|
||||||
|
descBytes.length + // desc length + desc
|
||||||
4 + // width
|
4 + // width
|
||||||
4 + // height
|
4 + // height
|
||||||
4 + // color depth
|
4 + // color depth
|
||||||
4 + // indexed colors
|
4 + // indexed colors
|
||||||
4 + imageBytes.length; // image length + image
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
js/player.js
46
js/player.js
|
|
@ -112,7 +112,7 @@ 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);
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -456,7 +455,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -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.audio.pause();
|
||||||
this.clearSleepTimer();
|
this.clearSleepTimer();
|
||||||
this.updateSleepTimerUI();
|
this.updateSleepTimerUI();
|
||||||
}, minutes * 60 * 1000);
|
},
|
||||||
|
minutes * 60 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
// Update UI every second
|
// Update UI every second
|
||||||
this.sleepTimerInterval = setInterval(() => {
|
this.sleepTimerInterval = setInterval(() => {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
368
js/settings.js
368
js/settings.js
|
|
@ -10,12 +10,12 @@ import {
|
||||||
waveformSettings,
|
waveformSettings,
|
||||||
replayGainSettings,
|
replayGainSettings,
|
||||||
smoothScrollingSettings,
|
smoothScrollingSettings,
|
||||||
downloadQualitySettings
|
downloadQualitySettings,
|
||||||
} from "./storage.js";
|
} from './storage.js';
|
||||||
import { db } from "./db.js";
|
import { db } from './db.js';
|
||||||
import { authManager } from "./firebase/auth.js";
|
import { authManager } from './firebase/auth.js';
|
||||||
import { syncManager } from "./firebase/sync.js";
|
import { syncManager } from './firebase/sync.js';
|
||||||
import { initializeFirebaseSettingsUI } from "./firebase/config.js";
|
import { initializeFirebaseSettingsUI } from './firebase/config.js';
|
||||||
|
|
||||||
export function initializeSettings(scrobbler, player, api, ui) {
|
export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
// Initialize Firebase UI & Settings
|
// Initialize Firebase UI & Settings
|
||||||
|
|
@ -23,45 +23,43 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
initializeFirebaseSettingsUI();
|
initializeFirebaseSettingsUI();
|
||||||
|
|
||||||
// Email Auth UI Logic
|
// Email Auth UI Logic
|
||||||
const toggleEmailBtn = document.getElementById("toggle-email-auth-btn");
|
const toggleEmailBtn = document.getElementById('toggle-email-auth-btn');
|
||||||
const cancelEmailBtn = document.getElementById("cancel-email-auth-btn");
|
const cancelEmailBtn = document.getElementById('cancel-email-auth-btn');
|
||||||
const authContainer = document.getElementById("email-auth-container");
|
const authContainer = document.getElementById('email-auth-container');
|
||||||
const authButtonsContainer = document.getElementById(
|
const authButtonsContainer = document.getElementById('auth-buttons-container');
|
||||||
"auth-buttons-container",
|
const emailInput = document.getElementById('auth-email');
|
||||||
);
|
const passwordInput = document.getElementById('auth-password');
|
||||||
const emailInput = document.getElementById("auth-email");
|
const signInBtn = document.getElementById('email-signin-btn');
|
||||||
const passwordInput = document.getElementById("auth-password");
|
const signUpBtn = document.getElementById('email-signup-btn');
|
||||||
const signInBtn = document.getElementById("email-signin-btn");
|
|
||||||
const signUpBtn = document.getElementById("email-signup-btn");
|
|
||||||
|
|
||||||
if (toggleEmailBtn && authContainer && authButtonsContainer) {
|
if (toggleEmailBtn && authContainer && authButtonsContainer) {
|
||||||
toggleEmailBtn.addEventListener("click", () => {
|
toggleEmailBtn.addEventListener('click', () => {
|
||||||
authContainer.style.display = "flex";
|
authContainer.style.display = 'flex';
|
||||||
authButtonsContainer.style.display = "none";
|
authButtonsContainer.style.display = 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelEmailBtn && authContainer && authButtonsContainer) {
|
if (cancelEmailBtn && authContainer && authButtonsContainer) {
|
||||||
cancelEmailBtn.addEventListener("click", () => {
|
cancelEmailBtn.addEventListener('click', () => {
|
||||||
authContainer.style.display = "none";
|
authContainer.style.display = 'none';
|
||||||
authButtonsContainer.style.display = "flex";
|
authButtonsContainer.style.display = 'flex';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signInBtn) {
|
if (signInBtn) {
|
||||||
signInBtn.addEventListener("click", async () => {
|
signInBtn.addEventListener('click', async () => {
|
||||||
const email = emailInput.value;
|
const email = emailInput.value;
|
||||||
const password = passwordInput.value;
|
const password = passwordInput.value;
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
alert("Please enter both email and password.");
|
alert('Please enter both email and password.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await authManager.signInWithEmail(email, password);
|
await authManager.signInWithEmail(email, password);
|
||||||
authContainer.style.display = "none";
|
authContainer.style.display = 'none';
|
||||||
authButtonsContainer.style.display = "flex";
|
authButtonsContainer.style.display = 'flex';
|
||||||
emailInput.value = "";
|
emailInput.value = '';
|
||||||
passwordInput.value = "";
|
passwordInput.value = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Error handled in authManager
|
// Error handled in authManager
|
||||||
}
|
}
|
||||||
|
|
@ -69,65 +67,64 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signUpBtn) {
|
if (signUpBtn) {
|
||||||
signUpBtn.addEventListener("click", async () => {
|
signUpBtn.addEventListener('click', async () => {
|
||||||
const email = emailInput.value;
|
const email = emailInput.value;
|
||||||
const password = passwordInput.value;
|
const password = passwordInput.value;
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
alert("Please enter both email and password.");
|
alert('Please enter both email and password.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await authManager.signUpWithEmail(email, password);
|
await authManager.signUpWithEmail(email, password);
|
||||||
authContainer.style.display = "none";
|
authContainer.style.display = 'none';
|
||||||
authButtonsContainer.style.display = "flex";
|
authButtonsContainer.style.display = 'flex';
|
||||||
emailInput.value = "";
|
emailInput.value = '';
|
||||||
passwordInput.value = "";
|
passwordInput.value = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Error handled in authManager
|
// Error handled in authManager
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastfmConnectBtn = document.getElementById("lastfm-connect-btn");
|
const lastfmConnectBtn = document.getElementById('lastfm-connect-btn');
|
||||||
const lastfmStatus = document.getElementById("lastfm-status");
|
const lastfmStatus = document.getElementById('lastfm-status');
|
||||||
const lastfmToggle = document.getElementById("lastfm-toggle");
|
const lastfmToggle = document.getElementById('lastfm-toggle');
|
||||||
const lastfmToggleSetting = document.getElementById("lastfm-toggle-setting");
|
const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting');
|
||||||
const lastfmLoveToggle = document.getElementById("lastfm-love-toggle");
|
const lastfmLoveToggle = document.getElementById('lastfm-love-toggle');
|
||||||
const lastfmLoveSetting = document.getElementById("lastfm-love-setting");
|
const lastfmLoveSetting = document.getElementById('lastfm-love-setting');
|
||||||
|
|
||||||
function updateLastFMUI() {
|
function updateLastFMUI() {
|
||||||
if (scrobbler.isAuthenticated()) {
|
if (scrobbler.isAuthenticated()) {
|
||||||
lastfmStatus.textContent = `Connected as ${scrobbler.username}`;
|
lastfmStatus.textContent = `Connected as ${scrobbler.username}`;
|
||||||
lastfmConnectBtn.textContent = "Disconnect";
|
lastfmConnectBtn.textContent = 'Disconnect';
|
||||||
lastfmConnectBtn.classList.add("danger");
|
lastfmConnectBtn.classList.add('danger');
|
||||||
lastfmToggleSetting.style.display = "flex";
|
lastfmToggleSetting.style.display = 'flex';
|
||||||
lastfmLoveSetting.style.display = "flex";
|
lastfmLoveSetting.style.display = 'flex';
|
||||||
lastfmToggle.checked = lastFMStorage.isEnabled();
|
lastfmToggle.checked = lastFMStorage.isEnabled();
|
||||||
lastfmLoveToggle.checked = lastFMStorage.shouldLoveOnLike();
|
lastfmLoveToggle.checked = lastFMStorage.shouldLoveOnLike();
|
||||||
} else {
|
} else {
|
||||||
lastfmStatus.textContent =
|
lastfmStatus.textContent = 'Connect your Last.fm account to scrobble tracks';
|
||||||
"Connect your Last.fm account to scrobble tracks";
|
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||||
lastfmConnectBtn.textContent = "Connect Last.fm";
|
lastfmConnectBtn.classList.remove('danger');
|
||||||
lastfmConnectBtn.classList.remove("danger");
|
lastfmToggleSetting.style.display = 'none';
|
||||||
lastfmToggleSetting.style.display = "none";
|
lastfmLoveSetting.style.display = 'none';
|
||||||
lastfmLoveSetting.style.display = "none";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLastFMUI();
|
updateLastFMUI();
|
||||||
|
|
||||||
lastfmConnectBtn?.addEventListener("click", async () => {
|
lastfmConnectBtn?.addEventListener('click', async () => {
|
||||||
if (scrobbler.isAuthenticated()) {
|
if (scrobbler.isAuthenticated()) {
|
||||||
if (confirm("Disconnect from Last.fm?")) {
|
if (confirm('Disconnect from Last.fm?')) {
|
||||||
scrobbler.disconnect();
|
scrobbler.disconnect();
|
||||||
updateLastFMUI();
|
updateLastFMUI();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authWindow = window.open("", "_blank");
|
const authWindow = window.open('', '_blank');
|
||||||
lastfmConnectBtn.disabled = true;
|
lastfmConnectBtn.disabled = true;
|
||||||
lastfmConnectBtn.textContent = "Opening Last.fm...";
|
lastfmConnectBtn.textContent = 'Opening Last.fm...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { token, url } = await scrobbler.getAuthUrl();
|
const { token, url } = await scrobbler.getAuthUrl();
|
||||||
|
|
@ -135,13 +132,13 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
if (authWindow) {
|
if (authWindow) {
|
||||||
authWindow.location.href = url;
|
authWindow.location.href = url;
|
||||||
} else {
|
} else {
|
||||||
alert("Popup blocked! Please allow popups.");
|
alert('Popup blocked! Please allow popups.');
|
||||||
lastfmConnectBtn.textContent = "Connect Last.fm";
|
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||||
lastfmConnectBtn.disabled = false;
|
lastfmConnectBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastfmConnectBtn.textContent = "Waiting for authorization...";
|
lastfmConnectBtn.textContent = 'Waiting for authorization...';
|
||||||
|
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 30;
|
const maxAttempts = 30;
|
||||||
|
|
@ -151,10 +148,10 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
|
|
||||||
if (attempts > maxAttempts) {
|
if (attempts > maxAttempts) {
|
||||||
clearInterval(checkAuth);
|
clearInterval(checkAuth);
|
||||||
lastfmConnectBtn.textContent = "Connect Last.fm";
|
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||||
lastfmConnectBtn.disabled = false;
|
lastfmConnectBtn.disabled = false;
|
||||||
if (authWindow && !authWindow.closed) authWindow.close();
|
if (authWindow && !authWindow.closed) authWindow.close();
|
||||||
alert("Authorization timed out. Please try again.");
|
alert('Authorization timed out. Please try again.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,60 +172,58 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Last.fm connection failed:", error);
|
console.error('Last.fm connection failed:', error);
|
||||||
alert("Failed to connect to Last.fm: " + error.message);
|
alert('Failed to connect to Last.fm: ' + error.message);
|
||||||
lastfmConnectBtn.textContent = "Connect Last.fm";
|
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||||
lastfmConnectBtn.disabled = false;
|
lastfmConnectBtn.disabled = false;
|
||||||
if (authWindow && !authWindow.closed) authWindow.close();
|
if (authWindow && !authWindow.closed) authWindow.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
lastfmToggle?.addEventListener("change", (e) => {
|
lastfmToggle?.addEventListener('change', (e) => {
|
||||||
lastFMStorage.setEnabled(e.target.checked);
|
lastFMStorage.setEnabled(e.target.checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
lastfmLoveToggle?.addEventListener("change", (e) => {
|
lastfmLoveToggle?.addEventListener('change', (e) => {
|
||||||
lastFMStorage.setLoveOnLike(e.target.checked);
|
lastFMStorage.setLoveOnLike(e.target.checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Theme picker
|
// Theme picker
|
||||||
const themePicker = document.getElementById("theme-picker");
|
const themePicker = document.getElementById('theme-picker');
|
||||||
const currentTheme = themeManager.getTheme();
|
const currentTheme = themeManager.getTheme();
|
||||||
|
|
||||||
themePicker.querySelectorAll(".theme-option").forEach((option) => {
|
themePicker.querySelectorAll('.theme-option').forEach((option) => {
|
||||||
if (option.dataset.theme === currentTheme) {
|
if (option.dataset.theme === currentTheme) {
|
||||||
option.classList.add("active");
|
option.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
option.addEventListener("click", () => {
|
option.addEventListener('click', () => {
|
||||||
const theme = option.dataset.theme;
|
const theme = option.dataset.theme;
|
||||||
|
|
||||||
themePicker
|
themePicker.querySelectorAll('.theme-option').forEach((opt) => opt.classList.remove('active'));
|
||||||
.querySelectorAll(".theme-option")
|
option.classList.add('active');
|
||||||
.forEach((opt) => opt.classList.remove("active"));
|
|
||||||
option.classList.add("active");
|
|
||||||
|
|
||||||
if (theme === "custom") {
|
if (theme === 'custom') {
|
||||||
document.getElementById("custom-theme-editor").classList.add("show");
|
document.getElementById('custom-theme-editor').classList.add('show');
|
||||||
renderCustomThemeEditor();
|
renderCustomThemeEditor();
|
||||||
themeManager.setTheme('custom');
|
themeManager.setTheme('custom');
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("custom-theme-editor").classList.remove("show");
|
document.getElementById('custom-theme-editor').classList.remove('show');
|
||||||
themeManager.setTheme(theme);
|
themeManager.setTheme(theme);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderCustomThemeEditor() {
|
function renderCustomThemeEditor() {
|
||||||
const grid = document.getElementById("theme-color-grid");
|
const grid = document.getElementById('theme-color-grid');
|
||||||
const customTheme = themeManager.getCustomTheme() || {
|
const customTheme = themeManager.getCustomTheme() || {
|
||||||
background: "#000000",
|
background: '#000000',
|
||||||
foreground: "#fafafa",
|
foreground: '#fafafa',
|
||||||
primary: "#ffffff",
|
primary: '#ffffff',
|
||||||
secondary: "#27272a",
|
secondary: '#27272a',
|
||||||
muted: "#27272a",
|
muted: '#27272a',
|
||||||
border: "#27272a",
|
border: '#27272a',
|
||||||
highlight: "#ffffff",
|
highlight: '#ffffff',
|
||||||
};
|
};
|
||||||
|
|
||||||
grid.innerHTML = Object.entries(customTheme)
|
grid.innerHTML = Object.entries(customTheme)
|
||||||
|
|
@ -238,209 +233,187 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
<label>${key}</label>
|
<label>${key}</label>
|
||||||
<input type="color" data-color="${key}" value="${value}">
|
<input type="color" data-color="${key}" value="${value}">
|
||||||
</div>
|
</div>
|
||||||
`,
|
`
|
||||||
)
|
)
|
||||||
.join("");
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
document
|
document.getElementById('apply-custom-theme')?.addEventListener('click', () => {
|
||||||
.getElementById("apply-custom-theme")
|
|
||||||
?.addEventListener("click", () => {
|
|
||||||
const colors = {};
|
const colors = {};
|
||||||
document
|
document.querySelectorAll('#theme-color-grid input[type="color"]').forEach((input) => {
|
||||||
.querySelectorAll('#theme-color-grid input[type="color"]')
|
|
||||||
.forEach((input) => {
|
|
||||||
colors[input.dataset.color] = input.value;
|
colors[input.dataset.color] = input.value;
|
||||||
});
|
});
|
||||||
themeManager.setCustomTheme(colors);
|
themeManager.setCustomTheme(colors);
|
||||||
});
|
});
|
||||||
|
|
||||||
document
|
document.getElementById('reset-custom-theme')?.addEventListener('click', () => {
|
||||||
.getElementById("reset-custom-theme")
|
|
||||||
?.addEventListener("click", () => {
|
|
||||||
renderCustomThemeEditor();
|
renderCustomThemeEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Streaming Quality setting
|
// Streaming Quality setting
|
||||||
const streamingQualitySetting = document.getElementById("streaming-quality-setting");
|
const streamingQualitySetting = document.getElementById('streaming-quality-setting');
|
||||||
if (streamingQualitySetting) {
|
if (streamingQualitySetting) {
|
||||||
const savedQuality = localStorage.getItem("playback-quality") || "LOSSLESS";
|
const savedQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
|
||||||
streamingQualitySetting.value = savedQuality;
|
streamingQualitySetting.value = savedQuality;
|
||||||
player.setQuality(savedQuality);
|
player.setQuality(savedQuality);
|
||||||
|
|
||||||
streamingQualitySetting.addEventListener("change", (e) => {
|
streamingQualitySetting.addEventListener('change', (e) => {
|
||||||
const newQuality = e.target.value;
|
const newQuality = e.target.value;
|
||||||
player.setQuality(newQuality);
|
player.setQuality(newQuality);
|
||||||
localStorage.setItem("playback-quality", newQuality);
|
localStorage.setItem('playback-quality', newQuality);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download Quality setting
|
// Download Quality setting
|
||||||
const downloadQualitySetting = document.getElementById("download-quality-setting");
|
const downloadQualitySetting = document.getElementById('download-quality-setting');
|
||||||
if (downloadQualitySetting) {
|
if (downloadQualitySetting) {
|
||||||
downloadQualitySetting.value = downloadQualitySettings.getQuality();
|
downloadQualitySetting.value = downloadQualitySettings.getQuality();
|
||||||
|
|
||||||
downloadQualitySetting.addEventListener("change", (e) => {
|
downloadQualitySetting.addEventListener('change', (e) => {
|
||||||
downloadQualitySettings.setQuality(e.target.value);
|
downloadQualitySettings.setQuality(e.target.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplayGain Settings
|
// ReplayGain Settings
|
||||||
const replayGainMode = document.getElementById("replay-gain-mode");
|
const replayGainMode = document.getElementById('replay-gain-mode');
|
||||||
if (replayGainMode) {
|
if (replayGainMode) {
|
||||||
replayGainMode.value = replayGainSettings.getMode();
|
replayGainMode.value = replayGainSettings.getMode();
|
||||||
replayGainMode.addEventListener("change", (e) => {
|
replayGainMode.addEventListener('change', (e) => {
|
||||||
replayGainSettings.setMode(e.target.value);
|
replayGainSettings.setMode(e.target.value);
|
||||||
player.applyReplayGain();
|
player.applyReplayGain();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const replayGainPreamp = document.getElementById("replay-gain-preamp");
|
const replayGainPreamp = document.getElementById('replay-gain-preamp');
|
||||||
if (replayGainPreamp) {
|
if (replayGainPreamp) {
|
||||||
replayGainPreamp.value = replayGainSettings.getPreamp();
|
replayGainPreamp.value = replayGainSettings.getPreamp();
|
||||||
replayGainPreamp.addEventListener("change", (e) => {
|
replayGainPreamp.addEventListener('change', (e) => {
|
||||||
replayGainSettings.setPreamp(parseFloat(e.target.value) || 3);
|
replayGainSettings.setPreamp(parseFloat(e.target.value) || 3);
|
||||||
player.applyReplayGain();
|
player.applyReplayGain();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now Playing Mode
|
// Now Playing Mode
|
||||||
const nowPlayingMode = document.getElementById("now-playing-mode");
|
const nowPlayingMode = document.getElementById('now-playing-mode');
|
||||||
if (nowPlayingMode) {
|
if (nowPlayingMode) {
|
||||||
nowPlayingMode.value = nowPlayingSettings.getMode();
|
nowPlayingMode.value = nowPlayingSettings.getMode();
|
||||||
nowPlayingMode.addEventListener("change", (e) => {
|
nowPlayingMode.addEventListener('change', (e) => {
|
||||||
nowPlayingSettings.setMode(e.target.value);
|
nowPlayingSettings.setMode(e.target.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track List Actions Mode
|
// Track List Actions Mode
|
||||||
const trackListActionsMode = document.getElementById(
|
const trackListActionsMode = document.getElementById('track-list-actions-mode');
|
||||||
"track-list-actions-mode",
|
|
||||||
);
|
|
||||||
if (trackListActionsMode) {
|
if (trackListActionsMode) {
|
||||||
trackListActionsMode.value = trackListSettings.getMode();
|
trackListActionsMode.value = trackListSettings.getMode();
|
||||||
trackListActionsMode.addEventListener("change", (e) => {
|
trackListActionsMode.addEventListener('change', (e) => {
|
||||||
trackListSettings.setMode(e.target.value);
|
trackListSettings.setMode(e.target.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact Artist Toggle
|
// Compact Artist Toggle
|
||||||
const compactArtistToggle = document.getElementById("compact-artist-toggle");
|
const compactArtistToggle = document.getElementById('compact-artist-toggle');
|
||||||
if (compactArtistToggle) {
|
if (compactArtistToggle) {
|
||||||
compactArtistToggle.checked = cardSettings.isCompactArtist();
|
compactArtistToggle.checked = cardSettings.isCompactArtist();
|
||||||
compactArtistToggle.addEventListener("change", (e) => {
|
compactArtistToggle.addEventListener('change', (e) => {
|
||||||
cardSettings.setCompactArtist(e.target.checked);
|
cardSettings.setCompactArtist(e.target.checked);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact Album Toggle
|
// Compact Album Toggle
|
||||||
const compactAlbumToggle = document.getElementById("compact-album-toggle");
|
const compactAlbumToggle = document.getElementById('compact-album-toggle');
|
||||||
if (compactAlbumToggle) {
|
if (compactAlbumToggle) {
|
||||||
compactAlbumToggle.checked = cardSettings.isCompactAlbum();
|
compactAlbumToggle.checked = cardSettings.isCompactAlbum();
|
||||||
compactAlbumToggle.addEventListener("change", (e) => {
|
compactAlbumToggle.addEventListener('change', (e) => {
|
||||||
cardSettings.setCompactAlbum(e.target.checked);
|
cardSettings.setCompactAlbum(e.target.checked);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download Lyrics Toggle
|
// Download Lyrics Toggle
|
||||||
const downloadLyricsToggle = document.getElementById(
|
const downloadLyricsToggle = document.getElementById('download-lyrics-toggle');
|
||||||
"download-lyrics-toggle",
|
|
||||||
);
|
|
||||||
if (downloadLyricsToggle) {
|
if (downloadLyricsToggle) {
|
||||||
downloadLyricsToggle.checked = lyricsSettings.shouldDownloadLyrics();
|
downloadLyricsToggle.checked = lyricsSettings.shouldDownloadLyrics();
|
||||||
downloadLyricsToggle.addEventListener("change", (e) => {
|
downloadLyricsToggle.addEventListener('change', (e) => {
|
||||||
lyricsSettings.setDownloadLyrics(e.target.checked);
|
lyricsSettings.setDownloadLyrics(e.target.checked);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Romaji Lyrics Toggle
|
// Romaji Lyrics Toggle
|
||||||
const romajiLyricsToggle = document.getElementById("romaji-lyrics-toggle");
|
const romajiLyricsToggle = document.getElementById('romaji-lyrics-toggle');
|
||||||
if (romajiLyricsToggle) {
|
if (romajiLyricsToggle) {
|
||||||
romajiLyricsToggle.checked =
|
romajiLyricsToggle.checked = localStorage.getItem('lyricsRomajiMode') === 'true';
|
||||||
localStorage.getItem("lyricsRomajiMode") === "true";
|
romajiLyricsToggle.addEventListener('change', (e) => {
|
||||||
romajiLyricsToggle.addEventListener("change", (e) => {
|
localStorage.setItem('lyricsRomajiMode', e.target.checked ? 'true' : 'false');
|
||||||
localStorage.setItem(
|
|
||||||
"lyricsRomajiMode",
|
|
||||||
e.target.checked ? "true" : "false",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album Background Toggle
|
// Album Background Toggle
|
||||||
const albumBackgroundToggle = document.getElementById(
|
const albumBackgroundToggle = document.getElementById('album-background-toggle');
|
||||||
"album-background-toggle",
|
|
||||||
);
|
|
||||||
if (albumBackgroundToggle) {
|
if (albumBackgroundToggle) {
|
||||||
albumBackgroundToggle.checked = backgroundSettings.isEnabled();
|
albumBackgroundToggle.checked = backgroundSettings.isEnabled();
|
||||||
albumBackgroundToggle.addEventListener("change", (e) => {
|
albumBackgroundToggle.addEventListener('change', (e) => {
|
||||||
backgroundSettings.setEnabled(e.target.checked);
|
backgroundSettings.setEnabled(e.target.checked);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Waveform Toggle
|
// Waveform Toggle
|
||||||
const waveformToggle = document.getElementById("waveform-toggle");
|
const waveformToggle = document.getElementById('waveform-toggle');
|
||||||
if (waveformToggle) {
|
if (waveformToggle) {
|
||||||
waveformToggle.checked = waveformSettings.isEnabled();
|
waveformToggle.checked = waveformSettings.isEnabled();
|
||||||
waveformToggle.addEventListener("change", (e) => {
|
waveformToggle.addEventListener('change', (e) => {
|
||||||
waveformSettings.setEnabled(e.target.checked);
|
waveformSettings.setEnabled(e.target.checked);
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent("waveform-toggle", { detail: { enabled: e.target.checked } }));
|
window.dispatchEvent(new CustomEvent('waveform-toggle', { detail: { enabled: e.target.checked } }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smooth Scrolling Toggle
|
// Smooth Scrolling Toggle
|
||||||
const smoothScrollingToggle = document.getElementById("smooth-scrolling-toggle");
|
const smoothScrollingToggle = document.getElementById('smooth-scrolling-toggle');
|
||||||
if (smoothScrollingToggle) {
|
if (smoothScrollingToggle) {
|
||||||
smoothScrollingToggle.checked = smoothScrollingSettings.isEnabled();
|
smoothScrollingToggle.checked = smoothScrollingSettings.isEnabled();
|
||||||
smoothScrollingToggle.addEventListener("change", (e) => {
|
smoothScrollingToggle.addEventListener('change', (e) => {
|
||||||
smoothScrollingSettings.setEnabled(e.target.checked);
|
smoothScrollingSettings.setEnabled(e.target.checked);
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent("smooth-scrolling-toggle", { detail: { enabled: e.target.checked } }));
|
window.dispatchEvent(new CustomEvent('smooth-scrolling-toggle', { detail: { enabled: e.target.checked } }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filename template setting
|
// Filename template setting
|
||||||
const filenameTemplate = document.getElementById("filename-template");
|
const filenameTemplate = document.getElementById('filename-template');
|
||||||
if (filenameTemplate) {
|
if (filenameTemplate) {
|
||||||
filenameTemplate.value =
|
filenameTemplate.value = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}';
|
||||||
localStorage.getItem("filename-template") ||
|
filenameTemplate.addEventListener('change', (e) => {
|
||||||
"{trackNumber} - {artist} - {title}";
|
localStorage.setItem('filename-template', e.target.value);
|
||||||
filenameTemplate.addEventListener("change", (e) => {
|
|
||||||
localStorage.setItem("filename-template", e.target.value);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZIP folder template
|
// ZIP folder template
|
||||||
const zipFolderTemplate = document.getElementById("zip-folder-template");
|
const zipFolderTemplate = document.getElementById('zip-folder-template');
|
||||||
if (zipFolderTemplate) {
|
if (zipFolderTemplate) {
|
||||||
zipFolderTemplate.value =
|
zipFolderTemplate.value = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}';
|
||||||
localStorage.getItem("zip-folder-template") ||
|
zipFolderTemplate.addEventListener('change', (e) => {
|
||||||
"{albumTitle} - {albumArtist}";
|
localStorage.setItem('zip-folder-template', e.target.value);
|
||||||
zipFolderTemplate.addEventListener("change", (e) => {
|
|
||||||
localStorage.setItem("zip-folder-template", e.target.value);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// API settings
|
// API settings
|
||||||
document
|
document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => {
|
||||||
.getElementById("refresh-speed-test-btn")
|
const btn = document.getElementById('refresh-speed-test-btn');
|
||||||
?.addEventListener("click", async () => {
|
|
||||||
const btn = document.getElementById("refresh-speed-test-btn");
|
|
||||||
const originalText = btn.textContent;
|
const originalText = btn.textContent;
|
||||||
btn.textContent = "Testing...";
|
btn.textContent = 'Testing...';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.settings.refreshSpeedTests();
|
await api.settings.refreshSpeedTests();
|
||||||
ui.renderApiSettings();
|
ui.renderApiSettings();
|
||||||
btn.textContent = "Done!";
|
btn.textContent = 'Done!';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.textContent = originalText;
|
btn.textContent = originalText;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to refresh speed tests:", error);
|
console.error('Failed to refresh speed tests:', error);
|
||||||
btn.textContent = "Error";
|
btn.textContent = 'Error';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.textContent = originalText;
|
btn.textContent = originalText;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|
@ -448,58 +421,45 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document
|
document.getElementById('api-instance-list')?.addEventListener('click', async (e) => {
|
||||||
.getElementById("api-instance-list")
|
const button = e.target.closest('button');
|
||||||
?.addEventListener("click", async (e) => {
|
|
||||||
const button = e.target.closest("button");
|
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
|
||||||
const li = button.closest("li");
|
const li = button.closest('li');
|
||||||
const index = parseInt(li.dataset.index, 10);
|
const index = parseInt(li.dataset.index, 10);
|
||||||
const type = li.dataset.type || "api"; // Default to api if not present
|
const type = li.dataset.type || 'api'; // Default to api if not present
|
||||||
|
|
||||||
const instances = await api.settings.getInstances(type);
|
const instances = await api.settings.getInstances(type);
|
||||||
|
|
||||||
if (button.classList.contains("move-up") && index > 0) {
|
if (button.classList.contains('move-up') && index > 0) {
|
||||||
[instances[index], instances[index - 1]] = [
|
[instances[index], instances[index - 1]] = [instances[index - 1], instances[index]];
|
||||||
instances[index - 1],
|
} else if (button.classList.contains('move-down') && index < instances.length - 1) {
|
||||||
instances[index],
|
[instances[index], instances[index + 1]] = [instances[index + 1], instances[index]];
|
||||||
];
|
|
||||||
} else if (
|
|
||||||
button.classList.contains("move-down") &&
|
|
||||||
index < instances.length - 1
|
|
||||||
) {
|
|
||||||
[instances[index], instances[index + 1]] = [
|
|
||||||
instances[index + 1],
|
|
||||||
instances[index],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
api.settings.saveInstances(instances, type);
|
api.settings.saveInstances(instances, type);
|
||||||
ui.renderApiSettings();
|
ui.renderApiSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
document
|
document.getElementById('clear-cache-btn')?.addEventListener('click', async () => {
|
||||||
.getElementById("clear-cache-btn")
|
const btn = document.getElementById('clear-cache-btn');
|
||||||
?.addEventListener("click", async () => {
|
|
||||||
const btn = document.getElementById("clear-cache-btn");
|
|
||||||
const originalText = btn.textContent;
|
const originalText = btn.textContent;
|
||||||
btn.textContent = "Clearing...";
|
btn.textContent = 'Clearing...';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.clearCache();
|
await api.clearCache();
|
||||||
btn.textContent = "Cleared!";
|
btn.textContent = 'Cleared!';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.textContent = originalText;
|
btn.textContent = originalText;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
if (window.location.hash.includes("settings")) {
|
if (window.location.hash.includes('settings')) {
|
||||||
ui.renderApiSettings();
|
ui.renderApiSettings();
|
||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to clear cache:", error);
|
console.error('Failed to clear cache:', error);
|
||||||
btn.textContent = "Error";
|
btn.textContent = 'Error';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.textContent = originalText;
|
btn.textContent = originalText;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|
@ -507,49 +467,39 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document
|
document.getElementById('firebase-clear-cloud-btn')?.addEventListener('click', async () => {
|
||||||
.getElementById("firebase-clear-cloud-btn")
|
if (confirm('Are you sure you want to delete ALL your data from the cloud? This cannot be undone.')) {
|
||||||
?.addEventListener("click", async () => {
|
|
||||||
if (
|
|
||||||
confirm(
|
|
||||||
"Are you sure you want to delete ALL your data from the cloud? This cannot be undone.",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
await syncManager.clearCloudData();
|
await syncManager.clearCloudData();
|
||||||
alert("Cloud data cleared successfully.");
|
alert('Cloud data cleared successfully.');
|
||||||
authManager.signOut();
|
authManager.signOut();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to clear cloud data:", error);
|
console.error('Failed to clear cloud data:', error);
|
||||||
alert("Failed to clear cloud data: " + error.message);
|
alert('Failed to clear cloud data: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Backup & Restore
|
// Backup & Restore
|
||||||
document
|
document.getElementById('export-library-btn')?.addEventListener('click', async () => {
|
||||||
.getElementById("export-library-btn")
|
|
||||||
?.addEventListener("click", async () => {
|
|
||||||
const data = await db.exportData();
|
const data = await db.exportData();
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||||
type: "application/json",
|
type: 'application/json',
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `monochrome-library-${new Date().toISOString().split("T")[0]}.json`;
|
a.download = `monochrome-library-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
const importInput = document.getElementById("import-library-input");
|
const importInput = document.getElementById('import-library-input');
|
||||||
document
|
document.getElementById('import-library-btn')?.addEventListener('click', () => {
|
||||||
.getElementById("import-library-btn")
|
|
||||||
?.addEventListener("click", () => {
|
|
||||||
importInput.click();
|
importInput.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
importInput?.addEventListener("change", async (e) => {
|
importInput?.addEventListener('change', async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
|
@ -558,11 +508,11 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.target.result);
|
const data = JSON.parse(event.target.result);
|
||||||
await db.importData(data);
|
await db.importData(data);
|
||||||
alert("Library imported successfully!");
|
alert('Library imported successfully!');
|
||||||
window.location.reload(); // Simple way to refresh all state
|
window.location.reload(); // Simple way to refresh all state
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Import failed:", err);
|
console.error('Import failed:', err);
|
||||||
alert("Failed to import library. Please check the file format.");
|
alert('Failed to import library. Please check the file format.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//js/smooth-scrolling.js
|
//js/smooth-scrolling.js
|
||||||
import { smoothScrollingSettings } from "./storage.js";
|
import { smoothScrollingSettings } from './storage.js';
|
||||||
|
|
||||||
let lenis = null;
|
let lenis = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//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: [] },
|
||||||
|
|
@ -53,20 +53,17 @@ export const apiSettings = {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load instances from GitHub:', error);
|
console.error('Failed to load instances from GitHub:', error);
|
||||||
this.defaultInstances = {
|
this.defaultInstances = {
|
||||||
api: [
|
api: ['https://tidal-api.binimum.org', 'https://monochrome-api.samidy.com'],
|
||||||
"https://tidal-api.binimum.org",
|
|
||||||
"https://monochrome-api.samidy.com"
|
|
||||||
],
|
|
||||||
streaming: [
|
streaming: [
|
||||||
"https://triton.squid.wtf",
|
'https://triton.squid.wtf',
|
||||||
"https://wolf.qqdl.site",
|
'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',
|
||||||
"https://tidal.kinoplus.online",
|
'https://tidal.kinoplus.online',
|
||||||
"https://tidal-api.binimum.org"
|
'https://tidal-api.binimum.org',
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
this.instancesLoaded = true;
|
this.instancesLoaded = true;
|
||||||
return this.defaultInstances;
|
return this.defaultInstances;
|
||||||
|
|
@ -95,7 +92,7 @@ export const apiSettings = {
|
||||||
|
|
||||||
const response = await fetch(testUrl, {
|
const response = await fetch(testUrl, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
cache: 'no-store'
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
@ -133,7 +130,7 @@ export const apiSettings = {
|
||||||
updateSpeedCache(newResults) {
|
updateSpeedCache(newResults) {
|
||||||
const currentCache = this.getCachedSpeedTests();
|
const currentCache = this.getCachedSpeedTests();
|
||||||
|
|
||||||
newResults.forEach(r => {
|
newResults.forEach((r) => {
|
||||||
// Use distinct keys for streaming tests to avoid overwriting API tests for same URL
|
// 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)
|
// API tests use raw URL as key (for backward compatibility with UI)
|
||||||
const key = r.type === 'streaming' ? `${r.url}#streaming` : r.url;
|
const key = r.type === 'streaming' ? `${r.url}#streaming` : r.url;
|
||||||
|
|
@ -155,12 +152,13 @@ export const apiSettings = {
|
||||||
if (!urls || urls.length === 0) return [];
|
if (!urls || urls.length === 0) return [];
|
||||||
console.log(`[SpeedTest] Testing ${urls.length} instances for ${type}...`);
|
console.log(`[SpeedTest] Testing ${urls.length} instances for ${type}...`);
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(urls.map((url) => this.speedTestInstance(url, type)));
|
||||||
urls.map(url => this.speedTestInstance(url, type))
|
|
||||||
);
|
|
||||||
|
|
||||||
const validResults = results.filter(r => r.speed !== Infinity);
|
const validResults = results.filter((r) => r.speed !== Infinity);
|
||||||
console.log(`[SpeedTest] ${type} Results:`, validResults.map(r => `${r.url}: ${r.speed.toFixed(0)}ms`));
|
console.log(
|
||||||
|
`[SpeedTest] ${type} Results:`,
|
||||||
|
validResults.map((r) => `${r.url}: ${r.speed.toFixed(0)}ms`)
|
||||||
|
);
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
},
|
},
|
||||||
|
|
@ -184,9 +182,9 @@ export const apiSettings = {
|
||||||
|
|
||||||
const speedCache = this.getCachedSpeedTests();
|
const speedCache = this.getCachedSpeedTests();
|
||||||
// Construct cache key based on type
|
// Construct cache key based on type
|
||||||
const getCacheKey = (u) => type === 'streaming' ? `${u}#streaming` : u;
|
const getCacheKey = (u) => (type === 'streaming' ? `${u}#streaming` : u);
|
||||||
|
|
||||||
const urlsToTest = targetUrls.filter(url => !speedCache.speeds[getCacheKey(url)]);
|
const urlsToTest = targetUrls.filter((url) => !speedCache.speeds[getCacheKey(url)]);
|
||||||
|
|
||||||
if (urlsToTest.length > 0) {
|
if (urlsToTest.length > 0) {
|
||||||
const results = await this.testSpecificUrls(urlsToTest, type);
|
const results = await this.testSpecificUrls(urlsToTest, type);
|
||||||
|
|
@ -239,12 +237,13 @@ export const apiSettings = {
|
||||||
fullObj[type] = instances;
|
fullObj[type] = instances;
|
||||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(fullObj));
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(fullObj));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save instances:", e);
|
console.error('Failed to save instances:', e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(instances));
|
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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,10 +193,11 @@ export function initializeUIInteractions(player, api) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = currentQueue.map((track, index) => {
|
const html = currentQueue
|
||||||
|
.map((track, index) => {
|
||||||
const isPlaying = index === player.currentQueueIndex;
|
const isPlaying = index === player.currentQueueIndex;
|
||||||
const trackTitle = getTrackTitle(track);
|
const trackTitle = getTrackTitle(track);
|
||||||
const trackArtists = getTrackArtists(track, { fallback: "Unknown" });
|
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">
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
340
js/ui.js
340
js/ui.js
|
|
@ -1,5 +1,19 @@
|
||||||
//js/ui.js
|
//js/ui.js
|
||||||
import { SVG_PLAY, SVG_DOWNLOAD, SVG_MENU, SVG_HEART, formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration, escapeHtml } from './utils.js';
|
import {
|
||||||
|
SVG_PLAY,
|
||||||
|
SVG_DOWNLOAD,
|
||||||
|
SVG_MENU,
|
||||||
|
SVG_HEART,
|
||||||
|
formatTime,
|
||||||
|
createPlaceholder,
|
||||||
|
trackDataStore,
|
||||||
|
hasExplicitContent,
|
||||||
|
getTrackArtists,
|
||||||
|
getTrackTitle,
|
||||||
|
calculateTotalDuration,
|
||||||
|
formatDuration,
|
||||||
|
escapeHtml,
|
||||||
|
} from './utils.js';
|
||||||
import { openLyricsPanel } from './lyrics.js';
|
import { openLyricsPanel } from './lyrics.js';
|
||||||
import { recentActivityManager, backgroundSettings, trackListSettings, cardSettings } from './storage.js';
|
import { recentActivityManager, backgroundSettings, trackListSettings, cardSettings } from './storage.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
|
|
@ -122,8 +136,6 @@ export class UIRenderer {
|
||||||
return '<span class="explicit-badge" title="Explicit">E</span>';
|
return '<span class="explicit-badge" title="Explicit">E</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
adjustTitleFontSize(element, text) {
|
adjustTitleFontSize(element, text) {
|
||||||
element.classList.remove('long-title', 'very-long-title');
|
element.classList.remove('long-title', 'very-long-title');
|
||||||
if (text.length > 40) {
|
if (text.length > 40) {
|
||||||
|
|
@ -135,7 +147,9 @@ export class UIRenderer {
|
||||||
|
|
||||||
createTrackItemHTML(track, index, showCover = false, hasMultipleDiscs = false) {
|
createTrackItemHTML(track, index, showCover = false, hasMultipleDiscs = false) {
|
||||||
const playIconSmall = SVG_PLAY;
|
const playIconSmall = SVG_PLAY;
|
||||||
const trackImageHTML = showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover)}" alt="Track Cover" class="track-item-cover" loading="lazy">` : '';
|
const trackImageHTML = showCover
|
||||||
|
? `<img src="${this.api.getCoverUrl(track.album?.cover)}" alt="Track Cover" class="track-item-cover" loading="lazy">`
|
||||||
|
: '';
|
||||||
|
|
||||||
let displayIndex;
|
let displayIndex;
|
||||||
if (hasMultipleDiscs && !showCover) {
|
if (hasMultipleDiscs && !showCover) {
|
||||||
|
|
@ -212,14 +226,28 @@ export class UIRenderer {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
createBaseCardHTML({ type, id, href, title, subtitle, imageHTML, actionButtonsHTML, isCompact, extraClasses = '' }) {
|
createBaseCardHTML({
|
||||||
const playBtnHTML = type !== 'artist' ? `
|
type,
|
||||||
|
id,
|
||||||
|
href,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
imageHTML,
|
||||||
|
actionButtonsHTML,
|
||||||
|
isCompact,
|
||||||
|
extraClasses = '',
|
||||||
|
}) {
|
||||||
|
const playBtnHTML =
|
||||||
|
type !== 'artist'
|
||||||
|
? `
|
||||||
<button class="play-btn card-play-btn" data-action="play-card" data-type="${type}" data-id="${id}" title="Play">
|
<button class="play-btn card-play-btn" data-action="play-card" data-type="${type}" data-id="${id}" title="Play">
|
||||||
${SVG_PLAY}
|
${SVG_PLAY}
|
||||||
</button>
|
</button>
|
||||||
` : '';
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
const cardContent = type === 'artist'
|
const cardContent =
|
||||||
|
type === 'artist'
|
||||||
? `<h4 class="card-title">${title}</h4>`
|
? `<h4 class="card-title">${title}</h4>`
|
||||||
: `<div class="card-info">
|
: `<div class="card-info">
|
||||||
<h4 class="card-title">${title}</h4>
|
<h4 class="card-title">${title}</h4>
|
||||||
|
|
@ -259,7 +287,7 @@ export class UIRenderer {
|
||||||
${this.createHeartIcon(false)}
|
${this.createHeartIcon(false)}
|
||||||
</button>
|
</button>
|
||||||
`,
|
`,
|
||||||
isCompact
|
isCompact,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,7 +308,7 @@ export class UIRenderer {
|
||||||
${this.createHeartIcon(false)}
|
${this.createHeartIcon(false)}
|
||||||
</button>
|
</button>
|
||||||
`,
|
`,
|
||||||
isCompact
|
isCompact,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,7 +338,7 @@ export class UIRenderer {
|
||||||
const covers = uniqueCovers.slice(0, 4);
|
const covers = uniqueCovers.slice(0, 4);
|
||||||
imageHTML = `
|
imageHTML = `
|
||||||
<div class="card-image card-collage ${itemsClass}">
|
<div class="card-image card-collage ${itemsClass}">
|
||||||
${covers.map(cover => `<img src="${this.api.getCoverUrl(cover)}" alt="" loading="lazy">`).join('')}
|
${covers.map((cover) => `<img src="${this.api.getCoverUrl(cover)}" alt="" loading="lazy">`).join('')}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else if (uniqueCovers.length > 0) {
|
} else if (uniqueCovers.length > 0) {
|
||||||
|
|
@ -328,7 +356,7 @@ export class UIRenderer {
|
||||||
id: playlist.id,
|
id: playlist.id,
|
||||||
href: `#userplaylist/${playlist.id}`,
|
href: `#userplaylist/${playlist.id}`,
|
||||||
title: escapeHtml(playlist.name),
|
title: escapeHtml(playlist.name),
|
||||||
subtitle: `${playlist.tracks ? playlist.tracks.length : (playlist.numberOfTracks || 0)} tracks`,
|
subtitle: `${playlist.tracks ? playlist.tracks.length : playlist.numberOfTracks || 0} tracks`,
|
||||||
imageHTML: imageHTML,
|
imageHTML: imageHTML,
|
||||||
actionButtonsHTML: `
|
actionButtonsHTML: `
|
||||||
<button class="edit-playlist-btn" data-action="edit-playlist" title="Edit Playlist">
|
<button class="edit-playlist-btn" data-action="edit-playlist" title="Edit Playlist">
|
||||||
|
|
@ -348,7 +376,7 @@ export class UIRenderer {
|
||||||
</button>
|
</button>
|
||||||
`,
|
`,
|
||||||
isCompact,
|
isCompact,
|
||||||
extraClasses: 'user-playlist'
|
extraClasses: 'user-playlist',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,7 +406,7 @@ export class UIRenderer {
|
||||||
${this.createHeartIcon(false)}
|
${this.createHeartIcon(false)}
|
||||||
</button>
|
</button>
|
||||||
`,
|
`,
|
||||||
isCompact
|
isCompact,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,7 +426,7 @@ export class UIRenderer {
|
||||||
</button>
|
</button>
|
||||||
`,
|
`,
|
||||||
isCompact,
|
isCompact,
|
||||||
extraClasses: 'artist'
|
extraClasses: 'artist',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -428,11 +456,17 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
createSkeletonTracks(count = 5, showCover = false) {
|
createSkeletonTracks(count = 5, showCover = false) {
|
||||||
return `<div class="skeleton-container">${Array(count).fill(0).map(() => this.createSkeletonTrack(showCover)).join('')}</div>`;
|
return `<div class="skeleton-container">${Array(count)
|
||||||
|
.fill(0)
|
||||||
|
.map(() => this.createSkeletonTrack(showCover))
|
||||||
|
.join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
createSkeletonCards(count = 6, isArtist = false) {
|
createSkeletonCards(count = 6, isArtist = false) {
|
||||||
return `<div class="card-grid">${Array(count).fill(0).map(() => this.createSkeletonCard(isArtist)).join('')}</div>`;
|
return `<div class="card-grid">${Array(count)
|
||||||
|
.fill(0)
|
||||||
|
.map(() => this.createSkeletonCard(isArtist))
|
||||||
|
.join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderListWithTracks(container, tracks, showCover) {
|
renderListWithTracks(container, tracks, showCover) {
|
||||||
|
|
@ -440,11 +474,11 @@ export class UIRenderer {
|
||||||
const tempDiv = document.createElement('div');
|
const tempDiv = document.createElement('div');
|
||||||
|
|
||||||
// Check if there are multiple discs in the tracks array
|
// Check if there are multiple discs in the tracks array
|
||||||
const hasMultipleDiscs = tracks.some(t => (t.volumeNumber || t.discNumber || 1) > 1);
|
const hasMultipleDiscs = tracks.some((t) => (t.volumeNumber || t.discNumber || 1) > 1);
|
||||||
|
|
||||||
tempDiv.innerHTML = tracks.map((track, i) =>
|
tempDiv.innerHTML = tracks
|
||||||
this.createTrackItemHTML(track, i, showCover, hasMultipleDiscs)
|
.map((track, i) => this.createTrackItemHTML(track, i, showCover, hasMultipleDiscs))
|
||||||
).join('');
|
.join('');
|
||||||
|
|
||||||
while (tempDiv.firstChild) {
|
while (tempDiv.firstChild) {
|
||||||
fragment.appendChild(tempDiv.firstChild);
|
fragment.appendChild(tempDiv.firstChild);
|
||||||
|
|
@ -453,7 +487,7 @@ export class UIRenderer {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
container.appendChild(fragment);
|
container.appendChild(fragment);
|
||||||
|
|
||||||
tracks.forEach(track => {
|
tracks.forEach((track) => {
|
||||||
const element = container.querySelector(`[data-track-id="${track.id}"]`);
|
const element = container.querySelector(`[data-track-id="${track.id}"]`);
|
||||||
if (element) {
|
if (element) {
|
||||||
trackDataStore.set(element, track);
|
trackDataStore.set(element, track);
|
||||||
|
|
@ -491,7 +525,10 @@ export class UIRenderer {
|
||||||
let hex = color.replace('#', '');
|
let hex = color.replace('#', '');
|
||||||
// Handle shorthand hex
|
// Handle shorthand hex
|
||||||
if (hex.length === 3) {
|
if (hex.length === 3) {
|
||||||
hex = hex.split('').map(char => char + char).join('');
|
hex = hex
|
||||||
|
.split('')
|
||||||
|
.map((char) => char + char)
|
||||||
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
let r = parseInt(hex.substr(0, 2), 16);
|
let r = parseInt(hex.substr(0, 2), 16);
|
||||||
|
|
@ -499,7 +536,7 @@ export class UIRenderer {
|
||||||
let b = parseInt(hex.substr(4, 2), 16);
|
let b = parseInt(hex.substr(4, 2), 16);
|
||||||
|
|
||||||
// Calculate perceived brightness
|
// Calculate perceived brightness
|
||||||
let brightness = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
let brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
|
||||||
if (isLightMode) {
|
if (isLightMode) {
|
||||||
// In light mode, the background is white.
|
// In light mode, the background is white.
|
||||||
|
|
@ -509,7 +546,7 @@ export class UIRenderer {
|
||||||
r = Math.floor(r * 0.9);
|
r = Math.floor(r * 0.9);
|
||||||
g = Math.floor(g * 0.9);
|
g = Math.floor(g * 0.9);
|
||||||
b = Math.floor(b * 0.9);
|
b = Math.floor(b * 0.9);
|
||||||
brightness = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// In dark mode, the background is dark.
|
// In dark mode, the background is dark.
|
||||||
|
|
@ -519,7 +556,7 @@ export class UIRenderer {
|
||||||
r = Math.min(255, Math.max(r + 1, Math.floor(r * 1.15)));
|
r = Math.min(255, Math.max(r + 1, Math.floor(r * 1.15)));
|
||||||
g = Math.min(255, Math.max(g + 1, Math.floor(g * 1.15)));
|
g = Math.min(255, Math.max(g + 1, Math.floor(g * 1.15)));
|
||||||
b = Math.min(255, Math.max(b + 1, Math.floor(b * 1.15)));
|
b = Math.min(255, Math.max(b + 1, Math.floor(b * 1.15)));
|
||||||
brightness = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
// Break if we hit white or can't get brighter to avoid infinite loop
|
// Break if we hit white or can't get brighter to avoid infinite loop
|
||||||
if (r >= 255 && g >= 255 && b >= 255) break;
|
if (r >= 255 && g >= 255 && b >= 255) break;
|
||||||
}
|
}
|
||||||
|
|
@ -583,7 +620,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
|
|
||||||
nextTrackEl.classList.remove('animate-in');
|
nextTrackEl.classList.remove('animate-in');
|
||||||
void nextTrackEl.offsetWidth;
|
void nextTrackEl.offsetWidth;
|
||||||
nextTrackEl.classList.add('animate-in'); } else {
|
nextTrackEl.classList.add('animate-in');
|
||||||
|
} else {
|
||||||
nextTrackEl.style.display = 'none';
|
nextTrackEl.style.display = 'none';
|
||||||
nextTrackEl.classList.remove('animate-in');
|
nextTrackEl.classList.remove('animate-in');
|
||||||
}
|
}
|
||||||
|
|
@ -615,11 +653,11 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
showPage(pageId) {
|
showPage(pageId) {
|
||||||
document.querySelectorAll('.page').forEach(page => {
|
document.querySelectorAll('.page').forEach((page) => {
|
||||||
page.classList.toggle('active', page.id === `page-${pageId}`);
|
page.classList.toggle('active', page.id === `page-${pageId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.sidebar-nav a').forEach(link => {
|
document.querySelectorAll('.sidebar-nav a').forEach((link) => {
|
||||||
link.classList.toggle('active', link.hash === `#${pageId}`);
|
link.classList.toggle('active', link.hash === `#${pageId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -653,8 +691,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
|
|
||||||
const likedAlbums = await db.getFavorites('album');
|
const likedAlbums = await db.getFavorites('album');
|
||||||
if (likedAlbums.length) {
|
if (likedAlbums.length) {
|
||||||
albumsContainer.innerHTML = likedAlbums.map(a => this.createAlbumCardHTML(a)).join('');
|
albumsContainer.innerHTML = likedAlbums.map((a) => this.createAlbumCardHTML(a)).join('');
|
||||||
likedAlbums.forEach(album => {
|
likedAlbums.forEach((album) => {
|
||||||
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, album);
|
trackDataStore.set(el, album);
|
||||||
|
|
@ -667,8 +705,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
|
|
||||||
const likedArtists = await db.getFavorites('artist');
|
const likedArtists = await db.getFavorites('artist');
|
||||||
if (likedArtists.length) {
|
if (likedArtists.length) {
|
||||||
artistsContainer.innerHTML = likedArtists.map(a => this.createArtistCardHTML(a)).join('');
|
artistsContainer.innerHTML = likedArtists.map((a) => this.createArtistCardHTML(a)).join('');
|
||||||
likedArtists.forEach(artist => {
|
likedArtists.forEach((artist) => {
|
||||||
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
|
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, artist);
|
trackDataStore.set(el, artist);
|
||||||
|
|
@ -683,20 +721,20 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const likedMixes = await db.getFavorites('mix');
|
const likedMixes = await db.getFavorites('mix');
|
||||||
|
|
||||||
let mixedContent = [];
|
let mixedContent = [];
|
||||||
if (likedPlaylists.length) mixedContent.push(...likedPlaylists.map(p => ({ ...p, _type: 'playlist' })));
|
if (likedPlaylists.length) mixedContent.push(...likedPlaylists.map((p) => ({ ...p, _type: 'playlist' })));
|
||||||
if (likedMixes.length) mixedContent.push(...likedMixes.map(m => ({ ...m, _type: 'mix' })));
|
if (likedMixes.length) mixedContent.push(...likedMixes.map((m) => ({ ...m, _type: 'mix' })));
|
||||||
|
|
||||||
// Sort by addedAt descending
|
// Sort by addedAt descending
|
||||||
mixedContent.sort((a, b) => b.addedAt - a.addedAt);
|
mixedContent.sort((a, b) => b.addedAt - a.addedAt);
|
||||||
|
|
||||||
if (mixedContent.length) {
|
if (mixedContent.length) {
|
||||||
playlistsContainer.innerHTML = mixedContent.map(item => {
|
playlistsContainer.innerHTML = mixedContent
|
||||||
return item._type === 'playlist'
|
.map((item) => {
|
||||||
? this.createPlaylistCardHTML(item)
|
return item._type === 'playlist' ? this.createPlaylistCardHTML(item) : this.createMixCardHTML(item);
|
||||||
: this.createMixCardHTML(item);
|
})
|
||||||
}).join('');
|
.join('');
|
||||||
|
|
||||||
likedPlaylists.forEach(playlist => {
|
likedPlaylists.forEach((playlist) => {
|
||||||
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
|
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, playlist);
|
trackDataStore.set(el, playlist);
|
||||||
|
|
@ -704,7 +742,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
likedMixes.forEach(mix => {
|
likedMixes.forEach((mix) => {
|
||||||
const el = playlistsContainer.querySelector(`[data-mix-id="${mix.id}"]`);
|
const el = playlistsContainer.querySelector(`[data-mix-id="${mix.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, mix);
|
trackDataStore.set(el, mix);
|
||||||
|
|
@ -718,8 +756,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const myPlaylistsContainer = document.getElementById('my-playlists-container');
|
const myPlaylistsContainer = document.getElementById('my-playlists-container');
|
||||||
const myPlaylists = await db.getPlaylists();
|
const myPlaylists = await db.getPlaylists();
|
||||||
if (myPlaylists.length) {
|
if (myPlaylists.length) {
|
||||||
myPlaylistsContainer.innerHTML = myPlaylists.map(p => this.createUserPlaylistCardHTML(p)).join('');
|
myPlaylistsContainer.innerHTML = myPlaylists.map((p) => this.createUserPlaylistCardHTML(p)).join('');
|
||||||
myPlaylists.forEach(playlist => {
|
myPlaylists.forEach((playlist) => {
|
||||||
const el = myPlaylistsContainer.querySelector(`[data-user-playlist-id="${playlist.id}"]`);
|
const el = myPlaylistsContainer.querySelector(`[data-user-playlist-id="${playlist.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, playlist);
|
trackDataStore.set(el, playlist);
|
||||||
|
|
@ -739,8 +777,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const playlistsContainer = document.getElementById('home-recent-playlists');
|
const playlistsContainer = document.getElementById('home-recent-playlists');
|
||||||
|
|
||||||
if (recents.albums.length) {
|
if (recents.albums.length) {
|
||||||
albumsContainer.innerHTML = recents.albums.map(album => this.createAlbumCardHTML(album)).join('');
|
albumsContainer.innerHTML = recents.albums.map((album) => this.createAlbumCardHTML(album)).join('');
|
||||||
recents.albums.forEach(album => {
|
recents.albums.forEach((album) => {
|
||||||
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, album);
|
trackDataStore.set(el, album);
|
||||||
|
|
@ -752,8 +790,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recents.artists.length) {
|
if (recents.artists.length) {
|
||||||
artistsContainer.innerHTML = recents.artists.map(artist => this.createArtistCardHTML(artist)).join('');
|
artistsContainer.innerHTML = recents.artists.map((artist) => this.createArtistCardHTML(artist)).join('');
|
||||||
recents.artists.forEach(artist => {
|
recents.artists.forEach((artist) => {
|
||||||
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
|
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, artist);
|
trackDataStore.set(el, artist);
|
||||||
|
|
@ -776,17 +814,20 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const combinedRecents = [...playlists, ...mixes]; // Order: Playlists then Mixes
|
const combinedRecents = [...playlists, ...mixes]; // Order: Playlists then Mixes
|
||||||
|
|
||||||
if (combinedRecents.length) {
|
if (combinedRecents.length) {
|
||||||
playlistsContainer.innerHTML = combinedRecents.map(item => {
|
playlistsContainer.innerHTML = combinedRecents
|
||||||
|
.map((item) => {
|
||||||
if (item.isUserPlaylist) {
|
if (item.isUserPlaylist) {
|
||||||
return this.createUserPlaylistCardHTML(item);
|
return this.createUserPlaylistCardHTML(item);
|
||||||
}
|
}
|
||||||
if (item.mixType) { // It's a mix
|
if (item.mixType) {
|
||||||
|
// It's a mix
|
||||||
return this.createMixCardHTML(item);
|
return this.createMixCardHTML(item);
|
||||||
}
|
}
|
||||||
return this.createPlaylistCardHTML(item);
|
return this.createPlaylistCardHTML(item);
|
||||||
}).join('');
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
combinedRecents.forEach(item => {
|
combinedRecents.forEach((item) => {
|
||||||
if (item.isUserPlaylist) {
|
if (item.isUserPlaylist) {
|
||||||
const el = playlistsContainer.querySelector(`[data-user-playlist-id="${item.id}"]`);
|
const el = playlistsContainer.querySelector(`[data-user-playlist-id="${item.id}"]`);
|
||||||
if (el) trackDataStore.set(el, item);
|
if (el) trackDataStore.set(el, item);
|
||||||
|
|
@ -835,7 +876,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
this.api.searchTracks(query, { signal }),
|
this.api.searchTracks(query, { signal }),
|
||||||
this.api.searchArtists(query, { signal }),
|
this.api.searchArtists(query, { signal }),
|
||||||
this.api.searchAlbums(query, { signal }),
|
this.api.searchAlbums(query, { signal }),
|
||||||
this.api.searchPlaylists(query, { signal })
|
this.api.searchPlaylists(query, { signal }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let finalTracks = tracksResult.items;
|
let finalTracks = tracksResult.items;
|
||||||
|
|
@ -845,12 +886,12 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
|
|
||||||
if (finalArtists.length === 0 && finalTracks.length > 0) {
|
if (finalArtists.length === 0 && finalTracks.length > 0) {
|
||||||
const artistMap = new Map();
|
const artistMap = new Map();
|
||||||
finalTracks.forEach(track => {
|
finalTracks.forEach((track) => {
|
||||||
if (track.artist && !artistMap.has(track.artist.id)) {
|
if (track.artist && !artistMap.has(track.artist.id)) {
|
||||||
artistMap.set(track.artist.id, track.artist);
|
artistMap.set(track.artist.id, track.artist);
|
||||||
}
|
}
|
||||||
if (track.artists) {
|
if (track.artists) {
|
||||||
track.artists.forEach(artist => {
|
track.artists.forEach((artist) => {
|
||||||
if (!artistMap.has(artist.id)) {
|
if (!artistMap.has(artist.id)) {
|
||||||
artistMap.set(artist.id, artist);
|
artistMap.set(artist.id, artist);
|
||||||
}
|
}
|
||||||
|
|
@ -862,7 +903,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
|
|
||||||
if (finalAlbums.length === 0 && finalTracks.length > 0) {
|
if (finalAlbums.length === 0 && finalTracks.length > 0) {
|
||||||
const albumMap = new Map();
|
const albumMap = new Map();
|
||||||
finalTracks.forEach(track => {
|
finalTracks.forEach((track) => {
|
||||||
if (track.album && !albumMap.has(track.album.id)) {
|
if (track.album && !albumMap.has(track.album.id)) {
|
||||||
albumMap.set(track.album.id, track.album);
|
albumMap.set(track.album.id, track.album);
|
||||||
}
|
}
|
||||||
|
|
@ -877,10 +918,10 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
artistsContainer.innerHTML = finalArtists.length
|
artistsContainer.innerHTML = finalArtists.length
|
||||||
? finalArtists.map(artist => this.createArtistCardHTML(artist)).join('')
|
? finalArtists.map((artist) => this.createArtistCardHTML(artist)).join('')
|
||||||
: createPlaceholder('No artists found.');
|
: createPlaceholder('No artists found.');
|
||||||
|
|
||||||
finalArtists.forEach(artist => {
|
finalArtists.forEach((artist) => {
|
||||||
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
|
const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, artist);
|
trackDataStore.set(el, artist);
|
||||||
|
|
@ -889,10 +930,10 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
});
|
});
|
||||||
|
|
||||||
albumsContainer.innerHTML = finalAlbums.length
|
albumsContainer.innerHTML = finalAlbums.length
|
||||||
? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('')
|
? finalAlbums.map((album) => this.createAlbumCardHTML(album)).join('')
|
||||||
: createPlaceholder('No albums found.');
|
: createPlaceholder('No albums found.');
|
||||||
|
|
||||||
finalAlbums.forEach(album => {
|
finalAlbums.forEach((album) => {
|
||||||
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, album);
|
trackDataStore.set(el, album);
|
||||||
|
|
@ -901,20 +942,19 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
});
|
});
|
||||||
|
|
||||||
playlistsContainer.innerHTML = finalPlaylists.length
|
playlistsContainer.innerHTML = finalPlaylists.length
|
||||||
? finalPlaylists.map(playlist => this.createPlaylistCardHTML(playlist)).join('')
|
? finalPlaylists.map((playlist) => this.createPlaylistCardHTML(playlist)).join('')
|
||||||
: createPlaceholder('No playlists found.');
|
: createPlaceholder('No playlists found.');
|
||||||
|
|
||||||
finalPlaylists.forEach(playlist => {
|
finalPlaylists.forEach((playlist) => {
|
||||||
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
|
const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, playlist);
|
trackDataStore.set(el, playlist);
|
||||||
this.updateLikeState(el, 'playlist', playlist.uuid);
|
this.updateLikeState(el, 'playlist', playlist.uuid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') return;
|
if (error.name === 'AbortError') return;
|
||||||
console.error("Search failed:", error);
|
console.error('Search failed:', error);
|
||||||
const errorMsg = createPlaceholder(`Error during search. ${error.message}`);
|
const errorMsg = createPlaceholder(`Error during search. ${error.message}`);
|
||||||
tracksContainer.innerHTML = errorMsg;
|
tracksContainer.innerHTML = errorMsg;
|
||||||
artistsContainer.innerHTML = errorMsg;
|
artistsContainer.innerHTML = errorMsg;
|
||||||
|
|
@ -976,17 +1016,21 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const releaseDate = new Date(album.releaseDate);
|
const releaseDate = new Date(album.releaseDate);
|
||||||
if (!isNaN(releaseDate.getTime())) {
|
if (!isNaN(releaseDate.getTime())) {
|
||||||
const year = releaseDate.getFullYear();
|
const year = releaseDate.getFullYear();
|
||||||
dateDisplay = window.innerWidth > 768
|
dateDisplay =
|
||||||
? releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
window.innerWidth > 768
|
||||||
|
? releaseDate.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
: year;
|
: year;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstCopyright = tracks.find(track => track.copyright)?.copyright;
|
const firstCopyright = tracks.find((track) => track.copyright)?.copyright;
|
||||||
|
|
||||||
metaEl.innerHTML =
|
metaEl.innerHTML =
|
||||||
(dateDisplay ? `${dateDisplay} • ` : '') +
|
(dateDisplay ? `${dateDisplay} • ` : '') + `${tracks.length} tracks • ${formatDuration(totalDuration)}`;
|
||||||
`${tracks.length} tracks • ${formatDuration(totalDuration)}`;
|
|
||||||
|
|
||||||
prodEl.innerHTML =
|
prodEl.innerHTML =
|
||||||
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a>` +
|
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a>` +
|
||||||
|
|
@ -1036,7 +1080,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const similarAlbumsContainer = document.getElementById('album-detail-similar-albums');
|
const similarAlbumsContainer = document.getElementById('album-detail-similar-albums');
|
||||||
|
|
||||||
// Hide all initially
|
// Hide all initially
|
||||||
[moreAlbumsSection, epsSection, similarArtistsSection, similarAlbumsSection].forEach(el => {
|
[moreAlbumsSection, epsSection, similarArtistsSection, similarAlbumsSection].forEach((el) => {
|
||||||
if (el) el.style.display = 'none';
|
if (el) el.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1047,26 +1091,26 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const mixBtn = document.getElementById('album-mix-btn');
|
const mixBtn = document.getElementById('album-mix-btn');
|
||||||
if (mixBtn && artistData.mixes && artistData.mixes.ARTIST_MIX) {
|
if (mixBtn && artistData.mixes && artistData.mixes.ARTIST_MIX) {
|
||||||
mixBtn.style.display = 'flex';
|
mixBtn.style.display = 'flex';
|
||||||
mixBtn.onclick = () => window.location.hash = `#mix/${artistData.mixes.ARTIST_MIX}`;
|
mixBtn.onclick = () => (window.location.hash = `#mix/${artistData.mixes.ARTIST_MIX}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderSection = (items, container, section, titleEl, titleText) => {
|
const renderSection = (items, container, section, titleEl, titleText) => {
|
||||||
if (!container || !section) return;
|
if (!container || !section) return;
|
||||||
|
|
||||||
const filtered = (items || [])
|
const filtered = (items || [])
|
||||||
.filter(a => a.id != album.id)
|
.filter((a) => a.id != album.id)
|
||||||
.filter((a, index, self) =>
|
.filter(
|
||||||
index === self.findIndex((t) => t.title === a.title) // Dedup by title
|
(a, index, self) => index === self.findIndex((t) => t.title === a.title) // Dedup by title
|
||||||
)
|
)
|
||||||
.slice(0, 12);
|
.slice(0, 12);
|
||||||
|
|
||||||
if (filtered.length === 0) return;
|
if (filtered.length === 0) return;
|
||||||
|
|
||||||
container.innerHTML = filtered.map(a => this.createAlbumCardHTML(a)).join('');
|
container.innerHTML = filtered.map((a) => this.createAlbumCardHTML(a)).join('');
|
||||||
if (titleEl && titleText) titleEl.textContent = titleText;
|
if (titleEl && titleText) titleEl.textContent = titleText;
|
||||||
section.style.display = 'block';
|
section.style.display = 'block';
|
||||||
|
|
||||||
filtered.forEach(a => {
|
filtered.forEach((a) => {
|
||||||
const el = container.querySelector(`[data-album-id="${a.id}"]`);
|
const el = container.querySelector(`[data-album-id="${a.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, a);
|
trackDataStore.set(el, a);
|
||||||
|
|
@ -1075,24 +1119,43 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
renderSection(artistData.albums, moreAlbumsContainer, moreAlbumsSection, moreAlbumsTitle, `More albums from ${album.artist.name}`);
|
renderSection(
|
||||||
renderSection(artistData.eps, epsContainer, epsSection, epsTitle, `EPs and Singles from ${album.artist.name}`);
|
artistData.albums,
|
||||||
|
moreAlbumsContainer,
|
||||||
|
moreAlbumsSection,
|
||||||
|
moreAlbumsTitle,
|
||||||
|
`More albums from ${album.artist.name}`
|
||||||
|
);
|
||||||
|
renderSection(
|
||||||
|
artistData.eps,
|
||||||
|
epsContainer,
|
||||||
|
epsSection,
|
||||||
|
epsTitle,
|
||||||
|
`EPs and Singles from ${album.artist.name}`
|
||||||
|
);
|
||||||
|
|
||||||
// Similar Artists
|
// Similar Artists
|
||||||
this.api.getSimilarArtists(album.artist.id).then(similar => {
|
this.api
|
||||||
|
.getSimilarArtists(album.artist.id)
|
||||||
|
.then((similar) => {
|
||||||
if (similar && similar.length > 0 && similarArtistsContainer && similarArtistsSection) {
|
if (similar && similar.length > 0 && similarArtistsContainer && similarArtistsSection) {
|
||||||
similarArtistsContainer.innerHTML = similar.map(a => this.createArtistCardHTML(a)).join('');
|
similarArtistsContainer.innerHTML = similar
|
||||||
|
.map((a) => this.createArtistCardHTML(a))
|
||||||
|
.join('');
|
||||||
similarArtistsSection.style.display = 'block';
|
similarArtistsSection.style.display = 'block';
|
||||||
}
|
}
|
||||||
}).catch(e => console.warn('Failed to load similar artists:', e));
|
})
|
||||||
|
.catch((e) => console.warn('Failed to load similar artists:', e));
|
||||||
|
|
||||||
// Similar Albums
|
// Similar Albums
|
||||||
this.api.getSimilarAlbums(albumId).then(similar => {
|
this.api
|
||||||
|
.getSimilarAlbums(albumId)
|
||||||
|
.then((similar) => {
|
||||||
if (similar && similar.length > 0 && similarAlbumsContainer && similarAlbumsSection) {
|
if (similar && similar.length > 0 && similarAlbumsContainer && similarAlbumsSection) {
|
||||||
similarAlbumsContainer.innerHTML = similar.map(a => this.createAlbumCardHTML(a)).join('');
|
similarAlbumsContainer.innerHTML = similar.map((a) => this.createAlbumCardHTML(a)).join('');
|
||||||
similarAlbumsSection.style.display = 'block';
|
similarAlbumsSection.style.display = 'block';
|
||||||
|
|
||||||
similar.forEach(a => {
|
similar.forEach((a) => {
|
||||||
const el = similarAlbumsContainer.querySelector(`[data-album-id="${a.id}"]`);
|
const el = similarAlbumsContainer.querySelector(`[data-album-id="${a.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, a);
|
trackDataStore.set(el, a);
|
||||||
|
|
@ -1100,14 +1163,13 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).catch(e => console.warn('Failed to load similar albums:', e));
|
})
|
||||||
|
.catch((e) => console.warn('Failed to load similar albums:', e));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to load "More from artist":', err);
|
console.warn('Failed to load "More from artist":', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load album:", error);
|
console.error('Failed to load album:', error);
|
||||||
tracklistContainer.innerHTML = createPlaceholder(`Could not load album details. ${error.message}`);
|
tracklistContainer.innerHTML = createPlaceholder(`Could not load album details. ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1198,7 +1260,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const removeBtn = document.createElement('button');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.className = 'track-action-btn remove-from-playlist-btn';
|
removeBtn.className = 'track-action-btn remove-from-playlist-btn';
|
||||||
removeBtn.title = 'Remove from playlist';
|
removeBtn.title = 'Remove from playlist';
|
||||||
removeBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
removeBtn.innerHTML =
|
||||||
|
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
||||||
removeBtn.dataset.trackIndex = index;
|
removeBtn.dataset.trackIndex = index;
|
||||||
const menuBtn = actionsDiv.querySelector('.track-menu-btn');
|
const menuBtn = actionsDiv.querySelector('.track-menu-btn');
|
||||||
actionsDiv.insertBefore(removeBtn, menuBtn);
|
actionsDiv.insertBefore(removeBtn, menuBtn);
|
||||||
|
|
@ -1238,7 +1301,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
cover: playlistData.cover,
|
cover: playlistData.cover,
|
||||||
images: uniqueCovers,
|
images: uniqueCovers,
|
||||||
numberOfTracks: playlistData.tracks ? playlistData.tracks.length : 0,
|
numberOfTracks: playlistData.tracks ? playlistData.tracks.length : 0,
|
||||||
isUserPlaylist: true
|
isUserPlaylist: true,
|
||||||
});
|
});
|
||||||
document.title = `${playlistData.name || playlistData.title} - Monochrome`;
|
document.title = `${playlistData.name || playlistData.title} - Monochrome`;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1309,7 +1372,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
document.title = playlist.title || 'Artist Mix';
|
document.title = playlist.title || 'Artist Mix';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load playlist:", error);
|
console.error('Failed to load playlist:', error);
|
||||||
tracklistContainer.innerHTML = createPlaceholder(`Could not load playlist details. ${error.message}`);
|
tracklistContainer.innerHTML = createPlaceholder(`Could not load playlist details. ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1402,7 +1465,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
|
|
||||||
document.title = displayTitle;
|
document.title = displayTitle;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load mix:", error);
|
console.error('Failed to load mix:', error);
|
||||||
tracklistContainer.innerHTML = createPlaceholder(`Could not load mix details. ${error.message}`);
|
tracklistContainer.innerHTML = createPlaceholder(`Could not load mix details. ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1441,7 +1504,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
if (mixBtn) {
|
if (mixBtn) {
|
||||||
if (artist.mixes && artist.mixes.ARTIST_MIX) {
|
if (artist.mixes && artist.mixes.ARTIST_MIX) {
|
||||||
mixBtn.style.display = 'flex';
|
mixBtn.style.display = 'flex';
|
||||||
mixBtn.onclick = () => window.location.hash = `#mix/${artist.mixes.ARTIST_MIX}`;
|
mixBtn.onclick = () => (window.location.hash = `#mix/${artist.mixes.ARTIST_MIX}`);
|
||||||
} else {
|
} else {
|
||||||
mixBtn.style.display = 'none';
|
mixBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
@ -1449,14 +1512,17 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
|
|
||||||
// Similar Artists
|
// Similar Artists
|
||||||
if (similarContainer && similarSection) {
|
if (similarContainer && similarSection) {
|
||||||
this.api.getSimilarArtists(artistId).then(similar => {
|
this.api
|
||||||
|
.getSimilarArtists(artistId)
|
||||||
|
.then((similar) => {
|
||||||
if (similar && similar.length > 0) {
|
if (similar && similar.length > 0) {
|
||||||
similarContainer.innerHTML = similar.map(a => this.createArtistCardHTML(a)).join('');
|
similarContainer.innerHTML = similar.map((a) => this.createArtistCardHTML(a)).join('');
|
||||||
similarSection.style.display = 'block';
|
similarSection.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
similarSection.style.display = 'none';
|
similarSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
})
|
||||||
|
.catch(() => {
|
||||||
similarSection.style.display = 'none';
|
similarSection.style.display = 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1478,8 +1544,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
<span>${artist.popularity}% popularity</span>
|
<span>${artist.popularity}% popularity</span>
|
||||||
<div class="artist-tags">
|
<div class="artist-tags">
|
||||||
${(artist.artistRoles || [])
|
${(artist.artistRoles || [])
|
||||||
.filter(role => role.category)
|
.filter((role) => role.category)
|
||||||
.map(role => `<span class="artist-tag">${role.category}</span>`)
|
.map((role) => `<span class="artist-tag">${role.category}</span>`)
|
||||||
.join('')}
|
.join('')}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -1494,25 +1560,23 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
artistLikeBtn.classList.toggle('active', isLiked);
|
artistLikeBtn.classList.toggle('active', isLiked);
|
||||||
}
|
}
|
||||||
|
|
||||||
albumsContainer.innerHTML = artist.albums.map(album =>
|
albumsContainer.innerHTML = artist.albums.map((album) => this.createAlbumCardHTML(album)).join('');
|
||||||
this.createAlbumCardHTML(album)
|
|
||||||
).join('');
|
|
||||||
// Render Albums
|
// Render Albums
|
||||||
albumsContainer.innerHTML = artist.albums.length
|
albumsContainer.innerHTML = artist.albums.length
|
||||||
? artist.albums.map(album => this.createAlbumCardHTML(album)).join('')
|
? artist.albums.map((album) => this.createAlbumCardHTML(album)).join('')
|
||||||
: createPlaceholder('No albums found.');
|
: createPlaceholder('No albums found.');
|
||||||
|
|
||||||
// Render EPs and Singles
|
// Render EPs and Singles
|
||||||
if (epsContainer && epsSection) {
|
if (epsContainer && epsSection) {
|
||||||
if (artist.eps && artist.eps.length > 0) {
|
if (artist.eps && artist.eps.length > 0) {
|
||||||
epsContainer.innerHTML = artist.eps.map(album => this.createAlbumCardHTML(album)).join('');
|
epsContainer.innerHTML = artist.eps.map((album) => this.createAlbumCardHTML(album)).join('');
|
||||||
epsSection.style.display = 'block';
|
epsSection.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
epsSection.style.display = 'none';
|
epsSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
artist.albums.forEach(album => {
|
artist.albums.forEach((album) => {
|
||||||
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
trackDataStore.set(el, album);
|
trackDataStore.set(el, album);
|
||||||
|
|
@ -1524,9 +1588,10 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
|
|
||||||
document.title = artist.name;
|
document.title = artist.name;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load artist:", error);
|
console.error('Failed to load artist:', error);
|
||||||
tracksContainer.innerHTML = albumsContainer.innerHTML =
|
tracksContainer.innerHTML = albumsContainer.innerHTML = createPlaceholder(
|
||||||
createPlaceholder(`Could not load artist details. ${error.message}`);
|
`Could not load artist details. ${error.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1548,14 +1613,20 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const today = new Date().setHours(0, 0, 0, 0);
|
const today = new Date().setHours(0, 0, 0, 0);
|
||||||
const yesterday = new Date(today - 86400000).setHours(0, 0, 0, 0);
|
const yesterday = new Date(today - 86400000).setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
history.forEach(item => {
|
history.forEach((item) => {
|
||||||
const date = new Date(item.timestamp);
|
const date = new Date(item.timestamp);
|
||||||
const dayStart = new Date(date).setHours(0, 0, 0, 0);
|
const dayStart = new Date(date).setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
let label;
|
let label;
|
||||||
if (dayStart === today) label = 'Today';
|
if (dayStart === today) label = 'Today';
|
||||||
else if (dayStart === yesterday) label = 'Yesterday';
|
else if (dayStart === yesterday) label = 'Yesterday';
|
||||||
else label = date.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
else
|
||||||
|
label = date.toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
if (!groups[label]) groups[label] = [];
|
if (!groups[label]) groups[label] = [];
|
||||||
groups[label].push(item);
|
groups[label].push(item);
|
||||||
|
|
@ -1584,7 +1655,6 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
container.appendChild(tempContainer.firstChild);
|
container.appendChild(tempContainer.firstChild);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load history:', error);
|
console.error('Failed to load history:', error);
|
||||||
container.innerHTML = createPlaceholder('Failed to load history.');
|
container.innerHTML = createPlaceholder('Failed to load history.');
|
||||||
|
|
@ -1595,7 +1665,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const actionsDiv = document.getElementById('page-playlist').querySelector('.detail-header-actions');
|
const actionsDiv = document.getElementById('page-playlist').querySelector('.detail-header-actions');
|
||||||
|
|
||||||
// Cleanup existing dynamic buttons
|
// Cleanup existing dynamic buttons
|
||||||
['shuffle-playlist-btn', 'edit-playlist-btn', 'delete-playlist-btn', 'share-playlist-btn'].forEach(id => {
|
['shuffle-playlist-btn', 'edit-playlist-btn', 'delete-playlist-btn', 'share-playlist-btn'].forEach((id) => {
|
||||||
const btn = actionsDiv.querySelector(`#${id}`);
|
const btn = actionsDiv.querySelector(`#${id}`);
|
||||||
if (btn) btn.remove();
|
if (btn) btn.remove();
|
||||||
});
|
});
|
||||||
|
|
@ -1606,7 +1676,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const shuffleBtn = document.createElement('button');
|
const shuffleBtn = document.createElement('button');
|
||||||
shuffleBtn.id = 'shuffle-playlist-btn';
|
shuffleBtn.id = 'shuffle-playlist-btn';
|
||||||
shuffleBtn.className = 'btn-primary';
|
shuffleBtn.className = 'btn-primary';
|
||||||
shuffleBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 14 4 4-4 4"/><path d="m18 2 4 4-4 4"/><path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22"/><path d="M2 6h1.972a4 4 0 0 1 3.6 2.2"/><path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45"/></svg><span>Shuffle</span>';
|
shuffleBtn.innerHTML =
|
||||||
|
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 14 4 4-4 4"/><path d="m18 2 4 4-4 4"/><path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22"/><path d="M2 6h1.972a4 4 0 0 1 3.6 2.2"/><path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45"/></svg><span>Shuffle</span>';
|
||||||
shuffleBtn.onclick = () => {
|
shuffleBtn.onclick = () => {
|
||||||
const shuffledTracks = [...tracks].sort(() => Math.random() - 0.5);
|
const shuffledTracks = [...tracks].sort(() => Math.random() - 0.5);
|
||||||
this.player.setQueue(shuffledTracks, 0);
|
this.player.setQueue(shuffledTracks, 0);
|
||||||
|
|
@ -1619,13 +1690,15 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const editBtn = document.createElement('button');
|
const editBtn = document.createElement('button');
|
||||||
editBtn.id = 'edit-playlist-btn';
|
editBtn.id = 'edit-playlist-btn';
|
||||||
editBtn.className = 'btn-secondary';
|
editBtn.className = 'btn-secondary';
|
||||||
editBtn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg><span>Edit</span>';
|
editBtn.innerHTML =
|
||||||
|
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg><span>Edit</span>';
|
||||||
fragment.appendChild(editBtn);
|
fragment.appendChild(editBtn);
|
||||||
|
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.id = 'delete-playlist-btn';
|
deleteBtn.id = 'delete-playlist-btn';
|
||||||
deleteBtn.className = 'btn-secondary danger';
|
deleteBtn.className = 'btn-secondary danger';
|
||||||
deleteBtn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg><span>Delete</span>';
|
deleteBtn.innerHTML =
|
||||||
|
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg><span>Delete</span>';
|
||||||
fragment.appendChild(deleteBtn);
|
fragment.appendChild(deleteBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1634,7 +1707,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
const shareBtn = document.createElement('button');
|
const shareBtn = document.createElement('button');
|
||||||
shareBtn.id = 'share-playlist-btn';
|
shareBtn.id = 'share-playlist-btn';
|
||||||
shareBtn.className = 'btn-secondary';
|
shareBtn.className = 'btn-secondary';
|
||||||
shareBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg><span>Share</span>';
|
shareBtn.innerHTML =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg><span>Share</span>';
|
||||||
|
|
||||||
shareBtn.onclick = () => {
|
shareBtn.onclick = () => {
|
||||||
const url = `${window.location.origin}${window.location.pathname}#userplaylist/${playlist.id || playlist.uuid}`;
|
const url = `${window.location.origin}${window.location.pathname}#userplaylist/${playlist.id || playlist.uuid}`;
|
||||||
|
|
@ -1727,7 +1801,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
|
|
||||||
// Get new order from DOM
|
// Get new order from DOM
|
||||||
const newTrackItems = Array.from(container.querySelectorAll('.track-item'));
|
const newTrackItems = Array.from(container.querySelectorAll('.track-item'));
|
||||||
const newTracks = newTrackItems.map(item => {
|
const newTracks = newTrackItems.map((item) => {
|
||||||
const originalIndex = parseInt(item.dataset.index);
|
const originalIndex = parseInt(item.dataset.index);
|
||||||
return tracks[originalIndex];
|
return tracks[originalIndex];
|
||||||
});
|
});
|
||||||
|
|
@ -1736,7 +1810,6 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
item.dataset.index = index;
|
item.dataset.index = index;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
tracks.length = 0;
|
tracks.length = 0;
|
||||||
tracks.push(...newTracks);
|
tracks.push(...newTracks);
|
||||||
|
|
||||||
|
|
@ -1757,7 +1830,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
function getDragAfterElement(container, y) {
|
function getDragAfterElement(container, y) {
|
||||||
const draggableElements = [...container.querySelectorAll('.track-item:not(.dragging)')];
|
const draggableElements = [...container.querySelectorAll('.track-item:not(.dragging)')];
|
||||||
|
|
||||||
return draggableElements.reduce((closest, child) => {
|
return draggableElements.reduce(
|
||||||
|
(closest, child) => {
|
||||||
const box = child.getBoundingClientRect();
|
const box = child.getBoundingClientRect();
|
||||||
const offset = y - box.top - box.height / 2;
|
const offset = y - box.top - box.height / 2;
|
||||||
if (offset < 0 && offset > closest.offset) {
|
if (offset < 0 && offset > closest.offset) {
|
||||||
|
|
@ -1765,14 +1839,17 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
} else {
|
} else {
|
||||||
return closest;
|
return closest;
|
||||||
}
|
}
|
||||||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
},
|
||||||
|
{ offset: Number.NEGATIVE_INFINITY }
|
||||||
|
).element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getDragAfterElement(container, y) {
|
getDragAfterElement(container, y) {
|
||||||
const draggableElements = [...container.querySelectorAll('.track-item:not(.dragging)')];
|
const draggableElements = [...container.querySelectorAll('.track-item:not(.dragging)')];
|
||||||
|
|
||||||
return draggableElements.reduce((closest, child) => {
|
return draggableElements.reduce(
|
||||||
|
(closest, child) => {
|
||||||
const box = child.getBoundingClientRect();
|
const box = child.getBoundingClientRect();
|
||||||
const offset = y - box.top - box.height / 2;
|
const offset = y - box.top - box.height / 2;
|
||||||
if (offset < 0 && offset > closest.offset) {
|
if (offset < 0 && offset > closest.offset) {
|
||||||
|
|
@ -1780,28 +1857,29 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
} else {
|
} else {
|
||||||
return closest;
|
return closest;
|
||||||
}
|
}
|
||||||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
},
|
||||||
|
{ offset: Number.NEGATIVE_INFINITY }
|
||||||
|
).element;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderApiSettings() {
|
renderApiSettings() {
|
||||||
const container = document.getElementById('api-instance-list');
|
const container = document.getElementById('api-instance-list');
|
||||||
Promise.all([
|
Promise.all([this.api.settings.getInstances('api'), this.api.settings.getInstances('streaming')]).then(
|
||||||
this.api.settings.getInstances('api'),
|
([apiInstances, streamingInstances]) => {
|
||||||
this.api.settings.getInstances('streaming')
|
|
||||||
]).then(([apiInstances, streamingInstances]) => {
|
|
||||||
const cachedData = this.api.settings.getCachedSpeedTests();
|
const cachedData = this.api.settings.getCachedSpeedTests();
|
||||||
const speeds = cachedData?.speeds || {};
|
const speeds = cachedData?.speeds || {};
|
||||||
|
|
||||||
const renderGroup = (instances, type) => {
|
const renderGroup = (instances, type) => {
|
||||||
if (!instances || instances.length === 0) return '';
|
if (!instances || instances.length === 0) return '';
|
||||||
|
|
||||||
const listHtml = instances.map((url, index) => {
|
const listHtml = instances
|
||||||
|
.map((url, index) => {
|
||||||
const cacheKey = type === 'streaming' ? `${url}#streaming` : url;
|
const cacheKey = type === 'streaming' ? `${url}#streaming` : url;
|
||||||
const speedInfo = speeds[cacheKey];
|
const speedInfo = speeds[cacheKey];
|
||||||
const speedText = speedInfo
|
const speedText = speedInfo
|
||||||
? (speedInfo.speed === Infinity || typeof speedInfo.speed !== 'number'
|
? speedInfo.speed === Infinity || typeof speedInfo.speed !== 'number'
|
||||||
? `<span style="color: var(--muted-foreground); font-size: 0.8rem;">Failed</span>`
|
? `<span style="color: var(--muted-foreground); font-size: 0.8rem;">Failed</span>`
|
||||||
: `<span style="color: var(--muted-foreground); font-size: 0.8rem;">${speedInfo.speed.toFixed(0)}ms</span>`)
|
: `<span style="color: var(--muted-foreground); font-size: 0.8rem;">${speedInfo.speed.toFixed(0)}ms</span>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
|
@ -1824,7 +1902,8 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<li class="group-header" style="font-weight: bold; padding: 1rem 0 0.5rem; background: transparent; border: none; pointer-events: none;">
|
<li class="group-header" style="font-weight: bold; padding: 1rem 0 0.5rem; background: transparent; border: none; pointer-events: none;">
|
||||||
|
|
@ -1841,6 +1920,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) {
|
||||||
if (cacheInfo) {
|
if (cacheInfo) {
|
||||||
cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`;
|
cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
79
js/utils.js
79
js/utils.js
|
|
@ -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, "&")
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, "<")
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, """)
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, ''');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTrackTitle = (track, { fallback = 'Unknown Title' } = {}) => {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +62,7 @@ function hslToHex(h, s, l) {
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<!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>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
{
|
{
|
||||||
"api": [
|
"api": ["https://tidal-api.binimum.org", "https://monochrome-api.samidy.com"],
|
||||||
"https://tidal-api.binimum.org",
|
|
||||||
"https://monochrome-api.samidy.com"
|
|
||||||
],
|
|
||||||
"streaming": [
|
"streaming": [
|
||||||
"https://triton.squid.wtf",
|
"https://triton.squid.wtf",
|
||||||
"https://wolf.qqdl.site",
|
"https://wolf.qqdl.site",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@
|
||||||
"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",
|
"background_color": "#000000",
|
||||||
"theme_color": "#000000",
|
"theme_color": "#000000",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
|
|
@ -30,10 +28,7 @@
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": [
|
"categories": ["music", "entertainment"],
|
||||||
"music",
|
|
||||||
"entertainment"
|
|
||||||
],
|
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
{
|
{
|
||||||
"name": "Search",
|
"name": "Search",
|
||||||
|
|
|
||||||
257
styles.css
257
styles.css
|
|
@ -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,6 +3041,7 @@ 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;
|
||||||
|
|
@ -3360,8 +3350,6 @@ img:not([src]), img[src=''] {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
|
||||||
.player-controls .progress-container {
|
.player-controls .progress-container {
|
||||||
order: -1;
|
order: -1;
|
||||||
}
|
}
|
||||||
|
|
@ -3385,9 +3373,9 @@ img:not([src]), img[src=''] {
|
||||||
|
|
||||||
.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);
|
||||||
|
|
@ -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,6 +3912,7 @@ 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;
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
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
|
||||||
|
|
@ -27,12 +27,10 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
urlPattern: ({ request }) =>
|
urlPattern: ({ request }) => request.destination === 'audio' || request.destination === 'video',
|
||||||
request.destination === "audio" ||
|
handler: 'CacheFirst',
|
||||||
request.destination === "video",
|
|
||||||
handler: "CacheFirst",
|
|
||||||
options: {
|
options: {
|
||||||
cacheName: "media",
|
cacheName: 'media',
|
||||||
expiration: {
|
expiration: {
|
||||||
maxEntries: 50,
|
maxEntries: 50,
|
||||||
maxAgeSeconds: 60 * 24 * 60 * 60, // 60 Days
|
maxAgeSeconds: 60 * 24 * 60 * 60, // 60 Days
|
||||||
|
|
@ -42,7 +40,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
includeAssets: ["instances.json", "discord.html"],
|
includeAssets: ['instances.json', 'discord.html'],
|
||||||
manifest: false, // Use existing public/manifest.json
|
manifest: false, // Use existing public/manifest.json
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue