This commit is contained in:
Daniel 2026-03-10 18:48:18 +00:00 committed by GitHub
commit 1c9c5fa242
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1233 additions and 849 deletions

View file

@ -1,25 +1,51 @@
# ------------------------------------------------------------ # ------------------------------------------------------------
# Base Image # Base Image
# ------------------------------------------------------------ # ------------------------------------------------------------
FROM mcr.microsoft.com/devcontainers/base:debian FROM debian:unstable-slim
ENV DEBIAN_FRONTEND=noninteractive
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
# ------------------------------------------------------------ # ------------------------------------------------------------
# System Dependencies # System Dependencies
# ------------------------------------------------------------ # ------------------------------------------------------------
RUN apt update && apt upgrade -y && \ RUN apt-get update && apt-get upgrade -y && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
curl \
ca-certificates \
git \ git \
git-lfs \ build-essential \
sudo \
fish \ fish \
unzip \
xz-utils \
libatomic1 \
libc6 \
wget \
nodejs \ nodejs \
npm \ npm && \
curl rm -rf /var/lib/apt/lists/*
# ------------------------------------------------------------
# Create Non-Root User
# ------------------------------------------------------------
ARG USERNAME=devuser
ARG UID=1000
ARG GID=1000
RUN groupadd --gid ${GID} ${USERNAME} && \
useradd --uid ${UID} --gid ${GID} -m -s /usr/bin/fish ${USERNAME} && \
echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
USER ${USERNAME}
WORKDIR /home/${USERNAME}
# ------------------------------------------------------------ # ------------------------------------------------------------
# Install Bun (Non-Root) # Install Bun (Non-Root)
# ------------------------------------------------------------ # ------------------------------------------------------------
ENV BUN_INSTALL="$HOME/.bun" ENV BUN_INSTALL=/home/${USERNAME}/.bun
ENV PATH="$BUN_INSTALL/bin:$PATH" ENV PATH="${BUN_INSTALL}/bin:${PATH}"
RUN curl -fsSL https://bun.sh/install | bash RUN curl -fsSL https://bun.sh/install | bash
@ -32,7 +58,7 @@ RUN curl -fsSL https://opencode.ai/install -o opencode-install && \
rm opencode-install rm opencode-install
# Add OpenCode to PATH permanently # Add OpenCode to PATH permanently
ENV PATH="$HOME/.opencode/bin:$PATH" ENV PATH="/home/${USERNAME}/.opencode/bin:${PATH}"
# ------------------------------------------------------------ # ------------------------------------------------------------
# Ensure fish is Default Shell # Ensure fish is Default Shell

View file

@ -26,14 +26,16 @@ Thank you for your interest in contributing to Monochrome! This guide will help
### Quick Start ### Quick Start
1. **Fork and clone the repository:** 1. Fork the Repository
2. clone the repository:
```bash ```bash
git clone https://github.com/YOUR_USERNAME/monochrome.git git clone https://github.com/YOUR_USERNAME/monochrome.git
cd monochrome cd monochrome
``` ```
2. **Install dependencies:** 3. Install dependencies:
```bash ```bash
bun install bun install
@ -41,7 +43,7 @@ Thank you for your interest in contributing to Monochrome! This guide will help
npm install npm install
``` ```
3. **Start the development server:** 4. Start the development server:
```bash ```bash
bun run dev bun run dev
@ -49,7 +51,7 @@ Thank you for your interest in contributing to Monochrome! This guide will help
npm run dev npm run dev
``` ```
4. **Open your browser:** 5. Open your browser:
Navigate to `http://localhost:5173/` Navigate to `http://localhost:5173/`
--- ---
@ -118,12 +120,12 @@ monochrome/
- **`/js`** - All JavaScript source code - **`/js`** - All JavaScript source code
- Keep modules focused and single-purpose - Keep modules focused and single-purpose
- Use ES6+ features - Use ES6+ features
- Add JSDoc comments for complex functions - Keep the code easy to work with/maintain
- **`/public`** - Static assets copied directly to build - **`/public`** - Static assets copied directly to build
- Images should be optimized before adding - Images should be optimized before adding
- Keep file sizes reasonable - Keep file sizes reasonable
- Use appropriate formats (WebP where possible) - Use appropriate formats (PNG where possible)
--- ---

View file

@ -10,19 +10,6 @@ docker compose up -d
Visit `http://localhost:3000` Visit `http://localhost:3000`
### With PocketBase
```bash
cp .env.example .env
# Edit .env -- set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD
docker compose --profile pocketbase up -d
```
- Monochrome: `http://localhost:3000`
- PocketBase admin: `http://localhost:8090/_/`
Configure PocketBase collections per [self-hosted-database.md](self-hosted-database.md).
### Development ### Development
```bash ```bash

View file

@ -17,6 +17,14 @@ The official Monochrome instance maintained by the core team:
## Community Instances ## Community Instances
### Community Monochrome Instances
These instances are community instances of Monochrome & its WebUI:
| Provider | URL | Status |
| ------------- | ---------------------------------------- | --------- |
| **Squid.WTF** | [mono.squid.wtf](https://mono.squid.wtf) | Community |
### UI-Only Instances ### UI-Only Instances
These instances provide the tidal-ui web interface, not monochrome: These instances provide the tidal-ui web interface, not monochrome:
@ -40,21 +48,21 @@ These are available API endpoints that can be used with Monochrome or other Hi-F
### Official & Community APIs ### Official & Community APIs
| Provider | URL | Notes | | Provider | URL | Notes |
| ----------------- | ----------------------------------- | ---------------------------------------------------------- | | ----------------- | ----------------------------------- | ---------------- |
| **Monochrome** | `https://monochrome-api.samidy.com` | Official API - [See Note](https://rentry.co/monochromeapi) | | **Monochrome** | `https://monochrome-api.samidy.com` | Official API |
| | `https://api.monochrome.tf` | Official API | | | `https://api.monochrome.tf` | Official API |
| | `https://arran.monochrome.tf` | Official API | | | `https://arran.monochrome.tf` | Official API |
| **squid.wtf** | `https://triton.squid.wtf` | Community hosted | | **squid.wtf** | `https://triton.squid.wtf` | Community hosted |
| **Lucida (QQDL)** | `https://wolf.qqdl.site` | Community hosted | | **Lucida (QQDL)** | `https://wolf.qqdl.site` | Community hosted |
| | `https://maus.qqdl.site` | Community hosted | | | `https://maus.qqdl.site` | Community hosted |
| | `https://vogel.qqdl.site` | Community hosted | | | `https://vogel.qqdl.site` | Community hosted |
| | `https://katze.qqdl.site` | Community hosted | | | `https://katze.qqdl.site` | Community hosted |
| | `https://hund.qqdl.site` | Community hosted | | | `https://hund.qqdl.site` | Community hosted |
| **Spotisaver** | `https://hifi-one.spotisaver.net` | Community hosted | | **Spotisaver** | `https://hifi-one.spotisaver.net` | Community hosted |
| | `https://hifi-two.spotisaver.net` | Community hosted | | | `https://hifi-two.spotisaver.net` | Community hosted |
| **Kinoplus** | `https://tidal.kinoplus.online` | Community hosted | | **Kinoplus** | `https://tidal.kinoplus.online` | Community hosted |
| **Binimum** | `https://tidal-api.binimum.org` | Community hosted | | **Binimum** | `https://tidal-api.binimum.org` | Community hosted |
--- ---
@ -95,6 +103,5 @@ Want to add your instance to this list?
## Related Resources ## Related Resources
- [Self-Hosting Guide](self-hosted-database.md) - Host your own instance
- [Contributing Guide](CONTRIBUTE.md) - Contribute to the project - [Contributing Guide](CONTRIBUTE.md) - Contribute to the project
- [Main Repository](https://github.com/SamidyFR/monochrome) - Source code - [Main Repository](https://github.com/monochrome-music/monochrome) - Source code

View file

@ -11,12 +11,11 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://monochrome.tf">Website</a> <a href="https://monochrome.tf">Website</a> -
<a href="https://ko-fi.com/monochromemusic">Donate</a> <a href="https://ko-fi.com/monochromemusic">Donate</a> -
<a href="#features">Features</a> <a href="#features">Features</a> -
<a href="#installation">Installation</a> <a href="#usage">Usage</a> -
<a href="#usage">Usage</a> <a href="#self-hosting">Self-Hosting</a> -
<a href="#self-hosting">Self-Hosting</a>
<a href="CONTRIBUTING.md">Contributing</a> <a href="CONTRIBUTING.md">Contributing</a>
</p> </p>
@ -39,8 +38,14 @@
**Monochrome** is an open-source, privacy-respecting, ad-free [TIDAL](https://tidal.com) web UI, built on top of [Hi-Fi](https://github.com/binimum/hifi-api). It provides a beautiful, minimalist interface for streaming high-quality music without the clutter of traditional streaming platforms. **Monochrome** is an open-source, privacy-respecting, ad-free [TIDAL](https://tidal.com) web UI, built on top of [Hi-Fi](https://github.com/binimum/hifi-api). It provides a beautiful, minimalist interface for streaming high-quality music without the clutter of traditional streaming platforms.
<p align="center"> <p align="center">
<a href="https://monochrome.tf/#album/90502209"> <a href="https://monochrome.tf/album/90502209">
<img width="2559" height="1439" alt="image" src="https://github.com/user-attachments/assets/7973ea9f-c4aa-4c12-b476-f388f614db38" alt="Monochrome UI" width="800"> <img width="2559" height="1439" alt="Image of 'NASIR' By Nas On Monochrome" src="https://i.samidy.xyz/NASIR.png" alt="Monochrome UI" width="800">
</a>
</p>
<p align="center">
<a href="https://monochrome.tf/album/413189044">
<img width="2559" height="1439" alt="Image of 'Jump Out' By Osamason On Monochrome" src="https://i.samidy.xyz/jumpout.png" alt="Monochrome UI" width="800">
</a> </a>
</p> </p>
@ -50,15 +55,16 @@
### Audio Quality ### Audio Quality
- High-quality Hi-Res/lossless audio streaming - High-quality High-Res/lossless audio streaming
- Support for local music files - Support for local music files
- Intelligent API caching for improved performance - API caching for improved performance
### Interface ### Interface
- Dark, minimalist interface optimized for focus - Dark, minimalist interface optimized for focus
- Customizable themes - Animated Album Covers For Supported Albums
- Community Theme Store - High-quality Music Videos
- Customizable themes & Community Theme Store
- Accurate and unique audio visualizer - Accurate and unique audio visualizer
- Offline-capable Progressive Web App (PWA) - Offline-capable Progressive Web App (PWA)
- Media Session API integration for system controls - Media Session API integration for system controls
@ -71,6 +77,8 @@
- Playlist import from other platforms - Playlist import from other platforms
- Public playlists for social sharing - Public playlists for social sharing
- Smart recommendations for new songs, albums & artists - Smart recommendations for new songs, albums & artists
- Infinite Recommendation Radio
- Explore Page (Hot & New) for discovering newly added music and whats trending overall or within each genre
### Lyrics & Metadata ### Lyrics & Metadata
@ -85,6 +93,7 @@
- Last.fm and ListenBrainz integration for scrobbling - Last.fm and ListenBrainz integration for scrobbling
- Unreleased music from [ArtistGrid](https://artistgrid.cx) - Unreleased music from [ArtistGrid](https://artistgrid.cx)
- Dynamic Discord Embeds - Dynamic Discord Embeds
- Artist Biography + Social Links for learning more about your favorite artists
- Multiple API instance support with failover - Multiple API instance support with failover
### Power User Features ### Power User Features
@ -107,7 +116,9 @@ For alternative instances, check [INSTANCES.md](INSTANCES.md).
## Self-Hosting ## Self-Hosting
NOTE: We only allow authorized domains to use our firebase authentication system, so unless you switch to your own firebase project, accounts wont work. NOTE: Accounts will not work on self-hosted instances. Our Appwrite authentication system only allows authorized domains.
We had to heavily customize the authentication system and write several custom scripts to support features like SMTP and Google OAuth (which are currently bugged in Appwrite). Because of this, we can no longer provide a self-hostable accounts system.
### Option 1: Docker (Recommended) ### Option 1: Docker (Recommended)
@ -144,7 +155,7 @@ docker compose down
docker compose up -d docker compose up -d
``` ```
For PocketBase, development mode, and advanced setups, see [DOCKER.md](DOCKER.md). For development mode and advanced setups, see [DOCKER.md](DOCKER.md).
### Option 2: Manual Installation ### Option 2: Manual Installation

View file

@ -36,12 +36,16 @@
</head> </head>
<body> <body>
<video id="audio-player" crossorigin="anonymous" style="display: none"></video> <audio id="audio-player" crossorigin="anonymous" style="display: none"></audio>
<video id="video-player" crossorigin="anonymous" style="display: none"></video>
<div id="context-menu"> <div id="context-menu">
<ul> <ul>
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist"> <li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
Shuffle play Shuffle play
</li> </li>
<li data-action="start-infinite-radio" data-type-filter="track,album,playlist,user-playlist">
Start Infinite Radio
</li>
<li data-action="start-mix" data-type-filter="album,track,video">Start mix</li> <li data-action="start-mix" data-type-filter="album,track,video">Start mix</li>
<li data-action="play-next">Play next</li> <li data-action="play-next">Play next</li>
<li data-action="add-to-queue">Add to queue</li> <li data-action="add-to-queue">Add to queue</li>
@ -1668,6 +1672,42 @@
</div> </div>
</div> </div>
<div id="maintenance-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content" style="text-align: center">
<h3 style="color: #ef4444; display: flex; align-items: center; gap: 0.5rem; justify-content: center">
<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="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
Maintenance Notice
</h3>
<p style="margin: 1.5rem 0; line-height: 1.6; font-size: 1.1rem">
The desktop version of Monochrome is currently undergoing essential maintenance to address critical
issues.
</p>
<p style="margin-bottom: 2rem; color: var(--muted-foreground); line-height: 1.5">
Downloads are temporarily disabled while we work on fixing these issues. Please check back later.
</p>
<div class="modal-actions" style="justify-content: center">
<button id="maintenance-home-btn" class="btn-primary" style="padding: 0.75rem 2rem">
Back to Home
</button>
</div>
</div>
</div>
<div id="donate-modal" class="modal"> <div id="donate-modal" class="modal">
<div class="modal-overlay"></div> <div class="modal-overlay"></div>
<div class="modal-content"> <div class="modal-content">
@ -2212,36 +2252,57 @@
<div id="home-content" style="display: none"> <div id="home-content" style="display: none">
<section class="content-section"> <section class="content-section">
<div <div class="header-actions">
style=" <h2 class="section-title">Recommended Songs</h2>
display: flex; <div style="display: flex; gap: 8px">
align-items: center; <button
justify-content: space-between; class="btn-primary"
margin-bottom: 1rem; id="home-start-infinite-radio-btn"
" title="Start Infinite Radio"
> style="
<h2 class="section-title" style="margin-bottom: 0">Recommended Songs</h2> display: flex;
<button align-items: center;
class="btn-secondary" gap: 8px;
id="refresh-songs-btn" padding: 6px 12px;
title="Refresh" font-size: 0.85rem;
style="padding: 4px 8px" "
>
<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"
> >
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <svg
<path d="M21 3v5h-5" /> xmlns="http://www.w3.org/2000/svg"
</svg> width="16"
</button> height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" />
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4" />
<circle cx="12" cy="12" r="2" />
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4" />
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19.1" />
</svg>
Start Infinite Radio
</button>
<button class="btn-secondary" id="refresh-songs-btn" title="Refresh">
<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"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
</button>
</div>
</div> </div>
<div class="track-list" id="home-recommended-songs"></div> <div class="track-list" id="home-recommended-songs"></div>
</section> </section>
@ -5615,6 +5676,10 @@
</div> </div>
</div> </div>
<div class="player-controls"> <div class="player-controls">
<div id="radio-loading-indicator">
<div class="animate-spin"></div>
<span>Finding more songs for you...</span>
</div>
<div class="buttons"> <div class="buttons">
<button id="shuffle-btn" title="Shuffle"> <button id="shuffle-btn" title="Shuffle">
<svg <svg

View file

@ -534,22 +534,15 @@ const syncManager = {
database = await database; database = await database;
} }
const getAll = async (store) => {
if (database && typeof database.getAll === 'function') return database.getAll(store);
if (database && database.db && typeof database.db.getAll === 'function')
return database.db.getAll(store);
return [];
};
const localData = { const localData = {
tracks: (await getAll('favorites_tracks')) || [], tracks: (await database.getAll('favorites_tracks')) || [],
albums: (await getAll('favorites_albums')) || [], albums: (await database.getAll('favorites_albums')) || [],
artists: (await getAll('favorites_artists')) || [], artists: (await database.getAll('favorites_artists')) || [],
playlists: (await getAll('favorites_playlists')) || [], playlists: (await database.getAll('favorites_playlists')) || [],
mixes: (await getAll('favorites_mixes')) || [], mixes: (await database.getAll('favorites_mixes')) || [],
history: (await getAll('history_tracks')) || [], history: (await database.getAll('history_tracks')) || [],
userPlaylists: (await getAll('user_playlists')) || [], userPlaylists: (await database.getAll('user_playlists')) || [],
userFolders: (await getAll('user_folders')) || [], userFolders: (await database.getAll('user_folders')) || [],
}; };
let { library, history, userPlaylists, userFolders } = cloudData; let { library, history, userPlaylists, userFolders } = cloudData;
@ -612,8 +605,23 @@ const syncManager = {
} }
}); });
if (history.length === 0 && localData.history.length > 0) { const combinedHistory = [...history, ...localData.history];
history = localData.history; combinedHistory.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
const uniqueHistory = [];
const seenTimestamps = new Set();
for (const item of combinedHistory) {
if (!item.timestamp) continue;
if (!seenTimestamps.has(item.timestamp)) {
seenTimestamps.add(item.timestamp);
uniqueHistory.push(item);
}
if (uniqueHistory.length >= 100) break;
}
if (JSON.stringify(history) !== JSON.stringify(uniqueHistory)) {
history = uniqueHistory;
needsUpdate = true; needsUpdate = true;
} }

View file

@ -306,6 +306,21 @@ export class LosslessAPI {
decoded = manifest; decoded = manifest;
} }
} else if (typeof manifest === 'object') { } else if (typeof manifest === 'object') {
if (manifest.urls && Array.isArray(manifest.urls)) {
const priorityKeywords = ['flac', 'lossless', 'hi-res', 'high'];
const sortedUrls = [...manifest.urls].sort((a, b) => {
const aLow = a.toLowerCase();
const bLow = b.toLowerCase();
const aScore = priorityKeywords.findIndex((k) => aLow.includes(k));
const bScore = priorityKeywords.findIndex((k) => bLow.includes(k));
const finalAScore = aScore === -1 ? 999 : aScore;
const finalBScore = bScore === -1 ? 999 : bScore;
return finalAScore - finalBScore;
});
return sortedUrls[0];
}
if (manifest.urls?.[0]) return manifest.urls[0]; if (manifest.urls?.[0]) return manifest.urls[0];
return null; return null;
} else { } else {
@ -320,6 +335,19 @@ export class LosslessAPI {
try { try {
const parsed = JSON.parse(decoded); const parsed = JSON.parse(decoded);
if (parsed?.urls && Array.isArray(parsed.urls)) {
const priorityKeywords = ['flac', 'lossless', 'hi-res', 'high'];
const sortedUrls = [...parsed.urls].sort((a, b) => {
const aLow = a.toLowerCase();
const bLow = b.toLowerCase();
const aScore = priorityKeywords.findIndex((k) => aLow.includes(k));
const bScore = priorityKeywords.findIndex((k) => bLow.includes(k));
const finalAScore = aScore === -1 ? 999 : aScore;
const finalBScore = bScore === -1 ? 999 : bScore;
return finalAScore - finalBScore;
});
return sortedUrls[0];
}
if (parsed?.urls?.[0]) { if (parsed?.urls?.[0]) {
return parsed.urls[0]; return parsed.urls[0];
} }
@ -863,11 +891,11 @@ export class LosslessAPI {
const trackMap = new Map(); const trackMap = new Map();
const videoMap = new Map(); const videoMap = new Map();
const isTrack = (v) => v?.id && v.duration && v.album; const isTrack = (v) => v?.id && v.duration;
const isAlbum = (v) => v?.id && 'numberOfTracks' in v; const isAlbum = (v) => v?.id && 'numberOfTracks' in v;
const isVideo = (v) => v?.id && v.type === 'VIDEO'; const isVideo = (v) => v?.id && v.type === 'VIDEO';
const scan = (value, visited = new Set()) => { const scan = (value, visited) => {
if (!value || typeof value !== 'object' || visited.has(value)) return; if (!value || typeof value !== 'object' || visited.has(value)) return;
visited.add(value); visited.add(value);
@ -878,13 +906,17 @@ export class LosslessAPI {
const item = value.item || value; const item = value.item || value;
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) && !isAlbum(item) && !isVideo(item)) {
trackMap.set(item.id, this.prepareTrack(item));
}
if (isVideo(item)) videoMap.set(item.id, this.prepareVideo(item)); if (isVideo(item)) videoMap.set(item.id, this.prepareVideo(item));
Object.values(value).forEach((nested) => scan(nested, visited)); Object.values(value).forEach((nested) => scan(nested, visited));
}; };
entries.forEach((entry) => scan(entry)); const visited = new Set();
entries.forEach((entry) => scan(entry, visited));
scan(primaryData, visited);
if (!options.lightweight) { if (!options.lightweight) {
try { try {
@ -1098,7 +1130,7 @@ export class LosslessAPI {
results.forEach((tracks) => { results.forEach((tracks) => {
if (tracks.length > 0) { if (tracks.length > 0) {
recommendedTracks.push(...tracks); recommendedTracks.push(...tracks);
seenTrackIds.add(...tracks.map((t) => t.id)); tracks.forEach((t) => seenTrackIds.add(t.id));
} }
}); });
@ -1400,7 +1432,7 @@ export class LosslessAPI {
try { try {
switch (losslessContainerSettings.getContainer()) { switch (losslessContainerSettings.getContainer()) {
case 'flac': case 'flac':
if ((await getExtensionFromBlob(blob)) != 'flac' || true) { if ((await getExtensionFromBlob(blob)) != 'flac') {
blob = await ffmpeg( blob = await ffmpeg(
blob, blob,
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] }, { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },

View file

@ -194,7 +194,7 @@ function initializeCasting(audioPlayer, castBtn) {
} }
} }
function initializeKeyboardShortcuts(player, audioPlayer) { function initializeKeyboardShortcuts(player, _audioPlayer) {
const keyActionMap = { const keyActionMap = {
playPause: () => { playPause: () => {
trackKeyboardShortcut('Space'); trackKeyboardShortcut('Space');
@ -202,11 +202,11 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
}, },
seekForward: () => { seekForward: () => {
trackKeyboardShortcut('Right'); trackKeyboardShortcut('Right');
audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10); player.seekForward(10);
}, },
seekBackward: () => { seekBackward: () => {
trackKeyboardShortcut('Left'); trackKeyboardShortcut('Left');
audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10); player.seekBackward(10);
}, },
nextTrack: () => { nextTrack: () => {
trackKeyboardShortcut('Shift+Right'); trackKeyboardShortcut('Shift+Right');
@ -226,7 +226,8 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
}, },
mute: () => { mute: () => {
trackKeyboardShortcut('M'); trackKeyboardShortcut('M');
audioPlayer.muted = !audioPlayer.muted; const el = player.activeElement;
el.muted = !el.muted;
}, },
shuffle: () => { shuffle: () => {
trackKeyboardShortcut('S'); trackKeyboardShortcut('S');
@ -252,7 +253,7 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
trackKeyboardShortcut('Escape'); trackKeyboardShortcut('Escape');
document.getElementById('search-input')?.blur(); document.getElementById('search-input')?.blur();
sidePanelManager.close(); sidePanelManager.close();
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
}, },
visualizerNext: () => { visualizerNext: () => {
trackKeyboardShortcut('VisualizerNext'); trackKeyboardShortcut('VisualizerNext');
@ -426,8 +427,9 @@ document.addEventListener('DOMContentLoaded', async () => {
events.on('mediaPrevious', () => player.playPrev()); events.on('mediaPrevious', () => player.playPrev());
events.on('mediaPlayPause', () => player.handlePlayPause()); events.on('mediaPlayPause', () => player.handlePlayPause());
events.on('mediaStop', () => { events.on('mediaStop', () => {
player.audio.pause(); const el = player.activeElement;
player.audio.currentTime = 0; el.pause();
el.currentTime = 0;
}); });
console.log('Media keys initialized via bridge'); console.log('Media keys initialized via bridge');
}); });
@ -598,9 +600,9 @@ document.addEventListener('DOMContentLoaded', async () => {
if (isActive) { if (isActive) {
sidePanelManager.close(); sidePanelManager.close();
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
} else { } else {
openLyricsPanel(player.currentTrack, audioPlayer, lyricsManager); openLyricsPanel(player.currentTrack, player.activeElement, lyricsManager);
} }
} else if (mode === 'cover') { } else if (mode === 'cover') {
const overlay = document.getElementById('fullscreen-cover-overlay'); const overlay = document.getElementById('fullscreen-cover-overlay');
@ -612,7 +614,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
} else { } else {
const nextTrack = player.getNextTrack(); const nextTrack = player.getNextTrack();
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer); ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, player.activeElement);
} }
} else { } else {
// Default to 'album' mode - navigate to album // Default to 'album' mode - navigate to album
@ -900,9 +902,9 @@ document.addEventListener('DOMContentLoaded', async () => {
if (isActive) { if (isActive) {
sidePanelManager.close(); sidePanelManager.close();
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
} else { } else {
openLyricsPanel(player.currentTrack, audioPlayer, lyricsManager); openLyricsPanel(player.currentTrack, player.activeElement, lyricsManager);
} }
}); });
@ -930,14 +932,14 @@ document.addEventListener('DOMContentLoaded', async () => {
// Update lyrics panel if it's open // Update lyrics panel if it's open
if (sidePanelManager.isActive('lyrics')) { if (sidePanelManager.isActive('lyrics')) {
// Re-open forces update/refresh of content and sync // Re-open forces update/refresh of content and sync
openLyricsPanel(player.currentTrack, audioPlayer, lyricsManager, true); openLyricsPanel(player.currentTrack, player.activeElement, lyricsManager, true);
} }
// Update Fullscreen if it's open // Update Fullscreen if it's open
const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay'); const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay');
if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') { if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') {
const nextTrack = player.getNextTrack(); const nextTrack = player.getNextTrack();
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer); ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, player.activeElement);
} }
// DEV: Auto-open fullscreen mode if ?fullscreen=1 in URL // DEV: Auto-open fullscreen mode if ?fullscreen=1 in URL
@ -948,7 +950,7 @@ document.addEventListener('DOMContentLoaded', async () => {
getComputedStyle(fullscreenOverlay).display === 'none' getComputedStyle(fullscreenOverlay).display === 'none'
) { ) {
const nextTrack = player.getNextTrack(); const nextTrack = player.getNextTrack();
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer); ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, player.activeElement);
} }
}); });

View file

@ -91,6 +91,7 @@ class AudioContextManager {
constructor() { constructor() {
this.audioContext = null; this.audioContext = null;
this.source = null; this.source = null;
this.sources = new Map();
this.analyser = null; this.analyser = null;
this.filters = []; this.filters = [];
this.outputNode = null; this.outputNode = null;
@ -299,81 +300,94 @@ class AudioContextManager {
this.audio = audioElement; this.audio = audioElement;
// Detect iOS - skip Web Audio initialization on iOS to avoid lock screen audio issues // Detect iOS - skip Web Audio initialization on iOS to avoid lock screen audio issues
// iOS suspends AudioContext when screen locks, and MediaSession controls don't count
// as user gestures to resume it, causing audio to play silently.
// Use window.__IS_IOS__ (set before UA spoof in index.html) so detection works on real iOS.
const isIOS = typeof window !== 'undefined' && window.__IS_IOS__ === true; const isIOS = typeof window !== 'undefined' && window.__IS_IOS__ === true;
if (isIOS) { if (isIOS) {
console.log('[AudioContext] Skipping Web Audio initialization on iOS for lock screen compatibility'); console.log('[AudioContext] Skipping Web Audio initialization on iOS for lock screen compatibility');
// Don't set isInitialized - let it remain false so isReady() returns false
// This prevents other code from trying to use the non-existent audio context
return; return;
} }
try { try {
const AudioContext = window.AudioContext || window.webkitAudioContext; const AudioContext = window.AudioContext || window.webkitAudioContext;
// "playback" latency hint maximizes buffer size to prevent audio glitches (stuttering),
// which is critical for high-fidelity music listening.
// We also attempt to request 192kHz sample rate for high-res audio support.
const highResOptions = { sampleRate: 192000, latencyHint: 'playback' }; const highResOptions = { sampleRate: 192000, latencyHint: 'playback' };
try { try {
this.audioContext = new AudioContext(highResOptions); this.audioContext = new AudioContext(highResOptions);
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`); console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
} catch (e) { } catch (e) {
console.warn('[AudioContext] 192kHz/playback init failed, falling back to system defaults:', e);
// Fallback: Try just playback latency preference without forcing sample rate
try { try {
this.audioContext = new AudioContext({ latencyHint: 'playback' }); this.audioContext = new AudioContext({ latencyHint: 'playback' });
console.log(`[AudioContext] Created with system default rate: ${this.audioContext.sampleRate}Hz`);
} catch (e2) { } catch (e2) {
console.warn('[AudioContext] Playback latency hint failed, using defaults:', e2);
this.audioContext = new AudioContext(); this.audioContext = new AudioContext();
} }
} }
// Create the media element source if (!this.sources.has(audioElement)) {
this.source = this.audioContext.createMediaElementSource(audioElement); this.sources.set(audioElement, this.audioContext.createMediaElementSource(audioElement));
}
this.source = this.sources.get(audioElement);
// Create analyser for visualizer
this.analyser = this.audioContext.createAnalyser(); this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 1024; this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.7; this.analyser.smoothingTimeConstant = 0.7;
// Create biquad filters for EQ with dynamic band count
this._createEQ(); this._createEQ();
// Create output gain node
this.outputNode = this.audioContext.createGain(); this.outputNode = this.audioContext.createGain();
this.outputNode.gain.value = 1; this.outputNode.gain.value = 1;
// Create volume node
this.volumeNode = this.audioContext.createGain(); this.volumeNode = this.audioContext.createGain();
this.volumeNode.gain.value = this.currentVolume; this.volumeNode.gain.value = this.currentVolume;
// Create mono audio merger node
this.monoMergerNode = this.audioContext.createChannelMerger(2); this.monoMergerNode = this.audioContext.createChannelMerger(2);
// Connect the audio graph based on EQ and mono state
this._connectGraph(); this._connectGraph();
this.isInitialized = true; this.isInitialized = true;
console.log(`[AudioContext] Initialized with ${this.bandCount}-band EQ`);
} catch (e) { } catch (e) {
console.warn('[AudioContext] Init failed:', e); console.warn('[AudioContext] Init failed:', e);
} }
} }
changeSource(audioElement) {
if (!this.audioContext) {
this.init(audioElement);
return;
}
if (this.audio === audioElement) return;
try {
if (this.source) {
try {
this.source.disconnect();
} catch (e) {}
}
this.audio = audioElement;
if (!this.sources.has(audioElement)) {
this.sources.set(audioElement, this.audioContext.createMediaElementSource(audioElement));
}
this.source = this.sources.get(audioElement);
if (this.isInitialized) {
this._connectGraph();
}
} catch (e) {
console.warn('changeSource failed:', e);
}
}
/** /**
* Connect the audio graph based on EQ and mono audio state * Connect the audio graph based on EQ and mono audio state
*/ */
_connectGraph() { _connectGraph() {
if (!this.source || !this.audioContext) return; if (!this.isInitialized || !this.source || !this.audioContext) return;
try { try {
// Disconnect everything first // Disconnect everything first
this.source.disconnect(); try {
this.source.disconnect();
} catch (e) {}
this.outputNode.disconnect(); this.outputNode.disconnect();
if (this.volumeNode) { if (this.volumeNode) {
this.volumeNode.disconnect(); this.volumeNode.disconnect();

View file

@ -89,6 +89,10 @@ export class MusicDatabase {
}); });
} }
async getAll(storeName) {
return this.performTransaction(storeName, 'readonly', (store) => store.getAll());
}
// History API // History API
async addToHistory(track) { async addToHistory(track) {
const storeName = 'history_tracks'; const storeName = 'history_tracks';

View file

@ -279,11 +279,19 @@ function removeBulkDownloadTask(notifEl) {
}, 300); }, 300);
} }
async function downloadTrackBlob(track, quality, api, lyricsManager = null, signal = null, onProgress = null) { async function downloadTrackBlob(
track,
quality,
api,
lyricsManager = null,
signal = null,
onProgress = null,
coverBlob = null
) {
// Load ffmpeg in the background. // Load ffmpeg in the background.
loadFfmpeg().catch(console.error); loadFfmpeg().catch(console.error);
const prefetchPromises = prefetchMetadataObjects(track, api); const prefetchPromises = prefetchMetadataObjects(track, api, coverBlob);
let enrichedTrack = { let enrichedTrack = {
...track, ...track,
@ -359,7 +367,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
// Fallback // Fallback
if (downloadQuality !== 'LOSSLESS') { if (downloadQuality !== 'LOSSLESS') {
console.warn('Falling back to LOSSLESS (16-bit) download.'); console.warn('Falling back to LOSSLESS (16-bit) download.');
return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal, onProgress); return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal, onProgress, coverBlob);
} }
throw dashError; throw dashError;
} }
@ -380,7 +388,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
try { try {
switch (losslessContainerSettings.getContainer()) { switch (losslessContainerSettings.getContainer()) {
case 'flac': case 'flac':
if ((await getExtensionFromBlob(blob)) != 'flac' || true) { if ((await getExtensionFromBlob(blob)) != 'flac') {
blob = await ffmpeg( blob = await ffmpeg(
blob, blob,
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] }, { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
@ -435,7 +443,7 @@ function triggerDownload(blob, filename) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification) { async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification, coverBlob = null) {
const { abortController } = bulkDownloadTasks.get(notification); const { abortController } = bulkDownloadTasks.get(notification);
const signal = abortController.signal; const signal = abortController.signal;
@ -447,7 +455,7 @@ async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, not
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try { try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal); const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, null, coverBlob);
const filename = buildTrackFilename(track, quality, extension); const filename = buildTrackFilename(track, quality, extension);
triggerDownload(blob, filename); triggerDownload(blob, filename);
@ -574,9 +582,17 @@ async function bulkDownloadToZipStream(
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try { try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => { const { blob, extension } = await downloadTrackBlob(
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); track,
}); quality,
api,
null,
signal,
(p) => {
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
},
coverBlob
);
const filename = buildTrackFilename(track, quality, extension); const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i); const discNumber = discLayout.resolveDiscNumber(i);
yield { yield {
@ -718,9 +734,17 @@ async function bulkDownloadToZipBlob(
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try { try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => { const { blob, extension } = await downloadTrackBlob(
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); track,
}); quality,
api,
null,
signal,
(p) => {
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
},
coverBlob
);
const filename = buildTrackFilename(track, quality, extension); const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i); const discNumber = discLayout.resolveDiscNumber(i);
yield { yield {
@ -863,9 +887,17 @@ async function bulkDownloadToZipNeutralino(
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try { try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => { const { blob, extension } = await downloadTrackBlob(
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); track,
}); quality,
api,
null,
signal,
(p) => {
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
},
coverBlob
);
const filename = buildTrackFilename(track, quality, extension); const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i); const discNumber = discLayout.resolveDiscNumber(i);
yield { yield {
@ -1193,7 +1225,15 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
const track = tracks[i]; const track = tracks[i];
if (signal.aborted) break; if (signal.aborted) break;
try { try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal); const { blob, extension } = await downloadTrackBlob(
track,
quality,
api,
null,
signal,
null,
coverBlob
);
const filename = buildTrackFilename(track, quality, extension); const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i); const discNumber = discLayout.resolveDiscNumber(i);
yield { yield {

View file

@ -61,155 +61,164 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const prevBtn = document.getElementById('prev-btn'); const prevBtn = document.getElementById('prev-btn');
const shuffleBtn = document.getElementById('shuffle-btn'); const shuffleBtn = document.getElementById('shuffle-btn');
const repeatBtn = document.getElementById('repeat-btn'); const repeatBtn = document.getElementById('repeat-btn');
const homeStartRadioBtn = document.getElementById('home-start-infinite-radio-btn');
const sleepTimerBtnDesktop = document.getElementById('sleep-timer-btn-desktop'); const sleepTimerBtnDesktop = document.getElementById('sleep-timer-btn-desktop');
const volumeBar = document.getElementById('volume-bar');
const volumeFill = document.getElementById('volume-fill');
const volumeBtn = document.getElementById('volume-btn');
const updateVolumeUI = () => {
const activeEl = player.activeElement;
const { muted } = activeEl;
const volume = player.userVolume;
volumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE : SVG_VOLUME;
const effectiveVolume = muted ? 0 : volume * 100;
volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
volumeFill.style.width = `${effectiveVolume}%`;
};
if (homeStartRadioBtn) {
homeStartRadioBtn.addEventListener('click', async () => {
await player.enableRadio();
});
}
const sleepTimerBtnMobile = document.getElementById('sleep-timer-btn'); const sleepTimerBtnMobile = document.getElementById('sleep-timer-btn');
// History tracking // History tracking
let historyLoggedTrackId = null; let historyLoggedTrackId = null;
audioPlayer.addEventListener('loadstart', () => { const setupMediaListeners = (element) => {
historyLoggedTrackId = null; element.addEventListener('loadstart', () => {
}); if (player.activeElement === element) {
historyLoggedTrackId = null;
// Sync UI with player state on load
if (player.shuffleActive) {
shuffleBtn.classList.add('active');
}
if (player.repeatMode && player.repeatMode !== REPEAT_MODE.OFF) {
repeatBtn.classList.add('active');
if (player.repeatMode === REPEAT_MODE.ONE) {
repeatBtn.classList.add('repeat-one');
}
repeatBtn.title = player.repeatMode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One';
} else {
repeatBtn.title = 'Repeat';
}
audioPlayer.addEventListener('play', () => {
// Initialize audio context manager for EQ (only once)
if (!audioContextManager.isReady()) {
audioContextManager.init(audioPlayer);
}
audioContextManager.resume();
if (player.currentTrack) {
// Track play event
trackPlayTrack(player.currentTrack);
// Scrobble
if (scrobbler.isAuthenticated()) {
scrobbler.updateNowPlaying(player.currentTrack);
} }
});
updateWaveform(); element.addEventListener('play', () => {
} if (player.activeElement !== element) return;
playPauseBtn.innerHTML = SVG_PAUSE; // Initialize audio context manager for EQ (only once)
player.updateMediaSessionPlaybackState(); if (!audioContextManager.isReady()) {
player.updateMediaSessionPositionState(); audioContextManager.init(element);
updateTabTitle(player); }
}); audioContextManager.resume();
audioPlayer.addEventListener('playing', () => { if (player.currentTrack) {
player.updateMediaSessionPlaybackState(); // Track play event
player.updateMediaSessionPositionState(); trackPlayTrack(player.currentTrack);
});
audioPlayer.addEventListener('pause', () => { // Scrobble
if (player.currentTrack) { if (scrobbler.isAuthenticated()) {
trackPauseTrack(player.currentTrack); scrobbler.updateNowPlaying(player.currentTrack);
}
playPauseBtn.innerHTML = SVG_PLAY;
player.updateMediaSessionPlaybackState();
player.updateMediaSessionPositionState();
});
audioPlayer.addEventListener('ended', () => {
player.playNext();
});
audioPlayer.addEventListener('timeupdate', async () => {
const { currentTime, duration } = audioPlayer;
if (duration) {
const progressFill = document.getElementById('progress-fill');
const currentTimeEl = document.getElementById('current-time');
progressFill.style.width = `${(currentTime / duration) * 100}%`;
currentTimeEl.textContent = formatTime(currentTime);
// Log to history after 10 seconds of playback
if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) {
historyLoggedTrackId = player.currentTrack.id;
const historyEntry = await db.addToHistory(player.currentTrack);
syncManager.syncHistoryItem(historyEntry);
if (window.location.hash === '#recent') {
ui.renderRecentPage();
} }
updateWaveform();
} }
}
});
audioPlayer.addEventListener('loadedmetadata', () => { playPauseBtn.innerHTML = SVG_PAUSE;
const totalDurationEl = document.getElementById('total-duration'); player.updateMediaSessionPlaybackState();
totalDurationEl.textContent = formatTime(audioPlayer.duration); player.updateMediaSessionPositionState();
player.updateMediaSessionPositionState(); updateTabTitle(player);
}); });
audioPlayer.addEventListener('error', async (e) => { element.addEventListener('playing', () => {
console.error('Audio playback error:', e); if (player.activeElement !== element) return;
playPauseBtn.innerHTML = SVG_PLAY; player.updateMediaSessionPlaybackState();
player.updateMediaSessionPositionState();
});
const currentQuality = player.quality; element.addEventListener('pause', () => {
if (player.activeElement !== element) return;
if (player.currentTrack) {
trackPauseTrack(player.currentTrack);
}
playPauseBtn.innerHTML = SVG_PLAY;
player.updateMediaSessionPlaybackState();
player.updateMediaSessionPositionState();
});
// Check if we can fallback to a lower quality element.addEventListener('ended', () => {
if ( if (player.activeElement !== element) return;
player.currentTrack && player.playNext();
currentQuality === 'HI_RES_LOSSLESS' && });
!player.currentTrack.isLocal &&
!player.currentTrack.isTracker &&
!player.isFallbackRetry
) {
console.warn('Playback failed, attempting fallback to LOSSLESS quality...');
player.isFallbackRetry = true; // Set flag to prevent infinite loops
try { element.addEventListener('timeupdate', async () => {
// Force getTrack to fetch new URL for LOSSLESS if (player.activeElement !== element) return;
const trackId = player.currentTrack.id;
// Fetch new stream URL const { currentTime, duration } = element;
const newStreamUrl = await player.api.getStreamUrl(trackId, 'LOSSLESS'); if (duration) {
const progressFill = document.getElementById('progress-fill');
const currentTimeEl = document.getElementById('current-time');
progressFill.style.width = `${(currentTime / duration) * 100}%`;
currentTimeEl.textContent = formatTime(currentTime);
if (newStreamUrl) { // Log to history after 10 seconds of playback
// Reset player state for standard playback (non-DASH if possible) if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) {
if (player.dashInitialized) { historyLoggedTrackId = player.currentTrack.id;
player.dashPlayer.reset(); const historyEntry = await db.addToHistory(player.currentTrack);
player.dashInitialized = false; syncManager.syncHistoryItem(historyEntry);
if (window.location.hash === '#recent') {
ui.renderRecentPage();
} }
audioPlayer.src = newStreamUrl;
audioPlayer.load();
await audioPlayer.play();
// Reset flag after successful start
setTimeout(() => {
player.isFallbackRetry = false;
}, 5000);
return; // Successfully handled
} }
} catch (fallbackError) {
console.error('Fallback failed:', fallbackError);
} }
} });
player.isFallbackRetry = false; element.addEventListener('loadedmetadata', () => {
if (player.activeElement !== element) return;
const totalDurationEl = document.getElementById('total-duration');
totalDurationEl.textContent = formatTime(element.duration);
player.updateMediaSessionPositionState();
});
// Skip to next track on error to prevent queue stalling element.addEventListener('error', (e) => {
if (player.currentTrack) { if (player.activeElement !== element) return;
console.warn('Skipping to next track due to playback error');
setTimeout(() => player.playNext(), 1000); // Small delay to avoid rapid skipping if (!element.src) return;
}
}); const error = element.error;
let errorMsg = 'Unknown error';
if (error) {
switch (error.code) {
case 1:
errorMsg = 'Playback aborted';
break;
case 2:
errorMsg = 'Network error';
break;
case 3:
errorMsg = 'Decoding error';
break;
case 4:
errorMsg = 'Source not supported';
break;
}
if (error.message) errorMsg += `: ${error.message}`;
}
console.error(`Media playback error (${element.id}):`, errorMsg, e);
playPauseBtn.innerHTML = SVG_PLAY;
if (player.currentTrack && error && error.code !== 1) {
console.warn('Skipping to next track due to playback error');
setTimeout(() => player.playNext(), 1000);
}
});
element.addEventListener('volumechange', () => {
if (player.activeElement === element) {
updateVolumeUI();
}
});
};
setupMediaListeners(audioPlayer);
if (player.video) {
setupMediaListeners(player.video);
}
playPauseBtn.addEventListener('click', () => player.handlePlayPause()); playPauseBtn.addEventListener('click', () => player.handlePlayPause());
nextBtn.addEventListener('click', () => { nextBtn.addEventListener('click', () => {
@ -237,6 +246,12 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
mode === REPEAT_MODE.OFF ? 'Repeat' : mode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One'; mode === REPEAT_MODE.OFF ? 'Repeat' : mode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One';
}); });
window.addEventListener('radio-state-changed', (e) => {
if (e.detail && e.detail.enabled) {
showNotification('Infinite Radio Enabled');
}
});
// Sleep Timer for desktop // Sleep Timer for desktop
if (sleepTimerBtnDesktop) { if (sleepTimerBtnDesktop) {
sleepTimerBtnDesktop.addEventListener('click', () => { sleepTimerBtnDesktop.addEventListener('click', () => {
@ -263,11 +278,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
}); });
} }
// Volume controls
const volumeBar = document.getElementById('volume-bar');
const volumeFill = document.getElementById('volume-fill');
const volumeBtn = document.getElementById('volume-btn');
// Waveform Masking Logic // Waveform Masking Logic
const updateWaveform = async () => { const updateWaveform = async () => {
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
@ -374,37 +384,10 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
updateWaveform(); updateWaveform();
}); });
const updateVolumeUI = () => { initializeSmoothSliders(player);
const { muted } = audioPlayer;
const volume = player.userVolume;
volumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE : SVG_VOLUME;
const effectiveVolume = muted ? 0 : volume * 100;
volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
volumeFill.style.width = `${effectiveVolume}%`;
};
volumeBtn.addEventListener('click', () => {
audioPlayer.muted = !audioPlayer.muted;
localStorage.setItem('muted', audioPlayer.muted);
});
audioPlayer.addEventListener('volumechange', updateVolumeUI);
// Initialize volume and mute from localStorage
const savedVolume = parseFloat(localStorage.getItem('volume') || '0.7');
const savedMuted = localStorage.getItem('muted') === 'true';
player.setVolume(savedVolume);
audioPlayer.muted = savedMuted;
volumeFill.style.width = `${savedVolume * 100}%`;
volumeBar.style.setProperty('--volume-level', `${savedVolume * 100}%`);
updateVolumeUI();
initializeSmoothSliders(audioPlayer, player);
} }
function initializeSmoothSliders(audioPlayer, player) { function initializeSmoothSliders(player) {
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill'); const progressFill = document.getElementById('progress-fill');
const currentTimeEl = document.getElementById('current-time'); const currentTimeEl = document.getElementById('current-time');
@ -424,19 +407,21 @@ function initializeSmoothSliders(audioPlayer, player) {
}; };
const updateSeekUI = (position) => { const updateSeekUI = (position) => {
if (!isNaN(audioPlayer.duration)) { const activeEl = player.activeElement;
if (!isNaN(activeEl.duration)) {
progressFill.style.width = `${position * 100}%`; progressFill.style.width = `${position * 100}%`;
if (currentTimeEl) { if (currentTimeEl) {
currentTimeEl.textContent = formatTime(position * audioPlayer.duration); currentTimeEl.textContent = formatTime(position * activeEl.duration);
} }
} }
}; };
// Progress bar with smooth dragging // Progress bar with smooth dragging
progressBar.addEventListener('mousedown', (e) => { progressBar.addEventListener('mousedown', (e) => {
const activeEl = player.activeElement;
isSeeking = true; isSeeking = true;
wasPlaying = !audioPlayer.paused; wasPlaying = !activeEl.paused;
if (wasPlaying) audioPlayer.pause(); if (wasPlaying) activeEl.pause();
seek(progressBar, e, (position) => { seek(progressBar, e, (position) => {
lastSeekPosition = position; lastSeekPosition = position;
@ -446,10 +431,11 @@ function initializeSmoothSliders(audioPlayer, player) {
// Touch events for mobile // Touch events for mobile
progressBar.addEventListener('touchstart', (e) => { progressBar.addEventListener('touchstart', (e) => {
const activeEl = player.activeElement;
e.preventDefault(); e.preventDefault();
isSeeking = true; isSeeking = true;
wasPlaying = !audioPlayer.paused; wasPlaying = !activeEl.paused;
if (wasPlaying) audioPlayer.pause(); if (wasPlaying) activeEl.pause();
const touch = e.touches[0]; const touch = e.touches[0];
const rect = progressBar.getBoundingClientRect(); const rect = progressBar.getBoundingClientRect();
@ -469,9 +455,13 @@ function initializeSmoothSliders(audioPlayer, player) {
if (isAdjustingVolume) { if (isAdjustingVolume) {
seek(volumeBar, e, (position) => { seek(volumeBar, e, (position) => {
if (audioPlayer.muted) { const activeEl = player.activeElement;
audioPlayer.muted = false; if (activeEl.muted) {
activeEl.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
if (inactiveEl) inactiveEl.muted = false;
} }
player.setVolume(position); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
@ -494,9 +484,13 @@ function initializeSmoothSliders(audioPlayer, player) {
const touch = e.touches[0]; const touch = e.touches[0];
const rect = volumeBar.getBoundingClientRect(); const rect = volumeBar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
if (audioPlayer.muted) { const activeEl = player.activeElement;
audioPlayer.muted = false; if (activeEl.muted) {
activeEl.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
if (inactiveEl) inactiveEl.muted = false;
} }
player.setVolume(position); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
@ -506,11 +500,12 @@ function initializeSmoothSliders(audioPlayer, player) {
document.addEventListener('mouseup', () => { document.addEventListener('mouseup', () => {
if (isSeeking) { if (isSeeking) {
const activeEl = player.activeElement;
// Commit the seek // Commit the seek
if (!isNaN(audioPlayer.duration)) { if (!isNaN(activeEl.duration)) {
audioPlayer.currentTime = lastSeekPosition * audioPlayer.duration; activeEl.currentTime = lastSeekPosition * activeEl.duration;
player.updateMediaSessionPositionState(); player.updateMediaSessionPositionState();
if (wasPlaying) audioPlayer.play(); if (wasPlaying) activeEl.play();
} }
isSeeking = false; isSeeking = false;
} }
@ -522,10 +517,11 @@ function initializeSmoothSliders(audioPlayer, player) {
document.addEventListener('touchend', () => { document.addEventListener('touchend', () => {
if (isSeeking) { if (isSeeking) {
if (!isNaN(audioPlayer.duration)) { const activeEl = player.activeElement;
audioPlayer.currentTime = lastSeekPosition * audioPlayer.duration; if (!isNaN(activeEl.duration)) {
activeEl.currentTime = lastSeekPosition * activeEl.duration;
player.updateMediaSessionPositionState(); player.updateMediaSessionPositionState();
if (wasPlaying) audioPlayer.play(); if (wasPlaying) activeEl.play();
} }
isSeeking = false; isSeeking = false;
} }
@ -537,10 +533,11 @@ function initializeSmoothSliders(audioPlayer, player) {
progressBar.addEventListener('click', (e) => { progressBar.addEventListener('click', (e) => {
if (!isSeeking) { if (!isSeeking) {
const activeEl = player.activeElement;
// Only handle click if not result of a drag release // Only handle click if not result of a drag release
seek(progressBar, e, (position) => { seek(progressBar, e, (position) => {
if (!isNaN(audioPlayer.duration) && audioPlayer.duration > 0 && audioPlayer.duration !== Infinity) { if (!isNaN(activeEl.duration) && activeEl.duration > 0 && activeEl.duration !== Infinity) {
audioPlayer.currentTime = position * audioPlayer.duration; activeEl.currentTime = position * activeEl.duration;
player.updateMediaSessionPositionState(); player.updateMediaSessionPositionState();
} else if (player.currentTrack && player.currentTrack.duration) { } else if (player.currentTrack && player.currentTrack.duration) {
const targetTime = position * player.currentTrack.duration; const targetTime = position * player.currentTrack.duration;
@ -555,9 +552,13 @@ function initializeSmoothSliders(audioPlayer, player) {
volumeBar.addEventListener('mousedown', (e) => { volumeBar.addEventListener('mousedown', (e) => {
isAdjustingVolume = true; isAdjustingVolume = true;
seek(volumeBar, e, (position) => { seek(volumeBar, e, (position) => {
if (audioPlayer.muted) { const activeEl = player.activeElement;
audioPlayer.muted = false; if (activeEl.muted) {
activeEl.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
if (inactiveEl) inactiveEl.muted = false;
} }
player.setVolume(position); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
@ -571,9 +572,13 @@ function initializeSmoothSliders(audioPlayer, player) {
const touch = e.touches[0]; const touch = e.touches[0];
const rect = volumeBar.getBoundingClientRect(); const rect = volumeBar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
if (audioPlayer.muted) { const activeEl = player.activeElement;
audioPlayer.muted = false; if (activeEl.muted) {
activeEl.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
if (inactiveEl) inactiveEl.muted = false;
} }
player.setVolume(position); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
@ -583,9 +588,13 @@ function initializeSmoothSliders(audioPlayer, player) {
volumeBar.addEventListener('click', (e) => { volumeBar.addEventListener('click', (e) => {
if (!isAdjustingVolume) { if (!isAdjustingVolume) {
seek(volumeBar, e, (position) => { seek(volumeBar, e, (position) => {
if (audioPlayer.muted) { const activeEl = player.activeElement;
audioPlayer.muted = false; if (activeEl.muted) {
activeEl.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
if (inactiveEl) inactiveEl.muted = false;
} }
player.setVolume(position); player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
@ -599,10 +608,14 @@ function initializeSmoothSliders(audioPlayer, player) {
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));
const activeEl = player.activeElement;
if (delta > 0 && audioPlayer.muted) { if (delta > 0 && activeEl.muted) {
audioPlayer.muted = false; activeEl.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
if (inactiveEl) inactiveEl.muted = false;
} }
player.setVolume(newVolume); player.setVolume(newVolume);
@ -618,10 +631,14 @@ function initializeSmoothSliders(audioPlayer, player) {
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));
const activeEl = player.activeElement;
if (delta > 0 && audioPlayer.muted) { if (delta > 0 && activeEl.muted) {
audioPlayer.muted = false; activeEl.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
if (inactiveEl) inactiveEl.muted = false;
} }
player.setVolume(newVolume); player.setVolume(newVolume);
@ -768,12 +785,47 @@ export async function handleTrackAction(
if (!item) return; if (!item) return;
// Actions not allowed for unavailable tracks // Actions not allowed for unavailable tracks
const forbiddenForUnavailable = ['add-to-queue', 'play-next', 'track-mix', 'download']; const forbiddenForUnavailable = [
'add-to-queue',
'play-next',
'track-mix',
'download',
'start-radio',
'start-infinite-radio',
];
if (item.isUnavailable && forbiddenForUnavailable.includes(action)) { if (item.isUnavailable && forbiddenForUnavailable.includes(action)) {
showNotification('This track is unavailable.'); showNotification('This track is unavailable.');
return; return;
} }
if (action === 'start-radio' || action === 'start-infinite-radio') {
let tracks = [];
if (type === 'track') {
tracks = [item];
} else if (item.tracks) {
tracks = item.tracks;
} else if (type === 'album') {
const data = await api.getAlbum(item.id);
tracks = data.tracks;
} else if (type === 'playlist') {
const data = await api.getPlaylist(item.uuid);
tracks = data.tracks;
} else if (type === 'user-playlist') {
const playlist = await db.getPlaylist(item.id);
tracks = playlist ? playlist.tracks : [];
}
if (tracks.length > 0) {
player.setQueue(tracks, 0);
player.playAtIndex(0);
player.enableRadio(tracks);
showNotification(`Started radio based on ${type}: ${item.title || item.name}`);
} else {
showNotification('Could not start infinite radio: No tracks found');
}
return;
}
if (action === 'track-mix' && type === 'track') { if (action === 'track-mix' && type === 'track') {
if (item.mixes && item.mixes.TRACK_MIX) { if (item.mixes && item.mixes.TRACK_MIX) {
navigate(`/mix/${item.mixes.TRACK_MIX}`); navigate(`/mix/${item.mixes.TRACK_MIX}`);

View file

@ -1,5 +1,5 @@
import { fetchBlobURL } from './utils'; import { fetchBlobURL } from './utils';
import FfmpegWorker from './ffmpeg.worker.js?worker' import FfmpegWorker from './ffmpeg.worker.js?worker';
const ffmpegBase = 'https://unpkg.com/@ffmpeg/core/dist/esm'; const ffmpegBase = 'https://unpkg.com/@ffmpeg/core/dist/esm';
const coreJs = `${ffmpegBase}/ffmpeg-core.js`; const coreJs = `${ffmpegBase}/ffmpeg-core.js`;
const coreWasm = `${ffmpegBase}/ffmpeg-core.wasm`; const coreWasm = `${ffmpegBase}/ffmpeg-core.wasm`;

View file

@ -1,4 +1,4 @@
import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType } from './utils.js'; import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType, getTrackCoverId } from './utils.js';
import { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts'; import { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
import { doTimed, doTimedAsync } from './doTimed.ts'; import { doTimed, doTimedAsync } from './doTimed.ts';
import { managers } from './app.js'; import { managers } from './app.js';
@ -10,11 +10,14 @@ export const METADATA_STRINGS = {
DEFAULT_ALBUM: 'Unknown Album', DEFAULT_ALBUM: 'Unknown Album',
}; };
export function prefetchMetadataObjects(track, api) { export function prefetchMetadataObjects(track, api, coverBlob = null) {
const _tagLib = fetchTagLib().catch(console.error); const _tagLib = fetchTagLib().catch(console.error);
const coverFetch = track?.album?.cover const coverId = getTrackCoverId(track);
? getCoverBlob(api, track.album.cover).catch(console.error) const coverFetch = coverBlob
: Promise.resolve(null); ? Promise.resolve(coverBlob)
: coverId
? getCoverBlob(api, coverId).catch(console.error)
: Promise.resolve(null);
const lyricsFetch = managers?.lyricsManager?.fetchLyrics?.(track.id, track)?.catch(console.error); const lyricsFetch = managers?.lyricsManager?.fetchLyrics?.(track.id, track)?.catch(console.error);
return { _tagLib, coverFetch, lyricsFetch }; return { _tagLib, coverFetch, lyricsFetch };

View file

@ -1,4 +1,4 @@
import { getCoverBlob, getTrackTitle } from './utils.js'; import { getCoverBlob, getTrackTitle, getTrackCoverId } from './utils.js';
export async function writeID3v2Tag(mp3Blob, metadata, coverBlob = null) { export async function writeID3v2Tag(mp3Blob, metadata, coverBlob = null) {
const frames = []; const frames = [];
@ -136,15 +136,16 @@ export function buildID3v2Tag(mp3Blob, frames) {
return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' }); return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' });
} }
export async function addMp3Metadata(mp3Blob, track, api) { export async function addMp3Metadata(mp3Blob, track, api, coverBlob = null) {
try { try {
let coverBlob = null; if (!coverBlob) {
const coverId = getTrackCoverId(track);
if (track.album?.cover) { if (coverId) {
try { try {
coverBlob = await getCoverBlob(api, track.album.cover); coverBlob = await getCoverBlob(api, coverId);
} catch (error) { } catch (error) {
console.warn('Failed to fetch album art for MP3:', error); console.warn('Failed to fetch album art for MP3:', error);
}
} }
} }

View file

@ -16,13 +16,16 @@ import {
trackDateSettings, trackDateSettings,
exponentialVolumeSettings, exponentialVolumeSettings,
audioEffectsSettings, audioEffectsSettings,
radioSettings,
} from './storage.js'; } from './storage.js';
import { audioContextManager } from './audio-context.js'; import { audioContextManager } from './audio-context.js';
import { db } from './db.js';
import Hls from 'hls.js'; import Hls from 'hls.js';
export class Player { export class Player {
constructor(audioElement, api, quality = 'HI_RES_LOSSLESS') { constructor(audioElement, api, quality = 'HI_RES_LOSSLESS') {
this.audio = audioElement; this.audio = audioElement;
this.video = document.getElementById('video-player');
this.api = api; this.api = api;
this.quality = quality; this.quality = quality;
this.queue = []; this.queue = [];
@ -53,6 +56,11 @@ export class Player {
this.audio.addEventListener('canplay', () => { this.audio.addEventListener('canplay', () => {
this.applyAudioEffects(); this.applyAudioEffects();
}); });
if (this.video) {
this.video.addEventListener('canplay', () => {
this.applyAudioEffects();
});
}
// Initialize dash.js player // Initialize dash.js player
this.dashPlayer = MediaPlayer().create(); this.dashPlayer = MediaPlayer().create();
@ -68,24 +76,56 @@ export class Player {
this.loadQueueState(); this.loadQueueState();
this.setupMediaSession(); this.setupMediaSession();
this.radioEnabled = radioSettings.isEnabled();
this.radioSeeds = [];
this.isFetchingRadio = false;
this.radioFetchPromise = null;
this.playbackSequence = 0;
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
this.saveQueueState(); this.saveQueueState();
}); });
// Handle visibility change for iOS - AudioContext gets suspended when screen locks // Handle visibility change for iOS - AudioContext gets suspended when screen locks
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !this.audio.paused) { const el = this.activeElement;
if (document.visibilityState === 'visible' && !el.paused) {
// Ensure audio context is resumed when user returns to the app // Ensure audio context is resumed when user returns to the app
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio); audioContextManager.init(el);
} }
audioContextManager.resume(); audioContextManager.resume();
} }
if (document.visibilityState === 'visible' && this.autoplayBlocked) { if (document.visibilityState === 'visible' && this.autoplayBlocked) {
this.autoplayBlocked = false; this.autoplayBlocked = false;
this.audio.play().catch(() => {}); el.play().catch(() => {});
} }
}); });
this._setupVideoSync();
}
_setupVideoSync() {
if (!this.video || !this.audio) return;
const eventsToSync = ['timeupdate', 'seeking', 'seeked', 'volumechange'];
eventsToSync.forEach((eventName) => {
this.video.addEventListener(eventName, (e) => {
if (this.currentTrack?.type === 'video') {
if (eventName === 'timeupdate' || eventName === 'seeking' || eventName === 'seeked') {
try {
if (this.video.readyState >= 2 && (this.audio.readyState > 0 || this.audio.src)) {
this.audio.currentTime = this.video.currentTime;
}
} catch (err) {}
}
const syncedEvent = new Event(eventName, { bubbles: e.bubbles, cancelable: e.cancelable });
this.audio.dispatchEvent(syncedEvent);
}
});
});
} }
setVolume(value) { setVolume(value) {
@ -128,38 +168,39 @@ export class Player {
// Calculate effective volume // Calculate effective volume
const effectiveVolume = curvedVolume * scale; const effectiveVolume = curvedVolume * scale;
const el = this.activeElement;
// Apply to audio element and/or Web Audio graph // Apply to audio element and/or Web Audio graph
if (audioContextManager.isReady()) { if (audioContextManager.isReady()) {
// If Web Audio is active, we apply volume there for better compatibility // If Web Audio is active, we apply volume there for better compatibility
// Especially on Linux where audio.volume might not affect the Web Audio graph // Especially on Linux where audio.volume might not affect the Web Audio graph
// We set audio.volume to 1.0 to avoid double-reduction, or keep it synced? el.volume = 1.0;
// Some browsers require audio.volume to be set for system media controls to show volume
this.audio.volume = 1.0;
audioContextManager.setVolume(effectiveVolume); audioContextManager.setVolume(effectiveVolume);
} else { } else {
this.audio.volume = Math.max(0, Math.min(1, effectiveVolume)); el.volume = Math.max(0, Math.min(1, effectiveVolume));
} }
} }
applyAudioEffects() { applyAudioEffects() {
const speed = audioEffectsSettings.getSpeed(); const speed = audioEffectsSettings.getSpeed();
const el = this.activeElement;
if (this.dashInitialized && this.dashPlayer) { if (this.dashInitialized && this.dashPlayer) {
if (this.dashPlayer.getPlaybackRate() !== speed) { if (this.dashPlayer.getPlaybackRate() !== speed) {
this.dashPlayer.setPlaybackRate(speed); this.dashPlayer.setPlaybackRate(speed);
} }
} else { } else {
if (this.audio.playbackRate !== speed) { if (el.playbackRate !== speed) {
this.audio.playbackRate = speed; el.playbackRate = speed;
} }
} }
const preservePitch = audioEffectsSettings.isPreservePitchEnabled(); const preservePitch = audioEffectsSettings.isPreservePitchEnabled();
if (this.audio.preservesPitch !== preservePitch) { if (el.preservesPitch !== preservePitch) {
this.audio.preservesPitch = preservePitch; el.preservesPitch = preservePitch;
// Firefox support // Firefox support
if (this.audio.mozPreservesPitch !== undefined) { if (el.mozPreservesPitch !== undefined) {
this.audio.mozPreservesPitch = preservePitch; el.mozPreservesPitch = preservePitch;
} }
} }
} }
@ -288,16 +329,17 @@ export class Player {
const setHandlers = () => { const setHandlers = () => {
navigator.mediaSession.setActionHandler('play', async () => { navigator.mediaSession.setActionHandler('play', async () => {
const el = this.activeElement;
// Initialize and resume audio context first (required for iOS lock screen) // Initialize and resume audio context first (required for iOS lock screen)
// Must happen before audio.play() or audio won't route through Web Audio // Must happen before audio.play() or audio won't route through Web Audio
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio); audioContextManager.init(el);
this.applyReplayGain(); this.applyReplayGain();
} }
await audioContextManager.resume(); await audioContextManager.resume();
try { try {
await this.audio.play(); await el.play();
} catch (e) { } catch (e) {
console.error('MediaSession play failed:', e); console.error('MediaSession play failed:', e);
// If play fails, try to handle it like a regular play/pause // If play fails, try to handle it like a regular play/pause
@ -306,13 +348,13 @@ export class Player {
}); });
navigator.mediaSession.setActionHandler('pause', () => { navigator.mediaSession.setActionHandler('pause', () => {
this.audio.pause(); this.activeElement.pause();
}); });
navigator.mediaSession.setActionHandler('previoustrack', async () => { navigator.mediaSession.setActionHandler('previoustrack', async () => {
// Ensure audio context is active for iOS lock screen controls // Ensure audio context is active for iOS lock screen controls
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio); audioContextManager.init(this.activeElement);
this.applyReplayGain(); this.applyReplayGain();
} }
await audioContextManager.resume(); await audioContextManager.resume();
@ -322,7 +364,7 @@ export class Player {
navigator.mediaSession.setActionHandler('nexttrack', async () => { navigator.mediaSession.setActionHandler('nexttrack', async () => {
// Ensure audio context is active for iOS lock screen controls // Ensure audio context is active for iOS lock screen controls
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
audioContextManager.init(this.audio); audioContextManager.init(this.activeElement);
this.applyReplayGain(); this.applyReplayGain();
} }
await audioContextManager.resume(); await audioContextManager.resume();
@ -342,14 +384,14 @@ export class Player {
navigator.mediaSession.setActionHandler('seekto', (details) => { navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.seekTime !== undefined) { if (details.seekTime !== undefined) {
this.audio.currentTime = Math.max(0, details.seekTime); this.activeElement.currentTime = Math.max(0, details.seekTime);
this.updateMediaSessionPositionState(); this.updateMediaSessionPositionState();
} }
}); });
navigator.mediaSession.setActionHandler('stop', () => { navigator.mediaSession.setActionHandler('stop', () => {
this.audio.pause(); this.activeElement.pause();
this.audio.currentTime = 0; this.activeElement.currentTime = 0;
this.updateMediaSessionPlaybackState(); this.updateMediaSessionPlaybackState();
}); });
}; };
@ -358,6 +400,9 @@ export class Player {
// iOS: set handlers only when playback starts. Setting them in the constructor makes // iOS: set handlers only when playback starts. Setting them in the constructor makes
// the lock screen show +10/-10. Registering on first 'playing' gives next/previous track // the lock screen show +10/-10. Registering on first 'playing' gives next/previous track
this.audio.addEventListener('playing', () => setHandlers(), { once: true }); this.audio.addEventListener('playing', () => setHandlers(), { once: true });
if (this.video) {
this.video.addEventListener('playing', () => setHandlers(), { once: true });
}
} else { } else {
setHandlers(); setHandlers();
} }
@ -420,9 +465,7 @@ export class Player {
this.hls = new Hls(); this.hls = new Hls();
this.hls.loadSource(url); this.hls.loadSource(url);
this.hls.attachMedia(video); this.hls.attachMedia(video);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => { this.hls.on(Hls.Events.MANIFEST_PARSED, () => {});
video.play().catch(() => {});
});
this.hls.on(Hls.Events.ERROR, (event, data) => { this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) { if (data.fatal) {
console.warn('HLS fatal error:', data.type); console.warn('HLS fatal error:', data.type);
@ -461,6 +504,7 @@ export class Player {
} }
async playTrackFromQueue(startTime = 0, recursiveCount = 0) { async playTrackFromQueue(startTime = 0, recursiveCount = 0) {
const currentSequence = ++this.playbackSequence;
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) { if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
return; return;
@ -490,25 +534,55 @@ export class Player {
const yearDisplay = getTrackYearDisplay(track); const yearDisplay = getTrackYearDisplay(track);
const trackInfo = document.querySelector('.now-playing-bar .track-info'); const trackInfo = document.querySelector('.now-playing-bar .track-info');
const coverEl = trackInfo?.querySelector('.cover:not(#audio-player)'); const coverEl = trackInfo?.querySelector('.cover:not(#audio-player):not(#video-player)');
if (track.type === 'video') { const isVideoTrack = track.type === 'video';
const activeElement = isVideoTrack ? this.video : this.audio;
const inactiveElement = isVideoTrack ? this.audio : this.video;
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
if (this.dashInitialized) {
this.dashPlayer.reset();
this.dashInitialized = false;
}
if (inactiveElement) {
inactiveElement.pause();
inactiveElement.src = '';
inactiveElement.removeAttribute('src');
inactiveElement.style.display = 'none';
if (inactiveElement.parentElement !== document.body) {
document.body.appendChild(inactiveElement);
}
}
if (activeElement) {
activeElement.pause();
activeElement.src = '';
activeElement.removeAttribute('src');
}
audioContextManager.changeSource(activeElement);
if (isVideoTrack) {
if (coverEl) coverEl.style.display = 'none'; if (coverEl) coverEl.style.display = 'none';
if (this.audio) { if (this.video) {
const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex'; const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';
if (!isInFullscreen) { if (!isInFullscreen) {
this.audio.style.display = 'block'; this.video.style.display = 'block';
this.audio.className = 'cover video-cover-mirror'; this.video.className = 'cover video-cover-mirror';
this.audio.style.width = '56px'; this.video.style.width = '56px';
this.audio.style.height = '56px'; this.video.style.height = '56px';
this.audio.style.borderRadius = 'var(--radius-sm)'; this.video.style.borderRadius = 'var(--radius-sm)';
this.audio.style.objectFit = 'cover'; this.video.style.objectFit = 'cover';
this.audio.style.gridArea = 'none'; this.video.style.gridArea = 'none';
this.audio.muted = false; this.video.muted = false;
if (trackInfo && this.audio.parentElement !== trackInfo) { if (trackInfo && this.video.parentElement !== trackInfo) {
trackInfo.insertBefore(this.audio, trackInfo.firstChild); trackInfo.insertBefore(this.video, trackInfo.firstChild);
} }
} }
} }
@ -522,9 +596,6 @@ export class Player {
const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex'; const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';
if (!isInFullscreen) { if (!isInFullscreen) {
this.audio.style.display = 'none'; this.audio.style.display = 'none';
if (this.audio.parentElement !== document.body) {
document.body.appendChild(this.audio);
}
} }
} }
} }
@ -566,10 +637,6 @@ export class Player {
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-')); const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
if (isTracker || (track.audioUrl && !track.isLocal)) { if (isTracker || (track.audioUrl && !track.isLocal)) {
if (this.dashInitialized) {
this.dashPlayer.reset();
this.dashInitialized = false;
}
streamUrl = track.audioUrl; streamUrl = track.audioUrl;
if ( if (
@ -598,83 +665,80 @@ export class Player {
} }
} }
if (this.playbackSequence !== currentSequence) return;
this.currentRgValues = null; this.currentRgValues = null;
this.applyReplayGain(); this.applyReplayGain();
this.audio.src = streamUrl; activeElement.src = streamUrl;
this.applyAudioEffects(); this.applyAudioEffects();
// Wait for audio to be ready before playing (prevents restart issues with blob URLs) // Wait for audio to be ready before playing (prevents restart issues with blob URLs)
const canPlay = await this.waitForCanPlayOrTimeout(); const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
if (!canPlay) return; if (!canPlay || this.playbackSequence !== currentSequence) return;
if (startTime > 0) { if (startTime > 0) {
this.audio.currentTime = startTime; activeElement.currentTime = startTime;
} }
const played = await this.safePlay(); const played = await this.safePlay(activeElement);
if (!played) return; if (!played) return;
} else if (track.isLocal && track.file) { } else if (track.isLocal && track.file) {
if (this.dashInitialized) {
this.dashPlayer.reset(); // Ensure dash is off
this.dashInitialized = false;
}
streamUrl = URL.createObjectURL(track.file); streamUrl = URL.createObjectURL(track.file);
if (this.playbackSequence !== currentSequence) return;
this.currentRgValues = null; // No replaygain for local files yet this.currentRgValues = null; // No replaygain for local files yet
this.applyReplayGain(); this.applyReplayGain();
this.audio.src = streamUrl; activeElement.src = streamUrl;
this.applyAudioEffects(); this.applyAudioEffects();
// Wait for audio to be ready before playing // Wait for audio to be ready before playing
const canPlay = await this.waitForCanPlayOrTimeout(); const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
if (!canPlay) return; if (!canPlay || this.playbackSequence !== currentSequence) return;
if (startTime > 0) { if (startTime > 0) {
this.audio.currentTime = startTime; activeElement.currentTime = startTime;
} }
const played = await this.safePlay(); const played = await this.safePlay(activeElement);
if (!played) return; if (!played) return;
} else if (track.type === 'video') { } else if (track.type === 'video') {
if (this.dashInitialized) { if (window.monochromeUi) {
this.dashPlayer.reset(); const isInFullscreen =
this.dashInitialized = false; document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';
} if (!isInFullscreen) {
if (this.hls) { const lyricsManager = window.monochromeUi.lyricsManager;
this.hls.destroy(); window.monochromeUi.showFullscreenCover(
this.hls = null; track,
this.getNextTrack(),
lyricsManager,
activeElement
);
}
} }
streamUrl = await this.api.getVideoStreamUrl(track.id); streamUrl = await this.api.getVideoStreamUrl(track.id);
if (this.playbackSequence !== currentSequence) return;
if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) { if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
this.setupHlsVideo(this.audio, streamUrl, null); this.setupHlsVideo(activeElement, streamUrl, null);
} else if (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) { } else if (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) {
this.dashPlayer.initialize(this.audio, streamUrl, true); this.dashPlayer.initialize(activeElement, streamUrl, false);
this.dashInitialized = true; this.dashInitialized = true;
} else { } else {
this.audio.src = streamUrl; activeElement.src = streamUrl;
} }
this.applyAudioEffects(); this.applyAudioEffects();
if (window.monochromeUi) { const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
const lyricsManager = window.monochromeUi.lyricsManager; if (!canPlay || this.playbackSequence !== currentSequence) return;
window.monochromeUi.showFullscreenCover(track, this.getNextTrack(), lyricsManager, this.audio);
}
const canPlay = await this.waitForCanPlayOrTimeout();
if (!canPlay) return;
if (startTime > 0) { if (startTime > 0) {
this.audio.currentTime = startTime; activeElement.currentTime = startTime;
} }
await this.safePlay(); await this.safePlay(activeElement);
} else { } else {
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
const isQobuz = String(track.id).startsWith('q:'); const isQobuz = String(track.id).startsWith('q:');
if (isQobuz) { if (isQobuz) {
@ -690,6 +754,7 @@ export class Player {
} else { } else {
// Tidal: Get track data for ReplayGain (should be cached by API) // Tidal: Get track data for ReplayGain (should be cached by API)
const trackData = await this.api.getTrack(track.id, this.quality); const trackData = await this.api.getTrack(track.id, this.quality);
if (this.playbackSequence !== currentSequence) return;
if (trackData && trackData.info) { if (trackData && trackData.info) {
this.currentRgValues = { this.currentRgValues = {
@ -714,42 +779,41 @@ export class Player {
} }
} }
if (this.playbackSequence !== currentSequence) return;
// Handle playback // Handle playback
if (streamUrl && streamUrl.startsWith('blob:') && !track.isLocal) { if (streamUrl && streamUrl.startsWith('blob:') && !track.isLocal) {
// It's likely a DASH manifest blob URL // It's likely a DASH manifest blob URL
if (this.dashInitialized) { this.dashPlayer.initialize(activeElement, streamUrl, false);
this.dashPlayer.attachSource(streamUrl); this.dashInitialized = true;
} else {
this.dashPlayer.initialize(this.audio, streamUrl, true);
this.dashInitialized = true;
}
this.applyAudioEffects(); this.applyAudioEffects();
if (startTime > 0) { if (startTime > 0) {
this.dashPlayer.seek(startTime); this.dashPlayer.seek(startTime);
} }
const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
if (!canPlay || this.playbackSequence !== currentSequence) return;
await this.safePlay(activeElement);
} else { } else {
if (this.dashInitialized) { activeElement.src = streamUrl;
this.dashPlayer.reset();
this.dashInitialized = false;
}
this.audio.src = streamUrl;
this.applyAudioEffects(); this.applyAudioEffects();
// Wait for audio to be ready before playing // Wait for audio to be ready before playing
const canPlay = await this.waitForCanPlayOrTimeout(); const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
if (!canPlay) return; if (!canPlay || this.playbackSequence !== currentSequence) return;
if (startTime > 0) { if (startTime > 0) {
this.audio.currentTime = startTime; activeElement.currentTime = startTime;
} }
const played = await this.safePlay(); const played = await this.safePlay(activeElement);
if (!played) return; if (!played) return;
} }
} }
this.preloadNextTracks(); this.preloadNextTracks();
} catch (error) { } catch (error) {
if (this.playbackSequence !== currentSequence) return;
if (error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) { if (error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) {
this.autoplayBlocked = true; this.autoplayBlocked = true;
return; return;
@ -771,12 +835,25 @@ export class Player {
} }
playNext(recursiveCount = 0) { playNext(recursiveCount = 0) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.getCurrentQueue();
const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1; const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1;
if (this.radioEnabled && this.currentQueueIndex >= currentQueue.length - 3) {
this.fetchRadioRecommendations();
}
if (recursiveCount > currentQueue.length) { if (recursiveCount > currentQueue.length) {
if (this.radioEnabled && isLastTrack) {
this.fetchRadioRecommendations().then(() => {
const updatedQueue = this.getCurrentQueue();
if (this.currentQueueIndex < updatedQueue.length - 1) {
this.playNext(0);
}
});
return;
}
console.error('All tracks in queue are unavailable or blocked.'); console.error('All tracks in queue are unavailable or blocked.');
this.audio.pause(); this.activeElement.pause();
return; return;
} }
@ -798,6 +875,14 @@ export class Player {
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) { if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
return this.playNext(recursiveCount + 1); return this.playNext(recursiveCount + 1);
} }
} else if (this.radioEnabled) {
this.fetchRadioRecommendations().then(() => {
const updatedQueue = this.getCurrentQueue();
if (this.currentQueueIndex < updatedQueue.length - 1) {
this.playNext(0);
}
});
return;
} else if (this.repeatMode === REPEAT_MODE.ALL) { } else if (this.repeatMode === REPEAT_MODE.ALL) {
this.currentQueueIndex = 0; this.currentQueueIndex = 0;
const track = currentQueue[this.currentQueueIndex]; const track = currentQueue[this.currentQueueIndex];
@ -813,9 +898,161 @@ export class Player {
}); });
} }
async enableRadio(seeds = []) {
this.radioEnabled = true;
radioSettings.setEnabled(true);
if (seeds.length === 0) {
this.wipeQueue();
const pickedSeeds = await this.pickRadioSeeds();
if (pickedSeeds.length > 0) {
this.radioSeeds = pickedSeeds;
this.setQueue(pickedSeeds, 0, true);
this.playAtIndex(0);
}
} else {
this.radioSeeds = Array.isArray(seeds) ? seeds : [seeds];
this.wipeQueue();
this.setQueue(this.radioSeeds, 0, true);
this.playAtIndex(0);
}
const currentQueue = this.getCurrentQueue();
if (this.currentQueueIndex >= currentQueue.length - 2) {
await this.fetchRadioRecommendations();
}
window.dispatchEvent(new CustomEvent('radio-state-changed', { detail: { enabled: true } }));
}
disableRadio() {
if (!this.radioEnabled) return;
this.radioEnabled = false;
radioSettings.setEnabled(false);
window.dispatchEvent(new CustomEvent('radio-state-changed', { detail: { enabled: false } }));
}
fetchRadioRecommendations() {
if (this.isFetchingRadio) return this.radioFetchPromise || Promise.resolve();
this.isFetchingRadio = true;
this.showRadioLoading(true);
this.radioFetchPromise = (async () => {
try {
if (this.radioSeeds.length === 0) {
this.radioSeeds = await this.pickRadioSeeds();
}
const seeds =
this.radioSeeds.length > 0 ? this.radioSeeds : this.currentTrack ? [this.currentTrack] : [];
if (seeds.length === 0) {
return;
}
const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 10);
if (recommendations && recommendations.length > 0) {
const currentQueueIds = new Set(this.getCurrentQueue().map((t) => t.id));
const [favorites, userPlaylists, history] = await Promise.all([
db.getFavorites('track'),
db.getAll('user_playlists'),
db.getHistory(),
]);
const knownTrackIds = new Set([
...favorites.map((t) => t.id),
...userPlaylists.flatMap((p) => (p.tracks || []).map((t) => t.id)),
...history.map((t) => t.id),
]);
const newTracks = recommendations.filter((t) => {
if (currentQueueIds.has(t.id)) return false;
if (knownTrackIds.has(t.id)) {
return Math.random() < 0.05;
}
return true;
});
if (newTracks.length > 0) {
this.addToQueue(newTracks);
}
}
} catch (error) {
console.error('Failed to fetch radio recommendations:', error);
} finally {
this.isFetchingRadio = false;
this.radioFetchPromise = null;
setTimeout(() => this.showRadioLoading(false), 500);
}
})();
return this.radioFetchPromise;
}
async pickRadioSeeds() {
try {
const [history, favorites, userPlaylists] = await Promise.all([
db.getHistory(),
db.getFavorites('track'),
db.getAll('user_playlists'),
]);
let potentialSeeds = [];
if (history && history.length > 0) {
const frequencyMap = new Map();
history.forEach((t) => {
frequencyMap.set(t.id, (frequencyMap.get(t.id) || 0) + 1);
});
const historyTracks = Array.from(new Set(history.map((t) => t.id)))
.map((id) => history.find((t) => t.id === id))
.sort((a, b) => frequencyMap.get(b.id) - frequencyMap.get(a.id));
potentialSeeds.push(...historyTracks.slice(0, 20));
}
if (favorites && favorites.length > 0) {
potentialSeeds.push(...favorites);
}
if (userPlaylists && userPlaylists.length > 0) {
userPlaylists.forEach((p) => {
if (p.tracks && p.tracks.length > 0) {
const randomTracks = p.tracks.sort(() => 0.5 - Math.random()).slice(0, 5);
potentialSeeds.push(...randomTracks);
}
});
}
if (potentialSeeds.length === 0) return [];
const uniqueSeeds = Array.from(new Set(potentialSeeds.map((s) => s.id))).map((id) =>
potentialSeeds.find((s) => s.id === id)
);
return uniqueSeeds.sort(() => 0.5 - Math.random()).slice(0, 5);
} catch (error) {
console.error('Failed to pick radio seeds:', error);
return this.currentTrack ? [this.currentTrack] : [];
}
}
showRadioLoading(show) {
const loadingEl = document.getElementById('radio-loading-indicator');
if (loadingEl) {
loadingEl.style.display = show ? 'flex' : 'none';
}
}
playPrev(recursiveCount = 0) { playPrev(recursiveCount = 0) {
if (this.audio.currentTime > 3) { const el = this.activeElement;
this.audio.currentTime = 0; if (el.currentTime > 3) {
el.currentTime = 0;
this.updateMediaSessionPositionState(); this.updateMediaSessionPositionState();
} else if (this.currentQueueIndex > 0) { } else if (this.currentQueueIndex > 0) {
this.currentQueueIndex--; this.currentQueueIndex--;
@ -824,7 +1061,7 @@ export class Player {
if (recursiveCount > currentQueue.length) { if (recursiveCount > currentQueue.length) {
console.error('All tracks in queue are unavailable or blocked.'); console.error('All tracks in queue are unavailable or blocked.');
this.audio.pause(); el.pause();
return; return;
} }
@ -838,16 +1075,21 @@ export class Player {
} }
} }
get activeElement() {
return this.currentTrack?.type === 'video' ? this.video : this.audio;
}
handlePlayPause() { handlePlayPause() {
if (!this.audio.src || this.audio.error) { const el = this.activeElement;
if (!el.src || el.error) {
if (this.currentTrack) { if (this.currentTrack) {
this.playTrackFromQueue(0, 0); this.playTrackFromQueue(0, 0);
} }
return; return;
} }
if (this.audio.paused) { if (el.paused) {
this.safePlay().catch((e) => { this.safePlay(el).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) {
@ -855,21 +1097,23 @@ export class Player {
} }
}); });
} else { } else {
this.audio.pause(); el.pause();
this.saveQueueState(); this.saveQueueState();
} }
} }
seekBackward(seconds = 10) { seekBackward(seconds = 10) {
const newTime = Math.max(0, this.audio.currentTime - seconds); const el = this.activeElement;
this.audio.currentTime = newTime; const newTime = Math.max(0, el.currentTime - seconds);
el.currentTime = newTime;
this.updateMediaSessionPositionState(); this.updateMediaSessionPositionState();
} }
seekForward(seconds = 10) { seekForward(seconds = 10) {
const duration = this.audio.duration || 0; const el = this.activeElement;
const newTime = Math.min(duration, this.audio.currentTime + seconds); const duration = el.duration || 0;
this.audio.currentTime = newTime; const newTime = Math.min(duration, el.currentTime + seconds);
el.currentTime = newTime;
this.updateMediaSessionPositionState(); this.updateMediaSessionPositionState();
} }
@ -914,7 +1158,10 @@ export class Player {
return this.repeatMode; return this.repeatMode;
} }
setQueue(tracks, startIndex = 0) { setQueue(tracks, startIndex = 0, isRadio = false) {
if (!isRadio) {
this.disableRadio();
}
this.queue = tracks; this.queue = tracks;
this.currentQueueIndex = startIndex; this.currentQueueIndex = startIndex;
this.shuffleActive = false; this.shuffleActive = false;
@ -1008,8 +1255,9 @@ export class Player {
} }
wipeQueue() { wipeQueue() {
this.audio.pause(); const el = this.activeElement;
this.audio.src = ''; el.pause();
el.src = '';
this.currentTrack = null; this.currentTrack = null;
this.queue = []; this.queue = [];
this.shuffledQueue = []; this.shuffledQueue = [];
@ -1119,14 +1367,15 @@ export class Player {
updateMediaSessionPlaybackState() { updateMediaSessionPlaybackState() {
if (!('mediaSession' in navigator)) return; if (!('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing'; navigator.mediaSession.playbackState = this.activeElement.paused ? 'paused' : 'playing';
} }
updateMediaSessionPositionState() { updateMediaSessionPositionState() {
if (!('mediaSession' in navigator)) return; if (!('mediaSession' in navigator)) return;
if (!('setPositionState' in navigator.mediaSession)) return; if (!('setPositionState' in navigator.mediaSession)) return;
const duration = this.audio.duration; const el = this.activeElement;
const duration = el.duration;
if (!duration || isNaN(duration) || !isFinite(duration)) { if (!duration || isNaN(duration) || !isFinite(duration)) {
return; return;
@ -1135,17 +1384,17 @@ export class Player {
try { try {
navigator.mediaSession.setPositionState({ navigator.mediaSession.setPositionState({
duration: duration, duration: duration,
playbackRate: this.audio.playbackRate || 1, playbackRate: el.playbackRate || 1,
position: Math.min(this.audio.currentTime, duration), position: Math.min(el.currentTime, duration),
}); });
} catch (error) { } catch (error) {
console.log('Failed to update Media Session position:', error); console.log('Failed to update Media Session position:', error);
} }
} }
async safePlay() { async safePlay(element = this.activeElement) {
try { try {
await this.audio.play(); await element.play();
this.autoplayBlocked = false; this.autoplayBlocked = false;
return true; return true;
} catch (error) { } catch (error) {
@ -1157,29 +1406,29 @@ export class Player {
} }
} }
async waitForCanPlayOrTimeout(timeoutMs = 10000) { async waitForCanPlayOrTimeout(element = this.activeElement, timeoutMs = 10000) {
if (this.audio.readyState >= 2) { if (element.readyState >= 2) {
return true; return true;
} }
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const onCanPlay = () => { const onCanPlay = () => {
this.audio.removeEventListener('canplay', onCanPlay); element.removeEventListener('canplay', onCanPlay);
this.audio.removeEventListener('error', onError); element.removeEventListener('error', onError);
resolve(true); resolve(true);
}; };
const onError = (e) => { const onError = (e) => {
this.audio.removeEventListener('canplay', onCanPlay); element.removeEventListener('canplay', onCanPlay);
this.audio.removeEventListener('error', onError); element.removeEventListener('error', onError);
reject(e); reject(e);
}; };
this.audio.addEventListener('canplay', onCanPlay); element.addEventListener('canplay', onCanPlay);
this.audio.addEventListener('error', onError); element.addEventListener('error', onError);
// Timeout after 10 seconds. Treat as autoplay blocked when backgrounded (esp. iOS PWA). // Timeout after 10 seconds. Treat as autoplay blocked when backgrounded (esp. iOS PWA).
setTimeout(() => { setTimeout(() => {
this.audio.removeEventListener('canplay', onCanPlay); element.removeEventListener('canplay', onCanPlay);
this.audio.removeEventListener('error', onError); element.removeEventListener('error', onError);
if (document.visibilityState === 'hidden' || (this.isIOS && this.isPwa)) { if (document.visibilityState === 'hidden' || (this.isIOS && this.isPwa)) {
this.autoplayBlocked = true; this.autoplayBlocked = true;
resolve(false); resolve(false);
@ -1198,7 +1447,7 @@ export class Player {
this.sleepTimer = setTimeout( this.sleepTimer = setTimeout(
() => { () => {
this.audio.pause(); this.activeElement.pause();
this.clearSleepTimer(); this.clearSleepTimer();
this.updateSleepTimerUI(); this.updateSleepTimerUI();
}, },

View file

@ -1,6 +1,8 @@
// js/qobuz-api.js // js/qobuz-api.js
// Qobuz API integration for Monochrome Music // Qobuz API integration for Monochrome Music
// LMFAOOOO this shit is useless now qobuz killing accounts every time a dude takes their breath
const QOBUZ_API_BASE = 'https://qobuz.squid.wtf/api'; const QOBUZ_API_BASE = 'https://qobuz.squid.wtf/api';
export class QobuzAPI { export class QobuzAPI {

View file

@ -1663,6 +1663,22 @@ export const homePageSettings = {
}, },
}; };
export const radioSettings = {
ENABLED_KEY: 'radio-enabled',
isEnabled() {
try {
return localStorage.getItem(this.ENABLED_KEY) === 'true';
} catch {
return false;
}
},
setEnabled(enabled) {
localStorage.setItem(this.ENABLED_KEY, enabled ? 'true' : 'false');
},
};
export const analyticsSettings = { export const analyticsSettings = {
ENABLED_KEY: 'analytics-enabled', ENABLED_KEY: 'analytics-enabled',

227
js/ui.js
View file

@ -112,6 +112,7 @@ export class UIRenderer {
this.vibrantColorCache = new Map(); this.vibrantColorCache = new Map();
this.visualizer = null; this.visualizer = null;
this.renderLock = false; this.renderLock = false;
this.lastRecommendedTracks = [];
// Listen for dynamic color reset events // Listen for dynamic color reset events
window.addEventListener('reset-dynamic-color', () => { window.addEventListener('reset-dynamic-color', () => {
@ -988,13 +989,13 @@ export class UIRenderer {
if (videoContainer) { if (videoContainer) {
videoContainer.style.display = 'flex'; videoContainer.style.display = 'flex';
const audioPlayer = document.getElementById('audio-player'); const videoPlayer = document.getElementById('video-player');
if (audioPlayer && audioPlayer.parentElement !== videoContainer) { if (videoPlayer && videoPlayer.parentElement !== videoContainer) {
videoContainer.appendChild(audioPlayer); videoContainer.appendChild(videoPlayer);
audioPlayer.style.display = 'block'; videoPlayer.style.display = 'block';
audioPlayer.style.width = '100%'; videoPlayer.style.width = '100%';
audioPlayer.style.height = '100%'; videoPlayer.style.height = '100%';
audioPlayer.style.objectFit = 'contain'; videoPlayer.style.objectFit = 'contain';
} }
} }
if (image) image.style.display = 'none'; if (image) image.style.display = 'none';
@ -1002,10 +1003,10 @@ export class UIRenderer {
} else { } else {
if (videoContainer) { if (videoContainer) {
videoContainer.style.display = 'none'; videoContainer.style.display = 'none';
const audioPlayer = document.getElementById('audio-player'); const videoPlayer = document.getElementById('video-player');
if (audioPlayer && audioPlayer.parentElement === videoContainer) { if (videoPlayer && videoPlayer.parentElement === videoContainer) {
document.body.appendChild(audioPlayer); document.body.appendChild(videoPlayer);
audioPlayer.style.display = 'none'; videoPlayer.style.display = 'none';
} }
} }
if (image) image.style.display = 'block'; if (image) image.style.display = 'block';
@ -1025,11 +1026,11 @@ export class UIRenderer {
if (currentImage.tagName === 'IMG') { if (currentImage.tagName === 'IMG') {
const video = document.createElement('video'); const video = document.createElement('video');
video.src = videoCoverUrl; video.src = videoCoverUrl;
video.autoplay = false; video.autoplay = true;
video.loop = false; video.loop = true;
video.muted = true; video.muted = true;
video.playsInline = true; video.playsInline = true;
video.preload = 'metadata'; video.preload = 'auto';
video.className = currentImage.className; video.className = currentImage.className;
currentImage.replaceWith(video); currentImage.replaceWith(video);
} else if (currentImage.src !== videoCoverUrl) { } else if (currentImage.src !== videoCoverUrl) {
@ -1062,7 +1063,7 @@ export class UIRenderer {
} }
} }
async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) { async showFullscreenCover(track, nextTrack, lyricsManager, activeElement) {
if (!track) return; if (!track) return;
if (window.location.hash !== '#fullscreen') { if (window.location.hash !== '#fullscreen') {
window.history.pushState({ fullscreen: true }, '', '#fullscreen'); window.history.pushState({ fullscreen: true }, '', '#fullscreen');
@ -1081,12 +1082,12 @@ export class UIRenderer {
nextTrackEl.classList.remove('animate-in'); nextTrackEl.classList.remove('animate-in');
} }
if (lyricsManager && audioPlayer) { if (lyricsManager && activeElement) {
lyricsToggleBtn.style.display = 'flex'; lyricsToggleBtn.style.display = 'flex';
lyricsToggleBtn.classList.remove('active'); lyricsToggleBtn.classList.remove('active');
const toggleLyrics = () => { const toggleLyrics = () => {
openLyricsPanel(track, audioPlayer, lyricsManager); openLyricsPanel(track, activeElement, lyricsManager);
lyricsToggleBtn.classList.toggle('active'); lyricsToggleBtn.classList.toggle('active');
}; };
@ -1100,7 +1101,7 @@ export class UIRenderer {
const playerBar = document.querySelector('.now-playing-bar'); const playerBar = document.querySelector('.now-playing-bar');
if (playerBar) playerBar.style.display = 'none'; if (playerBar) playerBar.style.display = 'none';
this.setupFullscreenControls(audioPlayer); this.setupFullscreenControls();
overlay.style.display = 'flex'; overlay.style.display = 'flex';
@ -1110,10 +1111,10 @@ export class UIRenderer {
return; return;
} }
if (!this.visualizer && audioPlayer) { if (!this.visualizer && activeElement) {
const canvas = document.getElementById('visualizer-canvas'); const canvas = document.getElementById('visualizer-canvas');
if (canvas) { if (canvas) {
this.visualizer = new Visualizer(canvas, audioPlayer); this.visualizer = new Visualizer(canvas, activeElement);
} }
} }
if (this.visualizer) { if (this.visualizer) {
@ -1162,22 +1163,22 @@ export class UIRenderer {
if (this.player?.currentTrack?.type === 'video') { if (this.player?.currentTrack?.type === 'video') {
const coverContainer = document.querySelector('.now-playing-bar .track-info'); const coverContainer = document.querySelector('.now-playing-bar .track-info');
const audioPlayer = document.getElementById('audio-player'); const videoPlayer = document.getElementById('video-player');
const imgCover = coverContainer?.querySelector('.cover:not(#audio-player)'); const imgCover = coverContainer?.querySelector('.cover:not(#audio-player):not(#video-player)');
if (audioPlayer && coverContainer) { if (videoPlayer && coverContainer) {
if (imgCover) imgCover.style.display = 'none'; if (imgCover) imgCover.style.display = 'none';
audioPlayer.style.display = 'block'; videoPlayer.style.display = 'block';
audioPlayer.classList.add('cover', 'video-cover-mirror'); videoPlayer.classList.add('cover', 'video-cover-mirror');
audioPlayer.style.width = '56px'; videoPlayer.style.width = '56px';
audioPlayer.style.height = '56px'; videoPlayer.style.height = '56px';
audioPlayer.style.borderRadius = 'var(--radius-sm)'; videoPlayer.style.borderRadius = 'var(--radius-sm)';
audioPlayer.style.objectFit = 'cover'; videoPlayer.style.objectFit = 'cover';
audioPlayer.style.gridArea = 'none'; videoPlayer.style.gridArea = 'none';
if (audioPlayer.parentElement !== coverContainer) { if (videoPlayer.parentElement !== coverContainer) {
coverContainer.insertBefore(audioPlayer, coverContainer.firstChild); coverContainer.insertBefore(videoPlayer, coverContainer.firstChild);
} }
} }
} }
@ -1289,7 +1290,7 @@ export class UIRenderer {
}; };
} }
setupFullscreenControls(audioPlayer) { setupFullscreenControls() {
const playBtn = document.getElementById('fs-play-pause-btn'); const playBtn = document.getElementById('fs-play-pause-btn');
const prevBtn = document.getElementById('fs-prev-btn'); const prevBtn = document.getElementById('fs-prev-btn');
const nextBtn = document.getElementById('fs-next-btn'); const nextBtn = document.getElementById('fs-next-btn');
@ -1318,7 +1319,8 @@ export class UIRenderer {
let lastPausedState = null; let lastPausedState = null;
const updatePlayBtn = () => { const updatePlayBtn = () => {
const isPaused = audioPlayer.paused; const activeEl = this.player.activeElement;
const isPaused = activeEl.paused;
if (isPaused === lastPausedState) return; if (isPaused === lastPausedState) return;
lastPausedState = isPaused; lastPausedState = isPaused;
@ -1364,18 +1366,20 @@ export class UIRenderer {
let lastFsSeekPosition = 0; let lastFsSeekPosition = 0;
const updateFsSeekUI = (position) => { const updateFsSeekUI = (position) => {
if (!isNaN(audioPlayer.duration)) { const activeEl = this.player.activeElement;
if (!isNaN(activeEl.duration)) {
progressFill.style.width = `${position * 100}%`; progressFill.style.width = `${position * 100}%`;
if (currentTimeEl) { if (currentTimeEl) {
currentTimeEl.textContent = formatTime(position * audioPlayer.duration); currentTimeEl.textContent = formatTime(position * activeEl.duration);
} }
} }
}; };
progressBar.addEventListener('mousedown', (e) => { progressBar.addEventListener('mousedown', (e) => {
const activeEl = this.player.activeElement;
isFsSeeking = true; isFsSeeking = true;
wasFsPlaying = !audioPlayer.paused; wasFsPlaying = !activeEl.paused;
if (wasFsPlaying) audioPlayer.pause(); if (wasFsPlaying) activeEl.pause();
const rect = progressBar.getBoundingClientRect(); const rect = progressBar.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
@ -1386,10 +1390,11 @@ export class UIRenderer {
progressBar.addEventListener( progressBar.addEventListener(
'touchstart', 'touchstart',
(e) => { (e) => {
const activeEl = this.player.activeElement;
e.preventDefault(); e.preventDefault();
isFsSeeking = true; isFsSeeking = true;
wasFsPlaying = !audioPlayer.paused; wasFsPlaying = !activeEl.paused;
if (wasFsPlaying) audioPlayer.pause(); if (wasFsPlaying) activeEl.pause();
const touch = e.touches[0]; const touch = e.touches[0];
const rect = progressBar.getBoundingClientRect(); const rect = progressBar.getBoundingClientRect();
@ -1425,9 +1430,10 @@ export class UIRenderer {
document.addEventListener('mouseup', () => { document.addEventListener('mouseup', () => {
if (isFsSeeking) { if (isFsSeeking) {
if (!isNaN(audioPlayer.duration)) { const activeEl = this.player.activeElement;
audioPlayer.currentTime = lastFsSeekPosition * audioPlayer.duration; if (!isNaN(activeEl.duration)) {
if (wasFsPlaying) audioPlayer.play(); activeEl.currentTime = lastFsSeekPosition * activeEl.duration;
if (wasFsPlaying) activeEl.play();
} }
isFsSeeking = false; isFsSeeking = false;
} }
@ -1435,9 +1441,10 @@ export class UIRenderer {
document.addEventListener('touchend', () => { document.addEventListener('touchend', () => {
if (isFsSeeking) { if (isFsSeeking) {
if (!isNaN(audioPlayer.duration)) { const activeEl = this.player.activeElement;
audioPlayer.currentTime = lastFsSeekPosition * audioPlayer.duration; if (!isNaN(activeEl.duration)) {
if (wasFsPlaying) audioPlayer.play(); activeEl.currentTime = lastFsSeekPosition * activeEl.duration;
if (wasFsPlaying) activeEl.play();
} }
isFsSeeking = false; isFsSeeking = false;
} }
@ -1476,7 +1483,8 @@ export class UIRenderer {
if (fsVolumeBtn && fsVolumeBar && fsVolumeFill) { if (fsVolumeBtn && fsVolumeBar && fsVolumeFill) {
const updateFsVolumeUI = () => { const updateFsVolumeUI = () => {
const { muted } = audioPlayer; const activeEl = this.player.activeElement;
const { muted } = activeEl;
const volume = this.player.userVolume; const volume = this.player.userVolume;
fsVolumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE : SVG_VOLUME; fsVolumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE : SVG_VOLUME;
fsVolumeBtn.classList.toggle('muted', muted || volume === 0); fsVolumeBtn.classList.toggle('muted', muted || volume === 0);
@ -1486,8 +1494,9 @@ export class UIRenderer {
}; };
fsVolumeBtn.onclick = () => { fsVolumeBtn.onclick = () => {
audioPlayer.muted = !audioPlayer.muted; const activeEl = this.player.activeElement;
localStorage.setItem('muted', audioPlayer.muted); activeEl.muted = !activeEl.muted;
localStorage.setItem('muted', activeEl.muted);
updateFsVolumeUI(); updateFsVolumeUI();
}; };
@ -1498,8 +1507,9 @@ export class UIRenderer {
const currentVolume = this.player.userVolume; const currentVolume = this.player.userVolume;
const newVolume = Math.max(0, Math.min(1, currentVolume + delta)); const newVolume = Math.max(0, Math.min(1, currentVolume + delta));
if (delta > 0 && audioPlayer.muted) { const activeEl = this.player.activeElement;
audioPlayer.muted = false; if (delta > 0 && activeEl.muted) {
activeEl.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
} }
@ -1520,8 +1530,9 @@ export class UIRenderer {
const position = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const position = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const newVolume = position; const newVolume = position;
this.player.setVolume(newVolume); this.player.setVolume(newVolume);
if (audioPlayer.muted && newVolume > 0) { const activeEl = this.player.activeElement;
audioPlayer.muted = false; if (activeEl.muted && newVolume > 0) {
activeEl.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
} }
updateFsVolumeUI(); updateFsVolumeUI();
@ -1570,15 +1581,16 @@ export class UIRenderer {
isAdjustingFsVolume = false; isAdjustingFsVolume = false;
}); });
audioPlayer.addEventListener('volumechange', updateFsVolumeUI); this.player.activeElement.addEventListener('volumechange', updateFsVolumeUI);
updateFsVolumeUI(); updateFsVolumeUI();
} }
const update = () => { const update = () => {
if (document.getElementById('fullscreen-cover-overlay').style.display === 'none') return; if (document.getElementById('fullscreen-cover-overlay').style.display === 'none') return;
const duration = audioPlayer.duration || 0; const activeEl = this.player.activeElement;
const current = audioPlayer.currentTime || 0; const duration = activeEl.duration || 0;
const current = activeEl.currentTime || 0;
if (duration > 0) { if (duration > 0) {
// Only update progress if not currently seeking (user is dragging) // Only update progress if not currently seeking (user is dragging)
@ -1619,6 +1631,27 @@ export class UIRenderer {
this.updateGlobalTheme(); this.updateGlobalTheme();
} }
const downloadsdisabled = true;
if (downloadsdisabled == true) {
if (pageId === 'download') {
const maintenanceModal = document.getElementById('maintenance-modal');
const maintenanceHomeBtn = document.getElementById('maintenance-home-btn');
if (maintenanceModal) {
maintenanceModal.classList.add('active');
if (maintenanceHomeBtn) {
maintenanceHomeBtn.onclick = () => {
maintenanceModal.classList.remove('active');
navigate('/');
};
}
}
} else {
const maintenanceModal = document.getElementById('maintenance-modal');
if (maintenanceModal) {
maintenanceModal.classList.remove('active');
}
}
}
if (pageId === 'settings') { if (pageId === 'settings') {
this.renderApiSettings(); this.renderApiSettings();
const savedTabName = settingsUiState.getActiveTab(); const savedTabName = settingsUiState.getActiveTab();
@ -2173,6 +2206,7 @@ export class UIRenderer {
}); });
const filteredTracks = await this.filterUserContent(recommendedTracks, 'track'); const filteredTracks = await this.filterUserContent(recommendedTracks, 'track');
this.lastRecommendedTracks = filteredTracks;
if (filteredTracks.length > 0) { if (filteredTracks.length > 0) {
this.renderListWithTracks(songsContainer, filteredTracks, true); this.renderListWithTracks(songsContainer, filteredTracks, true);
@ -2590,16 +2624,22 @@ export class UIRenderer {
} }
setupHlsVideo(video, result, fallbackImg) { setupHlsVideo(video, result, fallbackImg) {
const url = result.videoUrl || result.hlsUrl; if (!result) return;
const url = typeof result === 'string' ? result : result.videoUrl || result.hlsUrl;
if (!url) return; if (!url) return;
if (url.endsWith('.m3u8')) { if (url.endsWith('.m3u8')) {
if (Hls.isSupported()) { if (Hls.isSupported()) {
const hls = new Hls(); const hls = new Hls();
video._hls = hls;
hls.loadSource(url); hls.loadSource(url);
hls.attachMedia(video); hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {}); video.play().catch((e) => {
console.warn('Autoplay failed, muted play might be required:', e);
video.muted = true;
video.play().catch(() => {});
});
}); });
hls.on(Hls.Events.ERROR, (event, data) => { hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) { if (data.fatal) {
@ -2609,7 +2649,7 @@ export class UIRenderer {
} }
}); });
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// i heard safari supports HLS natively // safari supports HLS natively
video.src = url; video.src = url;
} else { } else {
video.replaceWith(fallbackImg); video.replaceWith(fallbackImg);
@ -2617,15 +2657,20 @@ export class UIRenderer {
} else { } else {
// MP4 // MP4
video.src = url; video.src = url;
video.onerror = () => { video.play().catch((e) => {
if (result.hlsUrl) { console.warn('MP4 autoplay failed:', e);
// HLS fallback (for some reason alot of animated covers js dont work on MP4 lol) video.muted = true;
this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, fallbackImg); video.play().catch(() => {});
} else { });
video.replaceWith(fallbackImg);
}
};
} }
video.onerror = () => {
if (result.hlsUrl) {
// HLS fallback (for some reason alot of animated covers js dont work on MP4 lol)
this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, fallbackImg);
} else {
video.replaceWith(fallbackImg);
}
};
} }
replaceVideoArtwork(container, type, id, result) { replaceVideoArtwork(container, type, id, result) {
@ -2637,11 +2682,11 @@ export class UIRenderer {
const img = card.querySelector('.card-image'); const img = card.querySelector('.card-image');
if (img && img.tagName !== 'VIDEO') { if (img && img.tagName !== 'VIDEO') {
const video = document.createElement('video'); const video = document.createElement('video');
video.autoplay = false; video.autoplay = true;
video.loop = false; video.loop = true;
video.muted = true; video.muted = true;
video.playsInline = true; video.playsInline = true;
video.preload = 'metadata'; video.preload = 'auto';
video.className = img.className; video.className = img.className;
video.id = img.id; video.id = img.id;
video.style.objectFit = 'cover'; video.style.objectFit = 'cover';
@ -2672,16 +2717,6 @@ export class UIRenderer {
img.replaceWith(video); img.replaceWith(video);
this.setupHlsVideo(video, result, img); this.setupHlsVideo(video, result, img);
// If HLS, dont play
const hls = video._hls;
if (hls) {
hls.on(Hls.Events.MANIFEST_PARSED, () => {
// Dont play
});
} else {
video.src = url;
}
} }
} }
@ -2946,11 +2981,11 @@ export class UIRenderer {
const currentImageEl = document.getElementById('album-detail-image'); const currentImageEl = document.getElementById('album-detail-image');
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') { if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
const video = document.createElement('video'); const video = document.createElement('video');
video.autoplay = false; video.autoplay = true;
video.loop = false; video.loop = true;
video.muted = true; video.muted = true;
video.playsInline = true; video.playsInline = true;
video.preload = 'metadata'; video.preload = 'auto';
video.className = currentImageEl.className; video.className = currentImageEl.className;
video.id = currentImageEl.id; video.id = currentImageEl.id;
video.style.opacity = '1'; video.style.opacity = '1';
@ -2966,18 +3001,19 @@ export class UIRenderer {
const coverUrl = videoCoverUrl || this.api.getCoverUrl(album.cover); const coverUrl = videoCoverUrl || this.api.getCoverUrl(album.cover);
if (videoCoverUrl) { if (videoCoverUrl) {
if (imageEl.tagName === 'IMG') { if (imageEl.tagName !== 'VIDEO') {
const video = document.createElement('video'); const video = document.createElement('video');
video.src = videoCoverUrl;
video.autoplay = true; video.autoplay = true;
video.loop = true; video.loop = true;
video.muted = true; video.muted = true;
video.playsInline = true; video.playsInline = true;
video.preload = 'auto';
video.className = imageEl.className; video.className = imageEl.className;
video.id = imageEl.id; video.id = imageEl.id;
this.setupHlsVideo(video, videoCoverUrl, imageEl);
imageEl.replaceWith(video); imageEl.replaceWith(video);
} else { } else {
imageEl.src = videoCoverUrl; this.setupHlsVideo(imageEl, videoCoverUrl, null);
} }
} else { } else {
if (imageEl.tagName === 'VIDEO') { if (imageEl.tagName === 'VIDEO') {
@ -3737,11 +3773,11 @@ export class UIRenderer {
const currentImageEl = document.getElementById('mix-detail-image'); const currentImageEl = document.getElementById('mix-detail-image');
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') { if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
const video = document.createElement('video'); const video = document.createElement('video');
video.autoplay = false; video.autoplay = true;
video.loop = false; video.loop = true;
video.muted = true; video.muted = true;
video.playsInline = true; video.playsInline = true;
video.preload = 'metadata'; video.preload = 'auto';
video.className = currentImageEl.className; video.className = currentImageEl.className;
video.id = currentImageEl.id; video.id = currentImageEl.id;
video.style.opacity = '1'; video.style.opacity = '1';
@ -4846,11 +4882,11 @@ export class UIRenderer {
const currentImageEl = document.getElementById('track-detail-image'); const currentImageEl = document.getElementById('track-detail-image');
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') { if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
const video = document.createElement('video'); const video = document.createElement('video');
video.autoplay = false; video.autoplay = true;
video.loop = false; video.loop = true;
video.muted = true; video.muted = true;
video.playsInline = true; video.playsInline = true;
video.preload = 'metadata'; video.preload = 'auto';
video.className = currentImageEl.className; video.className = currentImageEl.className;
video.id = currentImageEl.id; video.id = currentImageEl.id;
video.style.opacity = '1'; video.style.opacity = '1';
@ -4883,18 +4919,19 @@ export class UIRenderer {
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover); const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
if (videoCoverUrl) { if (videoCoverUrl) {
if (imageEl.tagName === 'IMG') { if (imageEl.tagName !== 'VIDEO') {
const video = document.createElement('video'); const video = document.createElement('video');
video.src = videoCoverUrl;
video.autoplay = true; video.autoplay = true;
video.loop = true; video.loop = true;
video.muted = true; video.muted = true;
video.playsInline = true; video.playsInline = true;
video.preload = 'auto';
video.className = imageEl.className; video.className = imageEl.className;
video.id = imageEl.id; video.id = imageEl.id;
this.setupHlsVideo(video, videoCoverUrl, imageEl);
imageEl.replaceWith(video); imageEl.replaceWith(video);
} else { } else {
imageEl.src = videoCoverUrl; this.setupHlsVideo(imageEl, videoCoverUrl, null);
} }
} else { } else {
if (imageEl.tagName === 'VIDEO') { if (imageEl.tagName === 'VIDEO') {

View file

@ -605,3 +605,27 @@ export function getMimeType(data) {
return 'image/png'; return 'image/png';
return 'image/jpeg'; return 'image/jpeg';
} }
/**
* Retrieves the cover ID or image URL for a track
* @param {Object} track - The track object
* @param {Object} [track.album] - The album object associated with the track
* @param {string} [track.album.cover] - The album cover ID or URL
* @param {string} [track.album.coverId] - The album cover ID
* @param {string} [track.album.image] - The album image URL
* @param {string} [track.cover] - The track cover ID or URL
* @param {string} [track.coverId] - The track cover ID
* @param {string} [track.image] - The track image URL
* @returns {string|null} The cover ID or image URL, or null if none is available
*/
export function getTrackCoverId(track) {
return (
track.album?.cover ||
track.cover ||
track.image ||
track.album?.coverId ||
track.coverId ||
track.album?.image ||
null
);
}

View file

@ -5,12 +5,12 @@
<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://monochrome.binimum.org/legacy'" /> <meta http-equiv="refresh" content="0; URL='https://legacy.monochrome.tf'" />
<script> <script>
window.location.href = 'https://monochrome.binimum.org/legacy'; window.location.href = 'https://legacy.monochrome.tf';
</script> </script>
</head> </head>
<body> <body>
<p>If you are not redirected, <a href="https://monochrome.binimum.org/legacy">click here</a>.</p> <p>If you are not redirected, <a href="https://legacy.monochrome.tf">click here</a>.</p>
</body> </body>
</html> </html>

View file

@ -1,251 +0,0 @@
# Self-Hosted Database Setup Guide
This guide will show you how to set up your own authentication system and database for Monochrome accounts.
> ⚠️ **Note:** You will need to enter the same configurations on each device where you want to use your custom database.
---
## Table of Contents
- [Prerequisites](#prerequisites)
- [Step 1: Setup Firebase Authentication](#step-1-setup-firebase-authentication)
- [Step 2: PocketBase Setup](#step-2-pocketbase-setup)
- [Step 3: Cloudflare Tunnel Setup](#step-3-cloudflare-tunnel-setup)
- [Step 4: Getting Configurations](#step-4-getting-configurations)
- [Step 5: Linking with Monochrome](#step-5-linking-with-monochrome)
- [Troubleshooting](#troubleshooting)
---
## Prerequisites
Before starting, ensure you have:
- A computer to host the database (can also use a VPS)
- A [Firebase](https://firebase.google.com) account (for authentication only)
- [PocketBase](https://pocketbase.io) installed on your host machine
- A domain name (free options available at [DigitalPlat](https://domain.digitalplat.org/))
> 💡 **This guide assumes you're setting everything up on your local machine. The process is identical for a VPS.**
---
## Step 1: Setup Firebase Authentication
### 1.1 Create a Firebase Project
1. Go to the [Firebase Console](https://console.firebase.google.com)
2. Create a new project
3. On the left sidebar, click **Build** → **Authentication**
4. Click **Get Started**
### 1.2 Enable Sign-in Methods
1. Go to the **Sign-in method** tab
2. Enable **Google** and **Email** providers
3. Set your project support email
4. Click **Save**
### 1.3 Authorize Your Domain
Firebase requires authorized domains for authentication:
1. In **Authentication****Settings** → **Authorized domains**
2. Click **Add domain**
3. Add your hosting domain:
- If using the official Monochrome site: `monochrome.samidy.com` or your preferred mirror (e.g., `monochrome.tf`)
- If self-hosting the website: add your custom domain
> 💡 `localhost` is usually added by default for local testing. You can leave this enabled.
---
## Step 2: PocketBase Setup
### 2.1 Install and Configure
1. Download [PocketBase](https://pocketbase.io) and follow their setup guide
2. Access the PocketBase Admin UI (typically at `http://127.0.0.1:8090/_/`)
### 2.2 Create Collections
Create two collections: `DB_users` and `public_playlists` (do NOT use the default "users" collection)
#### DB_users Fields
| Field Name | Type | Description |
| --------------------- | --------------- | --------------------------------- |
| `firebase_id` | Plain Text | Links to Firebase user ID |
| `lastUpdated` | Number | Timestamp of last update |
| `history` | JSON | User listening history |
| `library` | JSON | User's saved library |
| `user_playlists` | JSON | User's custom playlists |
| `user_folders` | JSON | User's playlist folders |
| `deleted_playlists` | JSON | Soft-deleted playlists |
| `username` | Plain Text | Unique username |
| `display_name` | Plain Text | Profile display name |
| `avatar_url` | URL | Profile avatar URL |
| `banner` | URL | Profile banner URL |
| `status` | Plain Text | User status |
| `about` | Plain Text | About me bio |
| `website` | URL | Personal website URL |
| `lastfm_username` | Plain Text | Last.fm username |
| `privacy` | JSON | Privacy settings |
| `profile_data_source` | Select (lastfm) | Preferred data source for profile |
| `favorite_albums` | JSON | User's favorite albums |
#### public_playlists Fields
| Field Name | Type | Description |
| ---------------- | ---------- | -------------------------- |
| `firebase_id` | Plain Text | Creator's Firebase user ID |
| `addedAt` | Number | Creation timestamp |
| `numberOfTracks` | Number | Total track count |
| `OriginalId` | Plain Text | Original playlist ID |
| `publishedAt` | Number | Publication timestamp |
| `title` | Plain Text | Playlist title |
| `uid` | Plain Text | Unique identifier |
| `uuid` | Plain Text | UUID for the playlist |
| `tracks` | JSON | Playlist tracks data |
| `image` | URL | Playlist cover image |
### 2.3 Configure API Rules
Set the API rules for both collections to allow read/write access:
**DB_users API Rules:**
- List/Search Rule: `firebase_id = @request.query.f_id || username != ""`
- View Rule: `firebase_id = @request.query.f_id || username != ""`
- Create Rule: `firebase_id = @request.query.f_id`
- Update Rule: `firebase_id = @request.query.f_id`
- Delete Rule: `firebase_id = @request.query.f_id`
**public_playlists API Rules:**
- List/Search Rule: `uuid = @request.query.p_id`
- View Rule: `id != ""`
- Create Rule: `firebase_id = @request.query.f_id`
- Update Rule: `uid = @request.query.f_id`
- Delete Rule: `uid = @request.query.f_id`
---
## Step 3: Cloudflare Tunnel Setup
To make your PocketBase instance accessible from other devices securely:
### 3.1 Create a Cloudflare Account
1. Sign up at the [Cloudflare Dashboard](https://dash.cloudflare.com)
2. Set up **Zero Trust** (free plan available)
### 3.2 Create a Tunnel
1. In the Cloudflare dashboard, go to **Zero Trust****Networks** → **Connectors**
2. Select **Cloudflared**
3. Give your tunnel a name (e.g., `monochrome-database`)
4. Follow the installation guide for your operating system
### 3.3 Configure Hostname
1. In the tunnel setup, add a **Public Hostname**
2. **Subdomain:** Choose a subdomain (e.g., `db` for `db.yourdomain.com`)
3. **Domain:** Select your domain from the dropdown
4. **Service:** Select **HTTP**
5. **URL:** Enter your PocketBase local address (e.g., `127.0.0.1:8090`)
> ⚠️ **Note:** Cloudflare requires a valid domain. Free `.pages.dev` domains won't work for this. Get a free domain at [DigitalPlat](https://domain.digitalplat.org/).
6. Save the configuration
Your database will now be accessible at your chosen domain!
---
## Step 4: Getting Configurations
### 4.1 Get Firebase Configuration
1. In the [Firebase Console](https://console.firebase.google.com), open your project
2. Click the **⚙️ Settings** icon next to "Project Overview"
3. Select **Project settings**
4. In the **General** tab, scroll to "Your apps"
5. Click the **Web icon** (`</>`)
6. Register your app (e.g., "Monochrome Auth")
7. Copy the `firebaseConfig` object:
```javascript
const firebaseConfig = {
apiKey: 'AIzaSy...',
authDomain: 'your-project.firebaseapp.com',
databaseURL: 'https://your-project.firebaseio.com',
projectId: 'your-project',
storageBucket: 'your-project.appspot.com',
messagingSenderId: '...',
appId: '...',
};
```
> ⚠️ **Copy only the object content inside the curly braces `{ ... }`**
### 4.2 Get Database URL
Simply copy your PocketBase domain from Cloudflare (e.g., `https://db.yourdomain.com`)
---
## Step 5: Linking with Monochrome
Now configure Monochrome to use your custom backend:
1. Open Monochrome in your browser
2. Go to **Settings** (gear icon)
3. Click **ADVANCED: Custom Account Database**
4. Enter your configurations:
- **Database Config:** Your PocketBase domain (e.g., `https://db.yourdomain.com`)
- **Authentication Config:** The Firebase config JSON object from Step 4.1
5. Click **Save**
**Done!** Your Monochrome instance is now connected to your custom database.
> 📝 **Important:** Repeat Step 5 on every device where you want to use your custom database.
---
## Troubleshooting
### Cannot sign in
- Ensure your domain is added to Firebase's authorized domains
- Check that the Firebase config JSON is correctly formatted
### Database connection errors
- Verify your Cloudflare tunnel is running
- Check that PocketBase is accessible at your domain
- Ensure API rules are configured correctly
### Data not syncing
- Make sure you're signed in with the same account on all devices
- Check the browser console for error messages
- Verify your database collections have the correct fields
---
## Security Tips
- Keep your Firebase API key secure (it's okay to expose it for client-side auth, but don't share it unnecessarily)
- Regularly backup your PocketBase database
- Use strong, unique passwords for your Cloudflare and Firebase accounts
- Consider enabling 2FA on all accounts
---
## Need Help?
- Join our [Discord community](https://monochrome.tf/discord) (if available)
- Open an issue on [GitHub](https://github.com/monochrome-music/monochrome/issues)
- Check existing [GitHub issues](https://github.com/monochrome-music/monochrome/issues) for solutions

View file

@ -3122,6 +3122,59 @@ input:checked + .slider::before {
transform: translateX(2px); transform: translateX(2px);
} }
#radio-btn.active,
#fs-radio-btn.active {
color: var(--primary);
}
#radio-btn.active svg,
#fs-radio-btn.active svg {
filter: drop-shadow(0 0 4px var(--primary));
}
#radio-loading-indicator {
display: none;
position: absolute;
top: -35px;
left: 50%;
transform: translateX(-50%);
background: var(--background-secondary);
padding: 6px 14px;
border-radius: 20px;
font-size: 0.85rem;
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
align-items: center;
gap: 10px;
z-index: 100;
white-space: nowrap;
animation: radio-slide-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#radio-loading-indicator .animate-spin {
width: 14px;
height: 14px;
border: 2px solid var(--primary);
border-top-color: transparent;
border-radius: 50%;
}
#radio-loading-indicator span {
font-weight: 500;
}
@keyframes radio-slide-up {
from {
opacity: 0;
transform: translate(-50%, 10px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.player-controls { .player-controls {
display: flex; display: flex;
flex-direction: column; flex-direction: column;