Merge https://github.com/monochrome-music/monochrome into taglib-wasm
This commit is contained in:
commit
1c9c5fa242
24 changed files with 1233 additions and 849 deletions
|
|
@ -1,26 +1,52 @@
|
|||
# ------------------------------------------------------------
|
||||
# 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
|
||||
# ------------------------------------------------------------
|
||||
RUN apt update && apt upgrade -y && \
|
||||
RUN apt-get update && apt-get upgrade -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
git \
|
||||
git-lfs \
|
||||
build-essential \
|
||||
sudo \
|
||||
fish \
|
||||
unzip \
|
||||
xz-utils \
|
||||
libatomic1 \
|
||||
libc6 \
|
||||
wget \
|
||||
nodejs \
|
||||
npm \
|
||||
curl
|
||||
npm && \
|
||||
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)
|
||||
# ------------------------------------------------------------
|
||||
ENV BUN_INSTALL="$HOME/.bun"
|
||||
ENV PATH="$BUN_INSTALL/bin:$PATH"
|
||||
|
||||
ENV BUN_INSTALL=/home/${USERNAME}/.bun
|
||||
ENV PATH="${BUN_INSTALL}/bin:${PATH}"
|
||||
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# ------------------------------------------------------------
|
||||
|
|
@ -32,11 +58,11 @@ RUN curl -fsSL https://opencode.ai/install -o opencode-install && \
|
|||
rm opencode-install
|
||||
|
||||
# Add OpenCode to PATH permanently
|
||||
ENV PATH="$HOME/.opencode/bin:$PATH"
|
||||
ENV PATH="/home/${USERNAME}/.opencode/bin:${PATH}"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Ensure fish is Default Shell
|
||||
# ------------------------------------------------------------
|
||||
ENV SHELL=/usr/bin/fish
|
||||
|
||||
CMD ["fish"]
|
||||
CMD ["fish"]
|
||||
|
|
|
|||
|
|
@ -26,14 +26,16 @@ Thank you for your interest in contributing to Monochrome! This guide will help
|
|||
|
||||
### Quick Start
|
||||
|
||||
1. **Fork and clone the repository:**
|
||||
1. Fork the Repository
|
||||
|
||||
2. clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/monochrome.git
|
||||
cd monochrome
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
3. Install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
|
|
@ -41,7 +43,7 @@ Thank you for your interest in contributing to Monochrome! This guide will help
|
|||
npm install
|
||||
```
|
||||
|
||||
3. **Start the development server:**
|
||||
4. Start the development server:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
|
|
@ -49,7 +51,7 @@ Thank you for your interest in contributing to Monochrome! This guide will help
|
|||
npm run dev
|
||||
```
|
||||
|
||||
4. **Open your browser:**
|
||||
5. Open your browser:
|
||||
Navigate to `http://localhost:5173/`
|
||||
|
||||
---
|
||||
|
|
@ -118,12 +120,12 @@ monochrome/
|
|||
- **`/js`** - All JavaScript source code
|
||||
- Keep modules focused and single-purpose
|
||||
- Use ES6+ features
|
||||
- Add JSDoc comments for complex functions
|
||||
- Keep the code easy to work with/maintain
|
||||
|
||||
- **`/public`** - Static assets copied directly to build
|
||||
- Images should be optimized before adding
|
||||
- Keep file sizes reasonable
|
||||
- Use appropriate formats (WebP where possible)
|
||||
- Use appropriate formats (PNG where possible)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
13
DOCKER.md
13
DOCKER.md
|
|
@ -10,19 +10,6 @@ docker compose up -d
|
|||
|
||||
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
|
||||
|
||||
```bash
|
||||
|
|
|
|||
41
INSTANCES.md
41
INSTANCES.md
|
|
@ -17,6 +17,14 @@ The official Monochrome instance maintained by the core team:
|
|||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
| Provider | URL | Notes |
|
||||
| ----------------- | ----------------------------------- | ---------------------------------------------------------- |
|
||||
| **Monochrome** | `https://monochrome-api.samidy.com` | Official API - [See Note](https://rentry.co/monochromeapi) |
|
||||
| | `https://api.monochrome.tf` | Official API |
|
||||
| | `https://arran.monochrome.tf` | Official API |
|
||||
| **squid.wtf** | `https://triton.squid.wtf` | Community hosted |
|
||||
| **Lucida (QQDL)** | `https://wolf.qqdl.site` | Community hosted |
|
||||
| | `https://maus.qqdl.site` | Community hosted |
|
||||
| | `https://vogel.qqdl.site` | Community hosted |
|
||||
| | `https://katze.qqdl.site` | Community hosted |
|
||||
| | `https://hund.qqdl.site` | Community hosted |
|
||||
| **Spotisaver** | `https://hifi-one.spotisaver.net` | Community hosted |
|
||||
| | `https://hifi-two.spotisaver.net` | Community hosted |
|
||||
| **Kinoplus** | `https://tidal.kinoplus.online` | Community hosted |
|
||||
| **Binimum** | `https://tidal-api.binimum.org` | Community hosted |
|
||||
| Provider | URL | Notes |
|
||||
| ----------------- | ----------------------------------- | ---------------- |
|
||||
| **Monochrome** | `https://monochrome-api.samidy.com` | Official API |
|
||||
| | `https://api.monochrome.tf` | Official API |
|
||||
| | `https://arran.monochrome.tf` | Official API |
|
||||
| **squid.wtf** | `https://triton.squid.wtf` | Community hosted |
|
||||
| **Lucida (QQDL)** | `https://wolf.qqdl.site` | Community hosted |
|
||||
| | `https://maus.qqdl.site` | Community hosted |
|
||||
| | `https://vogel.qqdl.site` | Community hosted |
|
||||
| | `https://katze.qqdl.site` | Community hosted |
|
||||
| | `https://hund.qqdl.site` | Community hosted |
|
||||
| **Spotisaver** | `https://hifi-one.spotisaver.net` | Community hosted |
|
||||
| | `https://hifi-two.spotisaver.net` | Community hosted |
|
||||
| **Kinoplus** | `https://tidal.kinoplus.online` | Community hosted |
|
||||
| **Binimum** | `https://tidal-api.binimum.org` | Community hosted |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -95,6 +103,5 @@ Want to add your instance to this list?
|
|||
|
||||
## Related Resources
|
||||
|
||||
- [Self-Hosting Guide](self-hosted-database.md) - Host your own instance
|
||||
- [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
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -11,12 +11,11 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://monochrome.tf">Website</a> •
|
||||
<a href="https://ko-fi.com/monochromemusic">Donate</a> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#usage">Usage</a> •
|
||||
<a href="#self-hosting">Self-Hosting</a> •
|
||||
<a href="https://monochrome.tf">Website</a> -
|
||||
<a href="https://ko-fi.com/monochromemusic">Donate</a> -
|
||||
<a href="#features">Features</a> -
|
||||
<a href="#usage">Usage</a> -
|
||||
<a href="#self-hosting">Self-Hosting</a> -
|
||||
<a href="CONTRIBUTING.md">Contributing</a>
|
||||
</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.
|
||||
|
||||
<p align="center">
|
||||
<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">
|
||||
<a href="https://monochrome.tf/album/90502209">
|
||||
<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>
|
||||
</p>
|
||||
|
||||
|
|
@ -50,15 +55,16 @@
|
|||
|
||||
### Audio Quality
|
||||
|
||||
- High-quality Hi-Res/lossless audio streaming
|
||||
- High-quality High-Res/lossless audio streaming
|
||||
- Support for local music files
|
||||
- Intelligent API caching for improved performance
|
||||
- API caching for improved performance
|
||||
|
||||
### Interface
|
||||
|
||||
- Dark, minimalist interface optimized for focus
|
||||
- Customizable themes
|
||||
- Community Theme Store
|
||||
- Animated Album Covers For Supported Albums
|
||||
- High-quality Music Videos
|
||||
- Customizable themes & Community Theme Store
|
||||
- Accurate and unique audio visualizer
|
||||
- Offline-capable Progressive Web App (PWA)
|
||||
- Media Session API integration for system controls
|
||||
|
|
@ -71,6 +77,8 @@
|
|||
- Playlist import from other platforms
|
||||
- Public playlists for social sharing
|
||||
- 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
|
||||
|
||||
|
|
@ -85,6 +93,7 @@
|
|||
- Last.fm and ListenBrainz integration for scrobbling
|
||||
- Unreleased music from [ArtistGrid](https://artistgrid.cx)
|
||||
- Dynamic Discord Embeds
|
||||
- Artist Biography + Social Links for learning more about your favorite artists
|
||||
- Multiple API instance support with failover
|
||||
|
||||
### Power User Features
|
||||
|
|
@ -107,7 +116,9 @@ For alternative instances, check [INSTANCES.md](INSTANCES.md).
|
|||
|
||||
## 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)
|
||||
|
||||
|
|
@ -144,7 +155,7 @@ docker compose down
|
|||
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
|
||||
|
||||
|
|
|
|||
125
index.html
125
index.html
|
|
@ -36,12 +36,16 @@
|
|||
</head>
|
||||
|
||||
<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">
|
||||
<ul>
|
||||
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
|
||||
Shuffle play
|
||||
</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="play-next">Play next</li>
|
||||
<li data-action="add-to-queue">Add to queue</li>
|
||||
|
|
@ -1668,6 +1672,42 @@
|
|||
</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 class="modal-overlay"></div>
|
||||
<div class="modal-content">
|
||||
|
|
@ -2212,36 +2252,57 @@
|
|||
|
||||
<div id="home-content" style="display: none">
|
||||
<section class="content-section">
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
"
|
||||
>
|
||||
<h2 class="section-title" style="margin-bottom: 0">Recommended Songs</h2>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
id="refresh-songs-btn"
|
||||
title="Refresh"
|
||||
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"
|
||||
<div class="header-actions">
|
||||
<h2 class="section-title">Recommended Songs</h2>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<button
|
||||
class="btn-primary"
|
||||
id="home-start-infinite-radio-btn"
|
||||
title="Start Infinite Radio"
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
"
|
||||
>
|
||||
<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>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
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 class="track-list" id="home-recommended-songs"></div>
|
||||
</section>
|
||||
|
|
@ -5615,6 +5676,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<button id="shuffle-btn" title="Shuffle">
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -534,22 +534,15 @@ const syncManager = {
|
|||
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 = {
|
||||
tracks: (await getAll('favorites_tracks')) || [],
|
||||
albums: (await getAll('favorites_albums')) || [],
|
||||
artists: (await getAll('favorites_artists')) || [],
|
||||
playlists: (await getAll('favorites_playlists')) || [],
|
||||
mixes: (await getAll('favorites_mixes')) || [],
|
||||
history: (await getAll('history_tracks')) || [],
|
||||
userPlaylists: (await getAll('user_playlists')) || [],
|
||||
userFolders: (await getAll('user_folders')) || [],
|
||||
tracks: (await database.getAll('favorites_tracks')) || [],
|
||||
albums: (await database.getAll('favorites_albums')) || [],
|
||||
artists: (await database.getAll('favorites_artists')) || [],
|
||||
playlists: (await database.getAll('favorites_playlists')) || [],
|
||||
mixes: (await database.getAll('favorites_mixes')) || [],
|
||||
history: (await database.getAll('history_tracks')) || [],
|
||||
userPlaylists: (await database.getAll('user_playlists')) || [],
|
||||
userFolders: (await database.getAll('user_folders')) || [],
|
||||
};
|
||||
|
||||
let { library, history, userPlaylists, userFolders } = cloudData;
|
||||
|
|
@ -612,8 +605,23 @@ const syncManager = {
|
|||
}
|
||||
});
|
||||
|
||||
if (history.length === 0 && localData.history.length > 0) {
|
||||
history = localData.history;
|
||||
const combinedHistory = [...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;
|
||||
}
|
||||
|
||||
|
|
|
|||
44
js/api.js
44
js/api.js
|
|
@ -306,6 +306,21 @@ export class LosslessAPI {
|
|||
decoded = manifest;
|
||||
}
|
||||
} 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];
|
||||
return null;
|
||||
} else {
|
||||
|
|
@ -320,6 +335,19 @@ export class LosslessAPI {
|
|||
|
||||
try {
|
||||
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]) {
|
||||
return parsed.urls[0];
|
||||
}
|
||||
|
|
@ -863,11 +891,11 @@ export class LosslessAPI {
|
|||
const trackMap = 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 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;
|
||||
visited.add(value);
|
||||
|
||||
|
|
@ -878,13 +906,17 @@ export class LosslessAPI {
|
|||
|
||||
const item = value.item || value;
|
||||
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));
|
||||
|
||||
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) {
|
||||
try {
|
||||
|
|
@ -1098,7 +1130,7 @@ export class LosslessAPI {
|
|||
results.forEach((tracks) => {
|
||||
if (tracks.length > 0) {
|
||||
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 {
|
||||
switch (losslessContainerSettings.getContainer()) {
|
||||
case 'flac':
|
||||
if ((await getExtensionFromBlob(blob)) != 'flac' || true) {
|
||||
if ((await getExtensionFromBlob(blob)) != 'flac') {
|
||||
blob = await ffmpeg(
|
||||
blob,
|
||||
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
||||
|
|
|
|||
32
js/app.js
32
js/app.js
|
|
@ -194,7 +194,7 @@ function initializeCasting(audioPlayer, castBtn) {
|
|||
}
|
||||
}
|
||||
|
||||
function initializeKeyboardShortcuts(player, audioPlayer) {
|
||||
function initializeKeyboardShortcuts(player, _audioPlayer) {
|
||||
const keyActionMap = {
|
||||
playPause: () => {
|
||||
trackKeyboardShortcut('Space');
|
||||
|
|
@ -202,11 +202,11 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
|
|||
},
|
||||
seekForward: () => {
|
||||
trackKeyboardShortcut('Right');
|
||||
audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10);
|
||||
player.seekForward(10);
|
||||
},
|
||||
seekBackward: () => {
|
||||
trackKeyboardShortcut('Left');
|
||||
audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10);
|
||||
player.seekBackward(10);
|
||||
},
|
||||
nextTrack: () => {
|
||||
trackKeyboardShortcut('Shift+Right');
|
||||
|
|
@ -226,7 +226,8 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
|
|||
},
|
||||
mute: () => {
|
||||
trackKeyboardShortcut('M');
|
||||
audioPlayer.muted = !audioPlayer.muted;
|
||||
const el = player.activeElement;
|
||||
el.muted = !el.muted;
|
||||
},
|
||||
shuffle: () => {
|
||||
trackKeyboardShortcut('S');
|
||||
|
|
@ -252,7 +253,7 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
|
|||
trackKeyboardShortcut('Escape');
|
||||
document.getElementById('search-input')?.blur();
|
||||
sidePanelManager.close();
|
||||
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
|
||||
clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
|
||||
},
|
||||
visualizerNext: () => {
|
||||
trackKeyboardShortcut('VisualizerNext');
|
||||
|
|
@ -426,8 +427,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
events.on('mediaPrevious', () => player.playPrev());
|
||||
events.on('mediaPlayPause', () => player.handlePlayPause());
|
||||
events.on('mediaStop', () => {
|
||||
player.audio.pause();
|
||||
player.audio.currentTime = 0;
|
||||
const el = player.activeElement;
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
});
|
||||
console.log('Media keys initialized via bridge');
|
||||
});
|
||||
|
|
@ -598,9 +600,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
if (isActive) {
|
||||
sidePanelManager.close();
|
||||
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
|
||||
clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
|
||||
} else {
|
||||
openLyricsPanel(player.currentTrack, audioPlayer, lyricsManager);
|
||||
openLyricsPanel(player.currentTrack, player.activeElement, lyricsManager);
|
||||
}
|
||||
} else if (mode === 'cover') {
|
||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||
|
|
@ -612,7 +614,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
}
|
||||
} else {
|
||||
const nextTrack = player.getNextTrack();
|
||||
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer);
|
||||
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, player.activeElement);
|
||||
}
|
||||
} else {
|
||||
// Default to 'album' mode - navigate to album
|
||||
|
|
@ -900,9 +902,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
if (isActive) {
|
||||
sidePanelManager.close();
|
||||
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
|
||||
clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
|
||||
} 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
|
||||
if (sidePanelManager.isActive('lyrics')) {
|
||||
// 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
|
||||
const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay');
|
||||
if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') {
|
||||
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
|
||||
|
|
@ -948,7 +950,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
getComputedStyle(fullscreenOverlay).display === 'none'
|
||||
) {
|
||||
const nextTrack = player.getNextTrack();
|
||||
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer);
|
||||
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, player.activeElement);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ class AudioContextManager {
|
|||
constructor() {
|
||||
this.audioContext = null;
|
||||
this.source = null;
|
||||
this.sources = new Map();
|
||||
this.analyser = null;
|
||||
this.filters = [];
|
||||
this.outputNode = null;
|
||||
|
|
@ -299,81 +300,94 @@ class AudioContextManager {
|
|||
this.audio = audioElement;
|
||||
|
||||
// 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;
|
||||
if (isIOS) {
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
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' };
|
||||
|
||||
try {
|
||||
this.audioContext = new AudioContext(highResOptions);
|
||||
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
|
||||
} 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 {
|
||||
this.audioContext = new AudioContext({ latencyHint: 'playback' });
|
||||
console.log(`[AudioContext] Created with system default rate: ${this.audioContext.sampleRate}Hz`);
|
||||
} catch (e2) {
|
||||
console.warn('[AudioContext] Playback latency hint failed, using defaults:', e2);
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
}
|
||||
|
||||
// Create the media element source
|
||||
this.source = this.audioContext.createMediaElementSource(audioElement);
|
||||
if (!this.sources.has(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.fftSize = 1024;
|
||||
this.analyser.smoothingTimeConstant = 0.7;
|
||||
|
||||
// Create biquad filters for EQ with dynamic band count
|
||||
this._createEQ();
|
||||
|
||||
// Create output gain node
|
||||
this.outputNode = this.audioContext.createGain();
|
||||
this.outputNode.gain.value = 1;
|
||||
|
||||
// Create volume node
|
||||
this.volumeNode = this.audioContext.createGain();
|
||||
this.volumeNode.gain.value = this.currentVolume;
|
||||
|
||||
// Create mono audio merger node
|
||||
this.monoMergerNode = this.audioContext.createChannelMerger(2);
|
||||
|
||||
// Connect the audio graph based on EQ and mono state
|
||||
this._connectGraph();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log(`[AudioContext] Initialized with ${this.bandCount}-band EQ`);
|
||||
} catch (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
|
||||
*/
|
||||
_connectGraph() {
|
||||
if (!this.source || !this.audioContext) return;
|
||||
if (!this.isInitialized || !this.source || !this.audioContext) return;
|
||||
|
||||
try {
|
||||
// Disconnect everything first
|
||||
this.source.disconnect();
|
||||
try {
|
||||
this.source.disconnect();
|
||||
} catch (e) {}
|
||||
this.outputNode.disconnect();
|
||||
if (this.volumeNode) {
|
||||
this.volumeNode.disconnect();
|
||||
|
|
|
|||
4
js/db.js
4
js/db.js
|
|
@ -89,6 +89,10 @@ export class MusicDatabase {
|
|||
});
|
||||
}
|
||||
|
||||
async getAll(storeName) {
|
||||
return this.performTransaction(storeName, 'readonly', (store) => store.getAll());
|
||||
}
|
||||
|
||||
// History API
|
||||
async addToHistory(track) {
|
||||
const storeName = 'history_tracks';
|
||||
|
|
|
|||
|
|
@ -279,11 +279,19 @@ function removeBulkDownloadTask(notifEl) {
|
|||
}, 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.
|
||||
loadFfmpeg().catch(console.error);
|
||||
|
||||
const prefetchPromises = prefetchMetadataObjects(track, api);
|
||||
const prefetchPromises = prefetchMetadataObjects(track, api, coverBlob);
|
||||
|
||||
let enrichedTrack = {
|
||||
...track,
|
||||
|
|
@ -359,7 +367,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
|||
// Fallback
|
||||
if (downloadQuality !== 'LOSSLESS') {
|
||||
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;
|
||||
}
|
||||
|
|
@ -380,7 +388,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
|||
try {
|
||||
switch (losslessContainerSettings.getContainer()) {
|
||||
case 'flac':
|
||||
if ((await getExtensionFromBlob(blob)) != 'flac' || true) {
|
||||
if ((await getExtensionFromBlob(blob)) != 'flac') {
|
||||
blob = await ffmpeg(
|
||||
blob,
|
||||
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
||||
|
|
@ -435,7 +443,7 @@ function triggerDownload(blob, filename) {
|
|||
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 signal = abortController.signal;
|
||||
|
||||
|
|
@ -447,7 +455,7 @@ async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, not
|
|||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
||||
|
||||
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);
|
||||
triggerDownload(blob, filename);
|
||||
|
||||
|
|
@ -574,9 +582,17 @@ async function bulkDownloadToZipStream(
|
|||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
||||
|
||||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
||||
});
|
||||
const { blob, extension } = await downloadTrackBlob(
|
||||
track,
|
||||
quality,
|
||||
api,
|
||||
null,
|
||||
signal,
|
||||
(p) => {
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
||||
},
|
||||
coverBlob
|
||||
);
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
yield {
|
||||
|
|
@ -718,9 +734,17 @@ async function bulkDownloadToZipBlob(
|
|||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
||||
|
||||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
||||
});
|
||||
const { blob, extension } = await downloadTrackBlob(
|
||||
track,
|
||||
quality,
|
||||
api,
|
||||
null,
|
||||
signal,
|
||||
(p) => {
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
||||
},
|
||||
coverBlob
|
||||
);
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
yield {
|
||||
|
|
@ -863,9 +887,17 @@ async function bulkDownloadToZipNeutralino(
|
|||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
||||
|
||||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
||||
});
|
||||
const { blob, extension } = await downloadTrackBlob(
|
||||
track,
|
||||
quality,
|
||||
api,
|
||||
null,
|
||||
signal,
|
||||
(p) => {
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
||||
},
|
||||
coverBlob
|
||||
);
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
yield {
|
||||
|
|
@ -1193,7 +1225,15 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
const track = tracks[i];
|
||||
if (signal.aborted) break;
|
||||
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 discNumber = discLayout.resolveDiscNumber(i);
|
||||
yield {
|
||||
|
|
|
|||
426
js/events.js
426
js/events.js
|
|
@ -61,155 +61,164 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
const prevBtn = document.getElementById('prev-btn');
|
||||
const shuffleBtn = document.getElementById('shuffle-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 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');
|
||||
|
||||
// History tracking
|
||||
let historyLoggedTrackId = null;
|
||||
|
||||
audioPlayer.addEventListener('loadstart', () => {
|
||||
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);
|
||||
const setupMediaListeners = (element) => {
|
||||
element.addEventListener('loadstart', () => {
|
||||
if (player.activeElement === element) {
|
||||
historyLoggedTrackId = null;
|
||||
}
|
||||
});
|
||||
|
||||
updateWaveform();
|
||||
}
|
||||
element.addEventListener('play', () => {
|
||||
if (player.activeElement !== element) return;
|
||||
|
||||
playPauseBtn.innerHTML = SVG_PAUSE;
|
||||
player.updateMediaSessionPlaybackState();
|
||||
player.updateMediaSessionPositionState();
|
||||
updateTabTitle(player);
|
||||
});
|
||||
// Initialize audio context manager for EQ (only once)
|
||||
if (!audioContextManager.isReady()) {
|
||||
audioContextManager.init(element);
|
||||
}
|
||||
audioContextManager.resume();
|
||||
|
||||
audioPlayer.addEventListener('playing', () => {
|
||||
player.updateMediaSessionPlaybackState();
|
||||
player.updateMediaSessionPositionState();
|
||||
});
|
||||
if (player.currentTrack) {
|
||||
// Track play event
|
||||
trackPlayTrack(player.currentTrack);
|
||||
|
||||
audioPlayer.addEventListener('pause', () => {
|
||||
if (player.currentTrack) {
|
||||
trackPauseTrack(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();
|
||||
// Scrobble
|
||||
if (scrobbler.isAuthenticated()) {
|
||||
scrobbler.updateNowPlaying(player.currentTrack);
|
||||
}
|
||||
|
||||
updateWaveform();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
audioPlayer.addEventListener('loadedmetadata', () => {
|
||||
const totalDurationEl = document.getElementById('total-duration');
|
||||
totalDurationEl.textContent = formatTime(audioPlayer.duration);
|
||||
player.updateMediaSessionPositionState();
|
||||
});
|
||||
playPauseBtn.innerHTML = SVG_PAUSE;
|
||||
player.updateMediaSessionPlaybackState();
|
||||
player.updateMediaSessionPositionState();
|
||||
updateTabTitle(player);
|
||||
});
|
||||
|
||||
audioPlayer.addEventListener('error', async (e) => {
|
||||
console.error('Audio playback error:', e);
|
||||
playPauseBtn.innerHTML = SVG_PLAY;
|
||||
element.addEventListener('playing', () => {
|
||||
if (player.activeElement !== element) return;
|
||||
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
|
||||
if (
|
||||
player.currentTrack &&
|
||||
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
|
||||
element.addEventListener('ended', () => {
|
||||
if (player.activeElement !== element) return;
|
||||
player.playNext();
|
||||
});
|
||||
|
||||
try {
|
||||
// Force getTrack to fetch new URL for LOSSLESS
|
||||
const trackId = player.currentTrack.id;
|
||||
element.addEventListener('timeupdate', async () => {
|
||||
if (player.activeElement !== element) return;
|
||||
|
||||
// Fetch new stream URL
|
||||
const newStreamUrl = await player.api.getStreamUrl(trackId, 'LOSSLESS');
|
||||
const { currentTime, duration } = element;
|
||||
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) {
|
||||
// Reset player state for standard playback (non-DASH if possible)
|
||||
if (player.dashInitialized) {
|
||||
player.dashPlayer.reset();
|
||||
player.dashInitialized = false;
|
||||
// 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();
|
||||
}
|
||||
|
||||
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
|
||||
if (player.currentTrack) {
|
||||
console.warn('Skipping to next track due to playback error');
|
||||
setTimeout(() => player.playNext(), 1000); // Small delay to avoid rapid skipping
|
||||
}
|
||||
});
|
||||
element.addEventListener('error', (e) => {
|
||||
if (player.activeElement !== element) return;
|
||||
|
||||
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());
|
||||
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';
|
||||
});
|
||||
|
||||
window.addEventListener('radio-state-changed', (e) => {
|
||||
if (e.detail && e.detail.enabled) {
|
||||
showNotification('Infinite Radio Enabled');
|
||||
}
|
||||
});
|
||||
|
||||
// Sleep Timer for desktop
|
||||
if (sleepTimerBtnDesktop) {
|
||||
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
|
||||
const updateWaveform = async () => {
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
|
|
@ -374,37 +384,10 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
|||
updateWaveform();
|
||||
});
|
||||
|
||||
const updateVolumeUI = () => {
|
||||
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);
|
||||
initializeSmoothSliders(player);
|
||||
}
|
||||
|
||||
function initializeSmoothSliders(audioPlayer, player) {
|
||||
function initializeSmoothSliders(player) {
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressFill = document.getElementById('progress-fill');
|
||||
const currentTimeEl = document.getElementById('current-time');
|
||||
|
|
@ -424,19 +407,21 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
};
|
||||
|
||||
const updateSeekUI = (position) => {
|
||||
if (!isNaN(audioPlayer.duration)) {
|
||||
const activeEl = player.activeElement;
|
||||
if (!isNaN(activeEl.duration)) {
|
||||
progressFill.style.width = `${position * 100}%`;
|
||||
if (currentTimeEl) {
|
||||
currentTimeEl.textContent = formatTime(position * audioPlayer.duration);
|
||||
currentTimeEl.textContent = formatTime(position * activeEl.duration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Progress bar with smooth dragging
|
||||
progressBar.addEventListener('mousedown', (e) => {
|
||||
const activeEl = player.activeElement;
|
||||
isSeeking = true;
|
||||
wasPlaying = !audioPlayer.paused;
|
||||
if (wasPlaying) audioPlayer.pause();
|
||||
wasPlaying = !activeEl.paused;
|
||||
if (wasPlaying) activeEl.pause();
|
||||
|
||||
seek(progressBar, e, (position) => {
|
||||
lastSeekPosition = position;
|
||||
|
|
@ -446,10 +431,11 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
|
||||
// Touch events for mobile
|
||||
progressBar.addEventListener('touchstart', (e) => {
|
||||
const activeEl = player.activeElement;
|
||||
e.preventDefault();
|
||||
isSeeking = true;
|
||||
wasPlaying = !audioPlayer.paused;
|
||||
if (wasPlaying) audioPlayer.pause();
|
||||
wasPlaying = !activeEl.paused;
|
||||
if (wasPlaying) activeEl.pause();
|
||||
|
||||
const touch = e.touches[0];
|
||||
const rect = progressBar.getBoundingClientRect();
|
||||
|
|
@ -469,9 +455,13 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
|
||||
if (isAdjustingVolume) {
|
||||
seek(volumeBar, e, (position) => {
|
||||
if (audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
const activeEl = player.activeElement;
|
||||
if (activeEl.muted) {
|
||||
activeEl.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
|
||||
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
|
||||
if (inactiveEl) inactiveEl.muted = false;
|
||||
}
|
||||
player.setVolume(position);
|
||||
volumeFill.style.width = `${position * 100}%`;
|
||||
|
|
@ -494,9 +484,13 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
const touch = e.touches[0];
|
||||
const rect = volumeBar.getBoundingClientRect();
|
||||
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
||||
if (audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
const activeEl = player.activeElement;
|
||||
if (activeEl.muted) {
|
||||
activeEl.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
|
||||
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
|
||||
if (inactiveEl) inactiveEl.muted = false;
|
||||
}
|
||||
player.setVolume(position);
|
||||
volumeFill.style.width = `${position * 100}%`;
|
||||
|
|
@ -506,11 +500,12 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isSeeking) {
|
||||
const activeEl = player.activeElement;
|
||||
// Commit the seek
|
||||
if (!isNaN(audioPlayer.duration)) {
|
||||
audioPlayer.currentTime = lastSeekPosition * audioPlayer.duration;
|
||||
if (!isNaN(activeEl.duration)) {
|
||||
activeEl.currentTime = lastSeekPosition * activeEl.duration;
|
||||
player.updateMediaSessionPositionState();
|
||||
if (wasPlaying) audioPlayer.play();
|
||||
if (wasPlaying) activeEl.play();
|
||||
}
|
||||
isSeeking = false;
|
||||
}
|
||||
|
|
@ -522,10 +517,11 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
|
||||
document.addEventListener('touchend', () => {
|
||||
if (isSeeking) {
|
||||
if (!isNaN(audioPlayer.duration)) {
|
||||
audioPlayer.currentTime = lastSeekPosition * audioPlayer.duration;
|
||||
const activeEl = player.activeElement;
|
||||
if (!isNaN(activeEl.duration)) {
|
||||
activeEl.currentTime = lastSeekPosition * activeEl.duration;
|
||||
player.updateMediaSessionPositionState();
|
||||
if (wasPlaying) audioPlayer.play();
|
||||
if (wasPlaying) activeEl.play();
|
||||
}
|
||||
isSeeking = false;
|
||||
}
|
||||
|
|
@ -537,10 +533,11 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
|
||||
progressBar.addEventListener('click', (e) => {
|
||||
if (!isSeeking) {
|
||||
const activeEl = player.activeElement;
|
||||
// Only handle click if not result of a drag release
|
||||
seek(progressBar, e, (position) => {
|
||||
if (!isNaN(audioPlayer.duration) && audioPlayer.duration > 0 && audioPlayer.duration !== Infinity) {
|
||||
audioPlayer.currentTime = position * audioPlayer.duration;
|
||||
if (!isNaN(activeEl.duration) && activeEl.duration > 0 && activeEl.duration !== Infinity) {
|
||||
activeEl.currentTime = position * activeEl.duration;
|
||||
player.updateMediaSessionPositionState();
|
||||
} else if (player.currentTrack && player.currentTrack.duration) {
|
||||
const targetTime = position * player.currentTrack.duration;
|
||||
|
|
@ -555,9 +552,13 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
volumeBar.addEventListener('mousedown', (e) => {
|
||||
isAdjustingVolume = true;
|
||||
seek(volumeBar, e, (position) => {
|
||||
if (audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
const activeEl = player.activeElement;
|
||||
if (activeEl.muted) {
|
||||
activeEl.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
|
||||
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
|
||||
if (inactiveEl) inactiveEl.muted = false;
|
||||
}
|
||||
player.setVolume(position);
|
||||
volumeFill.style.width = `${position * 100}%`;
|
||||
|
|
@ -571,9 +572,13 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
const touch = e.touches[0];
|
||||
const rect = volumeBar.getBoundingClientRect();
|
||||
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
||||
if (audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
const activeEl = player.activeElement;
|
||||
if (activeEl.muted) {
|
||||
activeEl.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
|
||||
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
|
||||
if (inactiveEl) inactiveEl.muted = false;
|
||||
}
|
||||
player.setVolume(position);
|
||||
volumeFill.style.width = `${position * 100}%`;
|
||||
|
|
@ -583,9 +588,13 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
volumeBar.addEventListener('click', (e) => {
|
||||
if (!isAdjustingVolume) {
|
||||
seek(volumeBar, e, (position) => {
|
||||
if (audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
const activeEl = player.activeElement;
|
||||
if (activeEl.muted) {
|
||||
activeEl.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
|
||||
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
|
||||
if (inactiveEl) inactiveEl.muted = false;
|
||||
}
|
||||
player.setVolume(position);
|
||||
volumeFill.style.width = `${position * 100}%`;
|
||||
|
|
@ -599,10 +608,14 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
|
||||
const activeEl = player.activeElement;
|
||||
|
||||
if (delta > 0 && audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
if (delta > 0 && activeEl.muted) {
|
||||
activeEl.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
|
||||
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
|
||||
if (inactiveEl) inactiveEl.muted = false;
|
||||
}
|
||||
|
||||
player.setVolume(newVolume);
|
||||
|
|
@ -618,10 +631,14 @@ function initializeSmoothSliders(audioPlayer, player) {
|
|||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
|
||||
const activeEl = player.activeElement;
|
||||
|
||||
if (delta > 0 && audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
if (delta > 0 && activeEl.muted) {
|
||||
activeEl.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
|
||||
const inactiveEl = player.currentTrack?.type === 'video' ? player.audio : player.video;
|
||||
if (inactiveEl) inactiveEl.muted = false;
|
||||
}
|
||||
|
||||
player.setVolume(newVolume);
|
||||
|
|
@ -768,12 +785,47 @@ export async function handleTrackAction(
|
|||
if (!item) return;
|
||||
|
||||
// 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)) {
|
||||
showNotification('This track is unavailable.');
|
||||
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 (item.mixes && item.mixes.TRACK_MIX) {
|
||||
navigate(`/mix/${item.mixes.TRACK_MIX}`);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 coreJs = `${ffmpegBase}/ffmpeg-core.js`;
|
||||
const coreWasm = `${ffmpegBase}/ffmpeg-core.wasm`;
|
||||
|
|
|
|||
|
|
@ -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 { doTimed, doTimedAsync } from './doTimed.ts';
|
||||
import { managers } from './app.js';
|
||||
|
|
@ -10,11 +10,14 @@ export const METADATA_STRINGS = {
|
|||
DEFAULT_ALBUM: 'Unknown Album',
|
||||
};
|
||||
|
||||
export function prefetchMetadataObjects(track, api) {
|
||||
export function prefetchMetadataObjects(track, api, coverBlob = null) {
|
||||
const _tagLib = fetchTagLib().catch(console.error);
|
||||
const coverFetch = track?.album?.cover
|
||||
? getCoverBlob(api, track.album.cover).catch(console.error)
|
||||
: Promise.resolve(null);
|
||||
const coverId = getTrackCoverId(track);
|
||||
const coverFetch = coverBlob
|
||||
? Promise.resolve(coverBlob)
|
||||
: coverId
|
||||
? getCoverBlob(api, coverId).catch(console.error)
|
||||
: Promise.resolve(null);
|
||||
const lyricsFetch = managers?.lyricsManager?.fetchLyrics?.(track.id, track)?.catch(console.error);
|
||||
|
||||
return { _tagLib, coverFetch, lyricsFetch };
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
const frames = [];
|
||||
|
|
@ -136,15 +136,16 @@ export function buildID3v2Tag(mp3Blob, frames) {
|
|||
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 {
|
||||
let coverBlob = null;
|
||||
|
||||
if (track.album?.cover) {
|
||||
try {
|
||||
coverBlob = await getCoverBlob(api, track.album.cover);
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch album art for MP3:', error);
|
||||
if (!coverBlob) {
|
||||
const coverId = getTrackCoverId(track);
|
||||
if (coverId) {
|
||||
try {
|
||||
coverBlob = await getCoverBlob(api, coverId);
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch album art for MP3:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
509
js/player.js
509
js/player.js
|
|
@ -16,13 +16,16 @@ import {
|
|||
trackDateSettings,
|
||||
exponentialVolumeSettings,
|
||||
audioEffectsSettings,
|
||||
radioSettings,
|
||||
} from './storage.js';
|
||||
import { audioContextManager } from './audio-context.js';
|
||||
import { db } from './db.js';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
export class Player {
|
||||
constructor(audioElement, api, quality = 'HI_RES_LOSSLESS') {
|
||||
this.audio = audioElement;
|
||||
this.video = document.getElementById('video-player');
|
||||
this.api = api;
|
||||
this.quality = quality;
|
||||
this.queue = [];
|
||||
|
|
@ -53,6 +56,11 @@ export class Player {
|
|||
this.audio.addEventListener('canplay', () => {
|
||||
this.applyAudioEffects();
|
||||
});
|
||||
if (this.video) {
|
||||
this.video.addEventListener('canplay', () => {
|
||||
this.applyAudioEffects();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize dash.js player
|
||||
this.dashPlayer = MediaPlayer().create();
|
||||
|
|
@ -68,24 +76,56 @@ export class Player {
|
|||
this.loadQueueState();
|
||||
this.setupMediaSession();
|
||||
|
||||
this.radioEnabled = radioSettings.isEnabled();
|
||||
this.radioSeeds = [];
|
||||
this.isFetchingRadio = false;
|
||||
this.radioFetchPromise = null;
|
||||
|
||||
this.playbackSequence = 0;
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.saveQueueState();
|
||||
});
|
||||
|
||||
// Handle visibility change for iOS - AudioContext gets suspended when screen locks
|
||||
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
|
||||
if (!audioContextManager.isReady()) {
|
||||
audioContextManager.init(this.audio);
|
||||
audioContextManager.init(el);
|
||||
}
|
||||
audioContextManager.resume();
|
||||
}
|
||||
if (document.visibilityState === 'visible' && this.autoplayBlocked) {
|
||||
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) {
|
||||
|
|
@ -128,38 +168,39 @@ export class Player {
|
|||
// Calculate effective volume
|
||||
const effectiveVolume = curvedVolume * scale;
|
||||
|
||||
const el = this.activeElement;
|
||||
|
||||
// Apply to audio element and/or Web Audio graph
|
||||
if (audioContextManager.isReady()) {
|
||||
// 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
|
||||
// We set audio.volume to 1.0 to avoid double-reduction, or keep it synced?
|
||||
// Some browsers require audio.volume to be set for system media controls to show volume
|
||||
this.audio.volume = 1.0;
|
||||
el.volume = 1.0;
|
||||
audioContextManager.setVolume(effectiveVolume);
|
||||
} else {
|
||||
this.audio.volume = Math.max(0, Math.min(1, effectiveVolume));
|
||||
el.volume = Math.max(0, Math.min(1, effectiveVolume));
|
||||
}
|
||||
}
|
||||
|
||||
applyAudioEffects() {
|
||||
const speed = audioEffectsSettings.getSpeed();
|
||||
const el = this.activeElement;
|
||||
|
||||
if (this.dashInitialized && this.dashPlayer) {
|
||||
if (this.dashPlayer.getPlaybackRate() !== speed) {
|
||||
this.dashPlayer.setPlaybackRate(speed);
|
||||
}
|
||||
} else {
|
||||
if (this.audio.playbackRate !== speed) {
|
||||
this.audio.playbackRate = speed;
|
||||
if (el.playbackRate !== speed) {
|
||||
el.playbackRate = speed;
|
||||
}
|
||||
}
|
||||
|
||||
const preservePitch = audioEffectsSettings.isPreservePitchEnabled();
|
||||
if (this.audio.preservesPitch !== preservePitch) {
|
||||
this.audio.preservesPitch = preservePitch;
|
||||
if (el.preservesPitch !== preservePitch) {
|
||||
el.preservesPitch = preservePitch;
|
||||
// Firefox support
|
||||
if (this.audio.mozPreservesPitch !== undefined) {
|
||||
this.audio.mozPreservesPitch = preservePitch;
|
||||
if (el.mozPreservesPitch !== undefined) {
|
||||
el.mozPreservesPitch = preservePitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -288,16 +329,17 @@ export class Player {
|
|||
|
||||
const setHandlers = () => {
|
||||
navigator.mediaSession.setActionHandler('play', async () => {
|
||||
const el = this.activeElement;
|
||||
// Initialize and resume audio context first (required for iOS lock screen)
|
||||
// Must happen before audio.play() or audio won't route through Web Audio
|
||||
if (!audioContextManager.isReady()) {
|
||||
audioContextManager.init(this.audio);
|
||||
audioContextManager.init(el);
|
||||
this.applyReplayGain();
|
||||
}
|
||||
await audioContextManager.resume();
|
||||
|
||||
try {
|
||||
await this.audio.play();
|
||||
await el.play();
|
||||
} catch (e) {
|
||||
console.error('MediaSession play failed:', e);
|
||||
// If play fails, try to handle it like a regular play/pause
|
||||
|
|
@ -306,13 +348,13 @@ export class Player {
|
|||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
this.audio.pause();
|
||||
this.activeElement.pause();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('previoustrack', async () => {
|
||||
// Ensure audio context is active for iOS lock screen controls
|
||||
if (!audioContextManager.isReady()) {
|
||||
audioContextManager.init(this.audio);
|
||||
audioContextManager.init(this.activeElement);
|
||||
this.applyReplayGain();
|
||||
}
|
||||
await audioContextManager.resume();
|
||||
|
|
@ -322,7 +364,7 @@ export class Player {
|
|||
navigator.mediaSession.setActionHandler('nexttrack', async () => {
|
||||
// Ensure audio context is active for iOS lock screen controls
|
||||
if (!audioContextManager.isReady()) {
|
||||
audioContextManager.init(this.audio);
|
||||
audioContextManager.init(this.activeElement);
|
||||
this.applyReplayGain();
|
||||
}
|
||||
await audioContextManager.resume();
|
||||
|
|
@ -342,14 +384,14 @@ export class Player {
|
|||
|
||||
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
||||
if (details.seekTime !== undefined) {
|
||||
this.audio.currentTime = Math.max(0, details.seekTime);
|
||||
this.activeElement.currentTime = Math.max(0, details.seekTime);
|
||||
this.updateMediaSessionPositionState();
|
||||
}
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('stop', () => {
|
||||
this.audio.pause();
|
||||
this.audio.currentTime = 0;
|
||||
this.activeElement.pause();
|
||||
this.activeElement.currentTime = 0;
|
||||
this.updateMediaSessionPlaybackState();
|
||||
});
|
||||
};
|
||||
|
|
@ -358,6 +400,9 @@ export class Player {
|
|||
// 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
|
||||
this.audio.addEventListener('playing', () => setHandlers(), { once: true });
|
||||
if (this.video) {
|
||||
this.video.addEventListener('playing', () => setHandlers(), { once: true });
|
||||
}
|
||||
} else {
|
||||
setHandlers();
|
||||
}
|
||||
|
|
@ -420,9 +465,7 @@ export class Player {
|
|||
this.hls = new Hls();
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(video);
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {});
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
console.warn('HLS fatal error:', data.type);
|
||||
|
|
@ -461,6 +504,7 @@ export class Player {
|
|||
}
|
||||
|
||||
async playTrackFromQueue(startTime = 0, recursiveCount = 0) {
|
||||
const currentSequence = ++this.playbackSequence;
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
||||
return;
|
||||
|
|
@ -490,25 +534,55 @@ export class Player {
|
|||
const yearDisplay = getTrackYearDisplay(track);
|
||||
|
||||
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 (this.audio) {
|
||||
if (this.video) {
|
||||
const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';
|
||||
|
||||
if (!isInFullscreen) {
|
||||
this.audio.style.display = 'block';
|
||||
this.audio.className = 'cover video-cover-mirror';
|
||||
this.audio.style.width = '56px';
|
||||
this.audio.style.height = '56px';
|
||||
this.audio.style.borderRadius = 'var(--radius-sm)';
|
||||
this.audio.style.objectFit = 'cover';
|
||||
this.audio.style.gridArea = 'none';
|
||||
this.audio.muted = false;
|
||||
this.video.style.display = 'block';
|
||||
this.video.className = 'cover video-cover-mirror';
|
||||
this.video.style.width = '56px';
|
||||
this.video.style.height = '56px';
|
||||
this.video.style.borderRadius = 'var(--radius-sm)';
|
||||
this.video.style.objectFit = 'cover';
|
||||
this.video.style.gridArea = 'none';
|
||||
this.video.muted = false;
|
||||
|
||||
if (trackInfo && this.audio.parentElement !== trackInfo) {
|
||||
trackInfo.insertBefore(this.audio, trackInfo.firstChild);
|
||||
if (trackInfo && this.video.parentElement !== trackInfo) {
|
||||
trackInfo.insertBefore(this.video, trackInfo.firstChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -522,9 +596,6 @@ export class Player {
|
|||
const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';
|
||||
if (!isInFullscreen) {
|
||||
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-'));
|
||||
|
||||
if (isTracker || (track.audioUrl && !track.isLocal)) {
|
||||
if (this.dashInitialized) {
|
||||
this.dashPlayer.reset();
|
||||
this.dashInitialized = false;
|
||||
}
|
||||
streamUrl = track.audioUrl;
|
||||
|
||||
if (
|
||||
|
|
@ -598,83 +665,80 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.playbackSequence !== currentSequence) return;
|
||||
|
||||
this.currentRgValues = null;
|
||||
this.applyReplayGain();
|
||||
|
||||
this.audio.src = streamUrl;
|
||||
activeElement.src = streamUrl;
|
||||
this.applyAudioEffects();
|
||||
|
||||
// Wait for audio to be ready before playing (prevents restart issues with blob URLs)
|
||||
const canPlay = await this.waitForCanPlayOrTimeout();
|
||||
if (!canPlay) return;
|
||||
const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
|
||||
if (!canPlay || this.playbackSequence !== currentSequence) return;
|
||||
|
||||
if (startTime > 0) {
|
||||
this.audio.currentTime = startTime;
|
||||
activeElement.currentTime = startTime;
|
||||
}
|
||||
const played = await this.safePlay();
|
||||
const played = await this.safePlay(activeElement);
|
||||
if (!played) return;
|
||||
} else if (track.isLocal && track.file) {
|
||||
if (this.dashInitialized) {
|
||||
this.dashPlayer.reset(); // Ensure dash is off
|
||||
this.dashInitialized = false;
|
||||
}
|
||||
streamUrl = URL.createObjectURL(track.file);
|
||||
if (this.playbackSequence !== currentSequence) return;
|
||||
|
||||
this.currentRgValues = null; // No replaygain for local files yet
|
||||
this.applyReplayGain();
|
||||
|
||||
this.audio.src = streamUrl;
|
||||
activeElement.src = streamUrl;
|
||||
this.applyAudioEffects();
|
||||
|
||||
// Wait for audio to be ready before playing
|
||||
const canPlay = await this.waitForCanPlayOrTimeout();
|
||||
if (!canPlay) return;
|
||||
const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
|
||||
if (!canPlay || this.playbackSequence !== currentSequence) return;
|
||||
|
||||
if (startTime > 0) {
|
||||
this.audio.currentTime = startTime;
|
||||
activeElement.currentTime = startTime;
|
||||
}
|
||||
const played = await this.safePlay();
|
||||
const played = await this.safePlay(activeElement);
|
||||
if (!played) return;
|
||||
} else if (track.type === 'video') {
|
||||
if (this.dashInitialized) {
|
||||
this.dashPlayer.reset();
|
||||
this.dashInitialized = false;
|
||||
}
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
this.hls = null;
|
||||
if (window.monochromeUi) {
|
||||
const isInFullscreen =
|
||||
document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';
|
||||
if (!isInFullscreen) {
|
||||
const lyricsManager = window.monochromeUi.lyricsManager;
|
||||
window.monochromeUi.showFullscreenCover(
|
||||
track,
|
||||
this.getNextTrack(),
|
||||
lyricsManager,
|
||||
activeElement
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
streamUrl = await this.api.getVideoStreamUrl(track.id);
|
||||
if (this.playbackSequence !== currentSequence) return;
|
||||
|
||||
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')) {
|
||||
this.dashPlayer.initialize(this.audio, streamUrl, true);
|
||||
this.dashPlayer.initialize(activeElement, streamUrl, false);
|
||||
this.dashInitialized = true;
|
||||
} else {
|
||||
this.audio.src = streamUrl;
|
||||
activeElement.src = streamUrl;
|
||||
}
|
||||
|
||||
this.applyAudioEffects();
|
||||
|
||||
if (window.monochromeUi) {
|
||||
const lyricsManager = window.monochromeUi.lyricsManager;
|
||||
window.monochromeUi.showFullscreenCover(track, this.getNextTrack(), lyricsManager, this.audio);
|
||||
}
|
||||
|
||||
const canPlay = await this.waitForCanPlayOrTimeout();
|
||||
if (!canPlay) return;
|
||||
const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
|
||||
if (!canPlay || this.playbackSequence !== currentSequence) return;
|
||||
|
||||
if (startTime > 0) {
|
||||
this.audio.currentTime = startTime;
|
||||
activeElement.currentTime = startTime;
|
||||
}
|
||||
|
||||
await this.safePlay();
|
||||
await this.safePlay(activeElement);
|
||||
} else {
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
this.hls = null;
|
||||
}
|
||||
const isQobuz = String(track.id).startsWith('q:');
|
||||
|
||||
if (isQobuz) {
|
||||
|
|
@ -690,6 +754,7 @@ export class Player {
|
|||
} else {
|
||||
// Tidal: Get track data for ReplayGain (should be cached by API)
|
||||
const trackData = await this.api.getTrack(track.id, this.quality);
|
||||
if (this.playbackSequence !== currentSequence) return;
|
||||
|
||||
if (trackData && trackData.info) {
|
||||
this.currentRgValues = {
|
||||
|
|
@ -714,42 +779,41 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.playbackSequence !== currentSequence) return;
|
||||
|
||||
// Handle playback
|
||||
if (streamUrl && streamUrl.startsWith('blob:') && !track.isLocal) {
|
||||
// It's likely a DASH manifest blob URL
|
||||
if (this.dashInitialized) {
|
||||
this.dashPlayer.attachSource(streamUrl);
|
||||
} else {
|
||||
this.dashPlayer.initialize(this.audio, streamUrl, true);
|
||||
this.dashInitialized = true;
|
||||
}
|
||||
this.dashPlayer.initialize(activeElement, streamUrl, false);
|
||||
this.dashInitialized = true;
|
||||
this.applyAudioEffects();
|
||||
|
||||
if (startTime > 0) {
|
||||
this.dashPlayer.seek(startTime);
|
||||
}
|
||||
|
||||
const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
|
||||
if (!canPlay || this.playbackSequence !== currentSequence) return;
|
||||
await this.safePlay(activeElement);
|
||||
} else {
|
||||
if (this.dashInitialized) {
|
||||
this.dashPlayer.reset();
|
||||
this.dashInitialized = false;
|
||||
}
|
||||
this.audio.src = streamUrl;
|
||||
activeElement.src = streamUrl;
|
||||
this.applyAudioEffects();
|
||||
|
||||
// Wait for audio to be ready before playing
|
||||
const canPlay = await this.waitForCanPlayOrTimeout();
|
||||
if (!canPlay) return;
|
||||
const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
|
||||
if (!canPlay || this.playbackSequence !== currentSequence) return;
|
||||
|
||||
if (startTime > 0) {
|
||||
this.audio.currentTime = startTime;
|
||||
activeElement.currentTime = startTime;
|
||||
}
|
||||
const played = await this.safePlay();
|
||||
const played = await this.safePlay(activeElement);
|
||||
if (!played) return;
|
||||
}
|
||||
}
|
||||
|
||||
this.preloadNextTracks();
|
||||
} catch (error) {
|
||||
if (this.playbackSequence !== currentSequence) return;
|
||||
if (error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) {
|
||||
this.autoplayBlocked = true;
|
||||
return;
|
||||
|
|
@ -771,12 +835,25 @@ export class Player {
|
|||
}
|
||||
|
||||
playNext(recursiveCount = 0) {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
const currentQueue = this.getCurrentQueue();
|
||||
const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1;
|
||||
|
||||
if (this.radioEnabled && this.currentQueueIndex >= currentQueue.length - 3) {
|
||||
this.fetchRadioRecommendations();
|
||||
}
|
||||
|
||||
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.');
|
||||
this.audio.pause();
|
||||
this.activeElement.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -798,6 +875,14 @@ export class Player {
|
|||
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
||||
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) {
|
||||
this.currentQueueIndex = 0;
|
||||
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) {
|
||||
if (this.audio.currentTime > 3) {
|
||||
this.audio.currentTime = 0;
|
||||
const el = this.activeElement;
|
||||
if (el.currentTime > 3) {
|
||||
el.currentTime = 0;
|
||||
this.updateMediaSessionPositionState();
|
||||
} else if (this.currentQueueIndex > 0) {
|
||||
this.currentQueueIndex--;
|
||||
|
|
@ -824,7 +1061,7 @@ export class Player {
|
|||
|
||||
if (recursiveCount > currentQueue.length) {
|
||||
console.error('All tracks in queue are unavailable or blocked.');
|
||||
this.audio.pause();
|
||||
el.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -838,16 +1075,21 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
get activeElement() {
|
||||
return this.currentTrack?.type === 'video' ? this.video : this.audio;
|
||||
}
|
||||
|
||||
handlePlayPause() {
|
||||
if (!this.audio.src || this.audio.error) {
|
||||
const el = this.activeElement;
|
||||
if (!el.src || el.error) {
|
||||
if (this.currentTrack) {
|
||||
this.playTrackFromQueue(0, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.audio.paused) {
|
||||
this.safePlay().catch((e) => {
|
||||
if (el.paused) {
|
||||
this.safePlay(el).catch((e) => {
|
||||
if (e.name === 'NotAllowedError' || e.name === 'AbortError') return;
|
||||
console.error('Play failed, reloading track:', e);
|
||||
if (this.currentTrack) {
|
||||
|
|
@ -855,21 +1097,23 @@ export class Player {
|
|||
}
|
||||
});
|
||||
} else {
|
||||
this.audio.pause();
|
||||
el.pause();
|
||||
this.saveQueueState();
|
||||
}
|
||||
}
|
||||
|
||||
seekBackward(seconds = 10) {
|
||||
const newTime = Math.max(0, this.audio.currentTime - seconds);
|
||||
this.audio.currentTime = newTime;
|
||||
const el = this.activeElement;
|
||||
const newTime = Math.max(0, el.currentTime - seconds);
|
||||
el.currentTime = newTime;
|
||||
this.updateMediaSessionPositionState();
|
||||
}
|
||||
|
||||
seekForward(seconds = 10) {
|
||||
const duration = this.audio.duration || 0;
|
||||
const newTime = Math.min(duration, this.audio.currentTime + seconds);
|
||||
this.audio.currentTime = newTime;
|
||||
const el = this.activeElement;
|
||||
const duration = el.duration || 0;
|
||||
const newTime = Math.min(duration, el.currentTime + seconds);
|
||||
el.currentTime = newTime;
|
||||
this.updateMediaSessionPositionState();
|
||||
}
|
||||
|
||||
|
|
@ -914,7 +1158,10 @@ export class Player {
|
|||
return this.repeatMode;
|
||||
}
|
||||
|
||||
setQueue(tracks, startIndex = 0) {
|
||||
setQueue(tracks, startIndex = 0, isRadio = false) {
|
||||
if (!isRadio) {
|
||||
this.disableRadio();
|
||||
}
|
||||
this.queue = tracks;
|
||||
this.currentQueueIndex = startIndex;
|
||||
this.shuffleActive = false;
|
||||
|
|
@ -1008,8 +1255,9 @@ export class Player {
|
|||
}
|
||||
|
||||
wipeQueue() {
|
||||
this.audio.pause();
|
||||
this.audio.src = '';
|
||||
const el = this.activeElement;
|
||||
el.pause();
|
||||
el.src = '';
|
||||
this.currentTrack = null;
|
||||
this.queue = [];
|
||||
this.shuffledQueue = [];
|
||||
|
|
@ -1119,14 +1367,15 @@ export class Player {
|
|||
|
||||
updateMediaSessionPlaybackState() {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing';
|
||||
navigator.mediaSession.playbackState = this.activeElement.paused ? 'paused' : 'playing';
|
||||
}
|
||||
|
||||
updateMediaSessionPositionState() {
|
||||
if (!('mediaSession' in navigator)) 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)) {
|
||||
return;
|
||||
|
|
@ -1135,17 +1384,17 @@ export class Player {
|
|||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: duration,
|
||||
playbackRate: this.audio.playbackRate || 1,
|
||||
position: Math.min(this.audio.currentTime, duration),
|
||||
playbackRate: el.playbackRate || 1,
|
||||
position: Math.min(el.currentTime, duration),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Failed to update Media Session position:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async safePlay() {
|
||||
async safePlay(element = this.activeElement) {
|
||||
try {
|
||||
await this.audio.play();
|
||||
await element.play();
|
||||
this.autoplayBlocked = false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -1157,29 +1406,29 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
async waitForCanPlayOrTimeout(timeoutMs = 10000) {
|
||||
if (this.audio.readyState >= 2) {
|
||||
async waitForCanPlayOrTimeout(element = this.activeElement, timeoutMs = 10000) {
|
||||
if (element.readyState >= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const onCanPlay = () => {
|
||||
this.audio.removeEventListener('canplay', onCanPlay);
|
||||
this.audio.removeEventListener('error', onError);
|
||||
element.removeEventListener('canplay', onCanPlay);
|
||||
element.removeEventListener('error', onError);
|
||||
resolve(true);
|
||||
};
|
||||
const onError = (e) => {
|
||||
this.audio.removeEventListener('canplay', onCanPlay);
|
||||
this.audio.removeEventListener('error', onError);
|
||||
element.removeEventListener('canplay', onCanPlay);
|
||||
element.removeEventListener('error', onError);
|
||||
reject(e);
|
||||
};
|
||||
this.audio.addEventListener('canplay', onCanPlay);
|
||||
this.audio.addEventListener('error', onError);
|
||||
element.addEventListener('canplay', onCanPlay);
|
||||
element.addEventListener('error', onError);
|
||||
|
||||
// Timeout after 10 seconds. Treat as autoplay blocked when backgrounded (esp. iOS PWA).
|
||||
setTimeout(() => {
|
||||
this.audio.removeEventListener('canplay', onCanPlay);
|
||||
this.audio.removeEventListener('error', onError);
|
||||
element.removeEventListener('canplay', onCanPlay);
|
||||
element.removeEventListener('error', onError);
|
||||
if (document.visibilityState === 'hidden' || (this.isIOS && this.isPwa)) {
|
||||
this.autoplayBlocked = true;
|
||||
resolve(false);
|
||||
|
|
@ -1198,7 +1447,7 @@ export class Player {
|
|||
|
||||
this.sleepTimer = setTimeout(
|
||||
() => {
|
||||
this.audio.pause();
|
||||
this.activeElement.pause();
|
||||
this.clearSleepTimer();
|
||||
this.updateSleepTimerUI();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// js/qobuz-api.js
|
||||
// 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';
|
||||
|
||||
export class QobuzAPI {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
ENABLED_KEY: 'analytics-enabled',
|
||||
|
||||
|
|
|
|||
227
js/ui.js
227
js/ui.js
|
|
@ -112,6 +112,7 @@ export class UIRenderer {
|
|||
this.vibrantColorCache = new Map();
|
||||
this.visualizer = null;
|
||||
this.renderLock = false;
|
||||
this.lastRecommendedTracks = [];
|
||||
|
||||
// Listen for dynamic color reset events
|
||||
window.addEventListener('reset-dynamic-color', () => {
|
||||
|
|
@ -988,13 +989,13 @@ export class UIRenderer {
|
|||
|
||||
if (videoContainer) {
|
||||
videoContainer.style.display = 'flex';
|
||||
const audioPlayer = document.getElementById('audio-player');
|
||||
if (audioPlayer && audioPlayer.parentElement !== videoContainer) {
|
||||
videoContainer.appendChild(audioPlayer);
|
||||
audioPlayer.style.display = 'block';
|
||||
audioPlayer.style.width = '100%';
|
||||
audioPlayer.style.height = '100%';
|
||||
audioPlayer.style.objectFit = 'contain';
|
||||
const videoPlayer = document.getElementById('video-player');
|
||||
if (videoPlayer && videoPlayer.parentElement !== videoContainer) {
|
||||
videoContainer.appendChild(videoPlayer);
|
||||
videoPlayer.style.display = 'block';
|
||||
videoPlayer.style.width = '100%';
|
||||
videoPlayer.style.height = '100%';
|
||||
videoPlayer.style.objectFit = 'contain';
|
||||
}
|
||||
}
|
||||
if (image) image.style.display = 'none';
|
||||
|
|
@ -1002,10 +1003,10 @@ export class UIRenderer {
|
|||
} else {
|
||||
if (videoContainer) {
|
||||
videoContainer.style.display = 'none';
|
||||
const audioPlayer = document.getElementById('audio-player');
|
||||
if (audioPlayer && audioPlayer.parentElement === videoContainer) {
|
||||
document.body.appendChild(audioPlayer);
|
||||
audioPlayer.style.display = 'none';
|
||||
const videoPlayer = document.getElementById('video-player');
|
||||
if (videoPlayer && videoPlayer.parentElement === videoContainer) {
|
||||
document.body.appendChild(videoPlayer);
|
||||
videoPlayer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
if (image) image.style.display = 'block';
|
||||
|
|
@ -1025,11 +1026,11 @@ export class UIRenderer {
|
|||
if (currentImage.tagName === 'IMG') {
|
||||
const video = document.createElement('video');
|
||||
video.src = videoCoverUrl;
|
||||
video.autoplay = false;
|
||||
video.loop = false;
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.preload = 'auto';
|
||||
video.className = currentImage.className;
|
||||
currentImage.replaceWith(video);
|
||||
} 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 (window.location.hash !== '#fullscreen') {
|
||||
window.history.pushState({ fullscreen: true }, '', '#fullscreen');
|
||||
|
|
@ -1081,12 +1082,12 @@ export class UIRenderer {
|
|||
nextTrackEl.classList.remove('animate-in');
|
||||
}
|
||||
|
||||
if (lyricsManager && audioPlayer) {
|
||||
if (lyricsManager && activeElement) {
|
||||
lyricsToggleBtn.style.display = 'flex';
|
||||
lyricsToggleBtn.classList.remove('active');
|
||||
|
||||
const toggleLyrics = () => {
|
||||
openLyricsPanel(track, audioPlayer, lyricsManager);
|
||||
openLyricsPanel(track, activeElement, lyricsManager);
|
||||
lyricsToggleBtn.classList.toggle('active');
|
||||
};
|
||||
|
||||
|
|
@ -1100,7 +1101,7 @@ export class UIRenderer {
|
|||
const playerBar = document.querySelector('.now-playing-bar');
|
||||
if (playerBar) playerBar.style.display = 'none';
|
||||
|
||||
this.setupFullscreenControls(audioPlayer);
|
||||
this.setupFullscreenControls();
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
|
|
@ -1110,10 +1111,10 @@ export class UIRenderer {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.visualizer && audioPlayer) {
|
||||
if (!this.visualizer && activeElement) {
|
||||
const canvas = document.getElementById('visualizer-canvas');
|
||||
if (canvas) {
|
||||
this.visualizer = new Visualizer(canvas, audioPlayer);
|
||||
this.visualizer = new Visualizer(canvas, activeElement);
|
||||
}
|
||||
}
|
||||
if (this.visualizer) {
|
||||
|
|
@ -1162,22 +1163,22 @@ export class UIRenderer {
|
|||
|
||||
if (this.player?.currentTrack?.type === 'video') {
|
||||
const coverContainer = document.querySelector('.now-playing-bar .track-info');
|
||||
const audioPlayer = document.getElementById('audio-player');
|
||||
const imgCover = coverContainer?.querySelector('.cover:not(#audio-player)');
|
||||
const videoPlayer = document.getElementById('video-player');
|
||||
const imgCover = coverContainer?.querySelector('.cover:not(#audio-player):not(#video-player)');
|
||||
|
||||
if (audioPlayer && coverContainer) {
|
||||
if (videoPlayer && coverContainer) {
|
||||
if (imgCover) imgCover.style.display = 'none';
|
||||
|
||||
audioPlayer.style.display = 'block';
|
||||
audioPlayer.classList.add('cover', 'video-cover-mirror');
|
||||
audioPlayer.style.width = '56px';
|
||||
audioPlayer.style.height = '56px';
|
||||
audioPlayer.style.borderRadius = 'var(--radius-sm)';
|
||||
audioPlayer.style.objectFit = 'cover';
|
||||
audioPlayer.style.gridArea = 'none';
|
||||
videoPlayer.style.display = 'block';
|
||||
videoPlayer.classList.add('cover', 'video-cover-mirror');
|
||||
videoPlayer.style.width = '56px';
|
||||
videoPlayer.style.height = '56px';
|
||||
videoPlayer.style.borderRadius = 'var(--radius-sm)';
|
||||
videoPlayer.style.objectFit = 'cover';
|
||||
videoPlayer.style.gridArea = 'none';
|
||||
|
||||
if (audioPlayer.parentElement !== coverContainer) {
|
||||
coverContainer.insertBefore(audioPlayer, coverContainer.firstChild);
|
||||
if (videoPlayer.parentElement !== coverContainer) {
|
||||
coverContainer.insertBefore(videoPlayer, coverContainer.firstChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1289,7 +1290,7 @@ export class UIRenderer {
|
|||
};
|
||||
}
|
||||
|
||||
setupFullscreenControls(audioPlayer) {
|
||||
setupFullscreenControls() {
|
||||
const playBtn = document.getElementById('fs-play-pause-btn');
|
||||
const prevBtn = document.getElementById('fs-prev-btn');
|
||||
const nextBtn = document.getElementById('fs-next-btn');
|
||||
|
|
@ -1318,7 +1319,8 @@ export class UIRenderer {
|
|||
|
||||
let lastPausedState = null;
|
||||
const updatePlayBtn = () => {
|
||||
const isPaused = audioPlayer.paused;
|
||||
const activeEl = this.player.activeElement;
|
||||
const isPaused = activeEl.paused;
|
||||
if (isPaused === lastPausedState) return;
|
||||
lastPausedState = isPaused;
|
||||
|
||||
|
|
@ -1364,18 +1366,20 @@ export class UIRenderer {
|
|||
let lastFsSeekPosition = 0;
|
||||
|
||||
const updateFsSeekUI = (position) => {
|
||||
if (!isNaN(audioPlayer.duration)) {
|
||||
const activeEl = this.player.activeElement;
|
||||
if (!isNaN(activeEl.duration)) {
|
||||
progressFill.style.width = `${position * 100}%`;
|
||||
if (currentTimeEl) {
|
||||
currentTimeEl.textContent = formatTime(position * audioPlayer.duration);
|
||||
currentTimeEl.textContent = formatTime(position * activeEl.duration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
progressBar.addEventListener('mousedown', (e) => {
|
||||
const activeEl = this.player.activeElement;
|
||||
isFsSeeking = true;
|
||||
wasFsPlaying = !audioPlayer.paused;
|
||||
if (wasFsPlaying) audioPlayer.pause();
|
||||
wasFsPlaying = !activeEl.paused;
|
||||
if (wasFsPlaying) activeEl.pause();
|
||||
|
||||
const rect = progressBar.getBoundingClientRect();
|
||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
|
|
@ -1386,10 +1390,11 @@ export class UIRenderer {
|
|||
progressBar.addEventListener(
|
||||
'touchstart',
|
||||
(e) => {
|
||||
const activeEl = this.player.activeElement;
|
||||
e.preventDefault();
|
||||
isFsSeeking = true;
|
||||
wasFsPlaying = !audioPlayer.paused;
|
||||
if (wasFsPlaying) audioPlayer.pause();
|
||||
wasFsPlaying = !activeEl.paused;
|
||||
if (wasFsPlaying) activeEl.pause();
|
||||
|
||||
const touch = e.touches[0];
|
||||
const rect = progressBar.getBoundingClientRect();
|
||||
|
|
@ -1425,9 +1430,10 @@ export class UIRenderer {
|
|||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isFsSeeking) {
|
||||
if (!isNaN(audioPlayer.duration)) {
|
||||
audioPlayer.currentTime = lastFsSeekPosition * audioPlayer.duration;
|
||||
if (wasFsPlaying) audioPlayer.play();
|
||||
const activeEl = this.player.activeElement;
|
||||
if (!isNaN(activeEl.duration)) {
|
||||
activeEl.currentTime = lastFsSeekPosition * activeEl.duration;
|
||||
if (wasFsPlaying) activeEl.play();
|
||||
}
|
||||
isFsSeeking = false;
|
||||
}
|
||||
|
|
@ -1435,9 +1441,10 @@ export class UIRenderer {
|
|||
|
||||
document.addEventListener('touchend', () => {
|
||||
if (isFsSeeking) {
|
||||
if (!isNaN(audioPlayer.duration)) {
|
||||
audioPlayer.currentTime = lastFsSeekPosition * audioPlayer.duration;
|
||||
if (wasFsPlaying) audioPlayer.play();
|
||||
const activeEl = this.player.activeElement;
|
||||
if (!isNaN(activeEl.duration)) {
|
||||
activeEl.currentTime = lastFsSeekPosition * activeEl.duration;
|
||||
if (wasFsPlaying) activeEl.play();
|
||||
}
|
||||
isFsSeeking = false;
|
||||
}
|
||||
|
|
@ -1476,7 +1483,8 @@ export class UIRenderer {
|
|||
|
||||
if (fsVolumeBtn && fsVolumeBar && fsVolumeFill) {
|
||||
const updateFsVolumeUI = () => {
|
||||
const { muted } = audioPlayer;
|
||||
const activeEl = this.player.activeElement;
|
||||
const { muted } = activeEl;
|
||||
const volume = this.player.userVolume;
|
||||
fsVolumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE : SVG_VOLUME;
|
||||
fsVolumeBtn.classList.toggle('muted', muted || volume === 0);
|
||||
|
|
@ -1486,8 +1494,9 @@ export class UIRenderer {
|
|||
};
|
||||
|
||||
fsVolumeBtn.onclick = () => {
|
||||
audioPlayer.muted = !audioPlayer.muted;
|
||||
localStorage.setItem('muted', audioPlayer.muted);
|
||||
const activeEl = this.player.activeElement;
|
||||
activeEl.muted = !activeEl.muted;
|
||||
localStorage.setItem('muted', activeEl.muted);
|
||||
updateFsVolumeUI();
|
||||
};
|
||||
|
||||
|
|
@ -1498,8 +1507,9 @@ export class UIRenderer {
|
|||
const currentVolume = this.player.userVolume;
|
||||
const newVolume = Math.max(0, Math.min(1, currentVolume + delta));
|
||||
|
||||
if (delta > 0 && audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
const activeEl = this.player.activeElement;
|
||||
if (delta > 0 && activeEl.muted) {
|
||||
activeEl.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 newVolume = position;
|
||||
this.player.setVolume(newVolume);
|
||||
if (audioPlayer.muted && newVolume > 0) {
|
||||
audioPlayer.muted = false;
|
||||
const activeEl = this.player.activeElement;
|
||||
if (activeEl.muted && newVolume > 0) {
|
||||
activeEl.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
}
|
||||
updateFsVolumeUI();
|
||||
|
|
@ -1570,15 +1581,16 @@ export class UIRenderer {
|
|||
isAdjustingFsVolume = false;
|
||||
});
|
||||
|
||||
audioPlayer.addEventListener('volumechange', updateFsVolumeUI);
|
||||
this.player.activeElement.addEventListener('volumechange', updateFsVolumeUI);
|
||||
updateFsVolumeUI();
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
if (document.getElementById('fullscreen-cover-overlay').style.display === 'none') return;
|
||||
|
||||
const duration = audioPlayer.duration || 0;
|
||||
const current = audioPlayer.currentTime || 0;
|
||||
const activeEl = this.player.activeElement;
|
||||
const duration = activeEl.duration || 0;
|
||||
const current = activeEl.currentTime || 0;
|
||||
|
||||
if (duration > 0) {
|
||||
// Only update progress if not currently seeking (user is dragging)
|
||||
|
|
@ -1619,6 +1631,27 @@ export class UIRenderer {
|
|||
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') {
|
||||
this.renderApiSettings();
|
||||
const savedTabName = settingsUiState.getActiveTab();
|
||||
|
|
@ -2173,6 +2206,7 @@ export class UIRenderer {
|
|||
});
|
||||
|
||||
const filteredTracks = await this.filterUserContent(recommendedTracks, 'track');
|
||||
this.lastRecommendedTracks = filteredTracks;
|
||||
|
||||
if (filteredTracks.length > 0) {
|
||||
this.renderListWithTracks(songsContainer, filteredTracks, true);
|
||||
|
|
@ -2590,16 +2624,22 @@ export class UIRenderer {
|
|||
}
|
||||
|
||||
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.endsWith('.m3u8')) {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
video._hls = hls;
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
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) => {
|
||||
if (data.fatal) {
|
||||
|
|
@ -2609,7 +2649,7 @@ export class UIRenderer {
|
|||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// i heard safari supports HLS natively
|
||||
// safari supports HLS natively
|
||||
video.src = url;
|
||||
} else {
|
||||
video.replaceWith(fallbackImg);
|
||||
|
|
@ -2617,15 +2657,20 @@ export class UIRenderer {
|
|||
} else {
|
||||
// MP4
|
||||
video.src = url;
|
||||
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);
|
||||
}
|
||||
};
|
||||
video.play().catch((e) => {
|
||||
console.warn('MP4 autoplay failed:', e);
|
||||
video.muted = true;
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
}
|
||||
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) {
|
||||
|
|
@ -2637,11 +2682,11 @@ export class UIRenderer {
|
|||
const img = card.querySelector('.card-image');
|
||||
if (img && img.tagName !== 'VIDEO') {
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = false;
|
||||
video.loop = false;
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.preload = 'auto';
|
||||
video.className = img.className;
|
||||
video.id = img.id;
|
||||
video.style.objectFit = 'cover';
|
||||
|
|
@ -2672,16 +2717,6 @@ export class UIRenderer {
|
|||
img.replaceWith(video);
|
||||
|
||||
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');
|
||||
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = false;
|
||||
video.loop = false;
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.preload = 'auto';
|
||||
video.className = currentImageEl.className;
|
||||
video.id = currentImageEl.id;
|
||||
video.style.opacity = '1';
|
||||
|
|
@ -2966,18 +3001,19 @@ export class UIRenderer {
|
|||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(album.cover);
|
||||
|
||||
if (videoCoverUrl) {
|
||||
if (imageEl.tagName === 'IMG') {
|
||||
if (imageEl.tagName !== 'VIDEO') {
|
||||
const video = document.createElement('video');
|
||||
video.src = videoCoverUrl;
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'auto';
|
||||
video.className = imageEl.className;
|
||||
video.id = imageEl.id;
|
||||
this.setupHlsVideo(video, videoCoverUrl, imageEl);
|
||||
imageEl.replaceWith(video);
|
||||
} else {
|
||||
imageEl.src = videoCoverUrl;
|
||||
this.setupHlsVideo(imageEl, videoCoverUrl, null);
|
||||
}
|
||||
} else {
|
||||
if (imageEl.tagName === 'VIDEO') {
|
||||
|
|
@ -3737,11 +3773,11 @@ export class UIRenderer {
|
|||
const currentImageEl = document.getElementById('mix-detail-image');
|
||||
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = false;
|
||||
video.loop = false;
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.preload = 'auto';
|
||||
video.className = currentImageEl.className;
|
||||
video.id = currentImageEl.id;
|
||||
video.style.opacity = '1';
|
||||
|
|
@ -4846,11 +4882,11 @@ export class UIRenderer {
|
|||
const currentImageEl = document.getElementById('track-detail-image');
|
||||
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = false;
|
||||
video.loop = false;
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.preload = 'auto';
|
||||
video.className = currentImageEl.className;
|
||||
video.id = currentImageEl.id;
|
||||
video.style.opacity = '1';
|
||||
|
|
@ -4883,18 +4919,19 @@ export class UIRenderer {
|
|||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
|
||||
|
||||
if (videoCoverUrl) {
|
||||
if (imageEl.tagName === 'IMG') {
|
||||
if (imageEl.tagName !== 'VIDEO') {
|
||||
const video = document.createElement('video');
|
||||
video.src = videoCoverUrl;
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'auto';
|
||||
video.className = imageEl.className;
|
||||
video.id = imageEl.id;
|
||||
this.setupHlsVideo(video, videoCoverUrl, imageEl);
|
||||
imageEl.replaceWith(video);
|
||||
} else {
|
||||
imageEl.src = videoCoverUrl;
|
||||
this.setupHlsVideo(imageEl, videoCoverUrl, null);
|
||||
}
|
||||
} else {
|
||||
if (imageEl.tagName === 'VIDEO') {
|
||||
|
|
|
|||
24
js/utils.js
24
js/utils.js
|
|
@ -605,3 +605,27 @@ export function getMimeType(data) {
|
|||
return 'image/png';
|
||||
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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
<link rel="icon" href="favicon.ico" type="image/x-icon" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Redirecting...</title>
|
||||
<meta http-equiv="refresh" content="0; URL='https://monochrome.binimum.org/legacy'" />
|
||||
<meta http-equiv="refresh" content="0; URL='https://legacy.monochrome.tf'" />
|
||||
<script>
|
||||
window.location.href = 'https://monochrome.binimum.org/legacy';
|
||||
window.location.href = 'https://legacy.monochrome.tf';
|
||||
</script>
|
||||
</head>
|
||||
<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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
53
styles.css
53
styles.css
|
|
@ -3122,6 +3122,59 @@ input:checked + .slider::before {
|
|||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
Loading…
Reference in a new issue