feat(recommendations): Infinite Radio

This commit is contained in:
Samidy 2026-03-10 04:58:17 +03:00
parent 34ba920662
commit 473e5ba8b6
8 changed files with 828 additions and 436 deletions

View file

@ -36,12 +36,16 @@
</head> </head>
<body> <body>
<video id="audio-player" crossorigin="anonymous" style="display: none"></video> <audio id="audio-player" crossorigin="anonymous" style="display: none"></audio>
<video id="video-player" crossorigin="anonymous" style="display: none"></video>
<div id="context-menu"> <div id="context-menu">
<ul> <ul>
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist"> <li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
Shuffle play Shuffle play
</li> </li>
<li data-action="start-infinite-radio" data-type-filter="track,album,playlist,user-playlist">
Start Infinite Radio
</li>
<li data-action="start-mix" data-type-filter="album,track,video">Start mix</li> <li data-action="start-mix" data-type-filter="album,track,video">Start mix</li>
<li data-action="play-next">Play next</li> <li data-action="play-next">Play next</li>
<li data-action="add-to-queue">Add to queue</li> <li data-action="add-to-queue">Add to queue</li>
@ -2212,36 +2216,41 @@
<div id="home-content" style="display: none"> <div id="home-content" style="display: none">
<section class="content-section"> <section class="content-section">
<div <div class="header-actions">
style=" <h2 class="section-title">Recommended Songs</h2>
display: flex; <div style="display: flex; gap: 8px;">
align-items: center; <button
justify-content: space-between; class="btn-primary"
margin-bottom: 1rem; id="home-start-infinite-radio-btn"
" title="Start Infinite Radio"
> style="display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 0.85rem;"
<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"
> >
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <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="M21 3v5h-5" /> <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> </svg>
</button> Start Infinite Radio
</button>
<button
class="btn-secondary"
id="refresh-songs-btn"
title="Refresh"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
</button>
</div>
</div> </div>
<div class="track-list" id="home-recommended-songs"></div> <div class="track-list" id="home-recommended-songs"></div>
</section> </section>
@ -5615,6 +5624,10 @@
</div> </div>
</div> </div>
<div class="player-controls"> <div class="player-controls">
<div id="radio-loading-indicator">
<div class="animate-spin"></div>
<span>Finding more songs for you...</span>
</div>
<div class="buttons"> <div class="buttons">
<button id="shuffle-btn" title="Shuffle"> <button id="shuffle-btn" title="Shuffle">
<svg <svg

View file

@ -192,7 +192,7 @@ function initializeCasting(audioPlayer, castBtn) {
} }
} }
function initializeKeyboardShortcuts(player, audioPlayer) { function initializeKeyboardShortcuts(player, _audioPlayer) {
const keyActionMap = { const keyActionMap = {
playPause: () => { playPause: () => {
trackKeyboardShortcut('Space'); trackKeyboardShortcut('Space');
@ -200,11 +200,11 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
}, },
seekForward: () => { seekForward: () => {
trackKeyboardShortcut('Right'); trackKeyboardShortcut('Right');
audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10); player.seekForward(10);
}, },
seekBackward: () => { seekBackward: () => {
trackKeyboardShortcut('Left'); trackKeyboardShortcut('Left');
audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10); player.seekBackward(10);
}, },
nextTrack: () => { nextTrack: () => {
trackKeyboardShortcut('Shift+Right'); trackKeyboardShortcut('Shift+Right');
@ -224,7 +224,8 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
}, },
mute: () => { mute: () => {
trackKeyboardShortcut('M'); trackKeyboardShortcut('M');
audioPlayer.muted = !audioPlayer.muted; const el = player.activeElement;
el.muted = !el.muted;
}, },
shuffle: () => { shuffle: () => {
trackKeyboardShortcut('S'); trackKeyboardShortcut('S');
@ -250,7 +251,7 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
trackKeyboardShortcut('Escape'); trackKeyboardShortcut('Escape');
document.getElementById('search-input')?.blur(); document.getElementById('search-input')?.blur();
sidePanelManager.close(); sidePanelManager.close();
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
}, },
visualizerNext: () => { visualizerNext: () => {
trackKeyboardShortcut('VisualizerNext'); trackKeyboardShortcut('VisualizerNext');
@ -424,8 +425,9 @@ document.addEventListener('DOMContentLoaded', async () => {
events.on('mediaPrevious', () => player.playPrev()); events.on('mediaPrevious', () => player.playPrev());
events.on('mediaPlayPause', () => player.handlePlayPause()); events.on('mediaPlayPause', () => player.handlePlayPause());
events.on('mediaStop', () => { events.on('mediaStop', () => {
player.audio.pause(); const el = player.activeElement;
player.audio.currentTime = 0; el.pause();
el.currentTime = 0;
}); });
console.log('Media keys initialized via bridge'); console.log('Media keys initialized via bridge');
}); });
@ -595,9 +597,9 @@ document.addEventListener('DOMContentLoaded', async () => {
if (isActive) { if (isActive) {
sidePanelManager.close(); sidePanelManager.close();
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
} else { } else {
openLyricsPanel(player.currentTrack, audioPlayer, lyricsManager); openLyricsPanel(player.currentTrack, player.activeElement, lyricsManager);
} }
} else if (mode === 'cover') { } else if (mode === 'cover') {
const overlay = document.getElementById('fullscreen-cover-overlay'); const overlay = document.getElementById('fullscreen-cover-overlay');
@ -609,7 +611,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
} else { } else {
const nextTrack = player.getNextTrack(); const nextTrack = player.getNextTrack();
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer); ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, player.activeElement);
} }
} else { } else {
// Default to 'album' mode - navigate to album // Default to 'album' mode - navigate to album
@ -897,9 +899,9 @@ document.addEventListener('DOMContentLoaded', async () => {
if (isActive) { if (isActive) {
sidePanelManager.close(); sidePanelManager.close();
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
} else { } else {
openLyricsPanel(player.currentTrack, audioPlayer, lyricsManager); openLyricsPanel(player.currentTrack, player.activeElement, lyricsManager);
} }
}); });
@ -927,14 +929,14 @@ document.addEventListener('DOMContentLoaded', async () => {
// Update lyrics panel if it's open // Update lyrics panel if it's open
if (sidePanelManager.isActive('lyrics')) { if (sidePanelManager.isActive('lyrics')) {
// Re-open forces update/refresh of content and sync // Re-open forces update/refresh of content and sync
openLyricsPanel(player.currentTrack, audioPlayer, lyricsManager, true); openLyricsPanel(player.currentTrack, player.activeElement, lyricsManager, true);
} }
// Update Fullscreen if it's open // Update Fullscreen if it's open
const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay'); const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay');
if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') { if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') {
const nextTrack = player.getNextTrack(); const nextTrack = player.getNextTrack();
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer); ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, player.activeElement);
} }
// DEV: Auto-open fullscreen mode if ?fullscreen=1 in URL // DEV: Auto-open fullscreen mode if ?fullscreen=1 in URL
@ -945,7 +947,7 @@ document.addEventListener('DOMContentLoaded', async () => {
getComputedStyle(fullscreenOverlay).display === 'none' getComputedStyle(fullscreenOverlay).display === 'none'
) { ) {
const nextTrack = player.getNextTrack(); const nextTrack = player.getNextTrack();
ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, audioPlayer); ui.showFullscreenCover(player.currentTrack, nextTrack, lyricsManager, player.activeElement);
} }
}); });

View file

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

View file

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

View file

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

View file

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

119
js/ui.js
View file

@ -112,6 +112,7 @@ export class UIRenderer {
this.vibrantColorCache = new Map(); this.vibrantColorCache = new Map();
this.visualizer = null; this.visualizer = null;
this.renderLock = false; this.renderLock = false;
this.lastRecommendedTracks = [];
// Listen for dynamic color reset events // Listen for dynamic color reset events
window.addEventListener('reset-dynamic-color', () => { window.addEventListener('reset-dynamic-color', () => {
@ -988,13 +989,13 @@ export class UIRenderer {
if (videoContainer) { if (videoContainer) {
videoContainer.style.display = 'flex'; videoContainer.style.display = 'flex';
const audioPlayer = document.getElementById('audio-player'); const videoPlayer = document.getElementById('video-player');
if (audioPlayer && audioPlayer.parentElement !== videoContainer) { if (videoPlayer && videoPlayer.parentElement !== videoContainer) {
videoContainer.appendChild(audioPlayer); videoContainer.appendChild(videoPlayer);
audioPlayer.style.display = 'block'; videoPlayer.style.display = 'block';
audioPlayer.style.width = '100%'; videoPlayer.style.width = '100%';
audioPlayer.style.height = '100%'; videoPlayer.style.height = '100%';
audioPlayer.style.objectFit = 'contain'; videoPlayer.style.objectFit = 'contain';
} }
} }
if (image) image.style.display = 'none'; if (image) image.style.display = 'none';
@ -1002,10 +1003,10 @@ export class UIRenderer {
} else { } else {
if (videoContainer) { if (videoContainer) {
videoContainer.style.display = 'none'; videoContainer.style.display = 'none';
const audioPlayer = document.getElementById('audio-player'); const videoPlayer = document.getElementById('video-player');
if (audioPlayer && audioPlayer.parentElement === videoContainer) { if (videoPlayer && videoPlayer.parentElement === videoContainer) {
document.body.appendChild(audioPlayer); document.body.appendChild(videoPlayer);
audioPlayer.style.display = 'none'; videoPlayer.style.display = 'none';
} }
} }
if (image) image.style.display = 'block'; if (image) image.style.display = 'block';
@ -1062,7 +1063,7 @@ export class UIRenderer {
} }
} }
async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) { async showFullscreenCover(track, nextTrack, lyricsManager, activeElement) {
if (!track) return; if (!track) return;
if (window.location.hash !== '#fullscreen') { if (window.location.hash !== '#fullscreen') {
window.history.pushState({ fullscreen: true }, '', '#fullscreen'); window.history.pushState({ fullscreen: true }, '', '#fullscreen');
@ -1081,12 +1082,12 @@ export class UIRenderer {
nextTrackEl.classList.remove('animate-in'); nextTrackEl.classList.remove('animate-in');
} }
if (lyricsManager && audioPlayer) { if (lyricsManager && activeElement) {
lyricsToggleBtn.style.display = 'flex'; lyricsToggleBtn.style.display = 'flex';
lyricsToggleBtn.classList.remove('active'); lyricsToggleBtn.classList.remove('active');
const toggleLyrics = () => { const toggleLyrics = () => {
openLyricsPanel(track, audioPlayer, lyricsManager); openLyricsPanel(track, activeElement, lyricsManager);
lyricsToggleBtn.classList.toggle('active'); lyricsToggleBtn.classList.toggle('active');
}; };
@ -1100,7 +1101,7 @@ export class UIRenderer {
const playerBar = document.querySelector('.now-playing-bar'); const playerBar = document.querySelector('.now-playing-bar');
if (playerBar) playerBar.style.display = 'none'; if (playerBar) playerBar.style.display = 'none';
this.setupFullscreenControls(audioPlayer); this.setupFullscreenControls();
overlay.style.display = 'flex'; overlay.style.display = 'flex';
@ -1110,10 +1111,10 @@ export class UIRenderer {
return; return;
} }
if (!this.visualizer && audioPlayer) { if (!this.visualizer && activeElement) {
const canvas = document.getElementById('visualizer-canvas'); const canvas = document.getElementById('visualizer-canvas');
if (canvas) { if (canvas) {
this.visualizer = new Visualizer(canvas, audioPlayer); this.visualizer = new Visualizer(canvas, activeElement);
} }
} }
if (this.visualizer) { if (this.visualizer) {
@ -1162,22 +1163,22 @@ export class UIRenderer {
if (this.player?.currentTrack?.type === 'video') { if (this.player?.currentTrack?.type === 'video') {
const coverContainer = document.querySelector('.now-playing-bar .track-info'); const coverContainer = document.querySelector('.now-playing-bar .track-info');
const audioPlayer = document.getElementById('audio-player'); const videoPlayer = document.getElementById('video-player');
const imgCover = coverContainer?.querySelector('.cover:not(#audio-player)'); const imgCover = coverContainer?.querySelector('.cover:not(#audio-player):not(#video-player)');
if (audioPlayer && coverContainer) { if (videoPlayer && coverContainer) {
if (imgCover) imgCover.style.display = 'none'; if (imgCover) imgCover.style.display = 'none';
audioPlayer.style.display = 'block'; videoPlayer.style.display = 'block';
audioPlayer.classList.add('cover', 'video-cover-mirror'); videoPlayer.classList.add('cover', 'video-cover-mirror');
audioPlayer.style.width = '56px'; videoPlayer.style.width = '56px';
audioPlayer.style.height = '56px'; videoPlayer.style.height = '56px';
audioPlayer.style.borderRadius = 'var(--radius-sm)'; videoPlayer.style.borderRadius = 'var(--radius-sm)';
audioPlayer.style.objectFit = 'cover'; videoPlayer.style.objectFit = 'cover';
audioPlayer.style.gridArea = 'none'; videoPlayer.style.gridArea = 'none';
if (audioPlayer.parentElement !== coverContainer) { if (videoPlayer.parentElement !== coverContainer) {
coverContainer.insertBefore(audioPlayer, coverContainer.firstChild); coverContainer.insertBefore(videoPlayer, coverContainer.firstChild);
} }
} }
} }
@ -1289,7 +1290,7 @@ export class UIRenderer {
}; };
} }
setupFullscreenControls(audioPlayer) { setupFullscreenControls() {
const playBtn = document.getElementById('fs-play-pause-btn'); const playBtn = document.getElementById('fs-play-pause-btn');
const prevBtn = document.getElementById('fs-prev-btn'); const prevBtn = document.getElementById('fs-prev-btn');
const nextBtn = document.getElementById('fs-next-btn'); const nextBtn = document.getElementById('fs-next-btn');
@ -1318,7 +1319,8 @@ export class UIRenderer {
let lastPausedState = null; let lastPausedState = null;
const updatePlayBtn = () => { const updatePlayBtn = () => {
const isPaused = audioPlayer.paused; const activeEl = this.player.activeElement;
const isPaused = activeEl.paused;
if (isPaused === lastPausedState) return; if (isPaused === lastPausedState) return;
lastPausedState = isPaused; lastPausedState = isPaused;
@ -1364,18 +1366,20 @@ export class UIRenderer {
let lastFsSeekPosition = 0; let lastFsSeekPosition = 0;
const updateFsSeekUI = (position) => { const updateFsSeekUI = (position) => {
if (!isNaN(audioPlayer.duration)) { const activeEl = this.player.activeElement;
if (!isNaN(activeEl.duration)) {
progressFill.style.width = `${position * 100}%`; progressFill.style.width = `${position * 100}%`;
if (currentTimeEl) { if (currentTimeEl) {
currentTimeEl.textContent = formatTime(position * audioPlayer.duration); currentTimeEl.textContent = formatTime(position * activeEl.duration);
} }
} }
}; };
progressBar.addEventListener('mousedown', (e) => { progressBar.addEventListener('mousedown', (e) => {
const activeEl = this.player.activeElement;
isFsSeeking = true; isFsSeeking = true;
wasFsPlaying = !audioPlayer.paused; wasFsPlaying = !activeEl.paused;
if (wasFsPlaying) audioPlayer.pause(); if (wasFsPlaying) activeEl.pause();
const rect = progressBar.getBoundingClientRect(); const rect = progressBar.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
@ -1386,10 +1390,11 @@ export class UIRenderer {
progressBar.addEventListener( progressBar.addEventListener(
'touchstart', 'touchstart',
(e) => { (e) => {
const activeEl = this.player.activeElement;
e.preventDefault(); e.preventDefault();
isFsSeeking = true; isFsSeeking = true;
wasFsPlaying = !audioPlayer.paused; wasFsPlaying = !activeEl.paused;
if (wasFsPlaying) audioPlayer.pause(); if (wasFsPlaying) activeEl.pause();
const touch = e.touches[0]; const touch = e.touches[0];
const rect = progressBar.getBoundingClientRect(); const rect = progressBar.getBoundingClientRect();
@ -1425,9 +1430,10 @@ export class UIRenderer {
document.addEventListener('mouseup', () => { document.addEventListener('mouseup', () => {
if (isFsSeeking) { if (isFsSeeking) {
if (!isNaN(audioPlayer.duration)) { const activeEl = this.player.activeElement;
audioPlayer.currentTime = lastFsSeekPosition * audioPlayer.duration; if (!isNaN(activeEl.duration)) {
if (wasFsPlaying) audioPlayer.play(); activeEl.currentTime = lastFsSeekPosition * activeEl.duration;
if (wasFsPlaying) activeEl.play();
} }
isFsSeeking = false; isFsSeeking = false;
} }
@ -1435,9 +1441,10 @@ export class UIRenderer {
document.addEventListener('touchend', () => { document.addEventListener('touchend', () => {
if (isFsSeeking) { if (isFsSeeking) {
if (!isNaN(audioPlayer.duration)) { const activeEl = this.player.activeElement;
audioPlayer.currentTime = lastFsSeekPosition * audioPlayer.duration; if (!isNaN(activeEl.duration)) {
if (wasFsPlaying) audioPlayer.play(); activeEl.currentTime = lastFsSeekPosition * activeEl.duration;
if (wasFsPlaying) activeEl.play();
} }
isFsSeeking = false; isFsSeeking = false;
} }
@ -1476,7 +1483,8 @@ export class UIRenderer {
if (fsVolumeBtn && fsVolumeBar && fsVolumeFill) { if (fsVolumeBtn && fsVolumeBar && fsVolumeFill) {
const updateFsVolumeUI = () => { const updateFsVolumeUI = () => {
const { muted } = audioPlayer; const activeEl = this.player.activeElement;
const { muted } = activeEl;
const volume = this.player.userVolume; const volume = this.player.userVolume;
fsVolumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE : SVG_VOLUME; fsVolumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE : SVG_VOLUME;
fsVolumeBtn.classList.toggle('muted', muted || volume === 0); fsVolumeBtn.classList.toggle('muted', muted || volume === 0);
@ -1486,8 +1494,9 @@ export class UIRenderer {
}; };
fsVolumeBtn.onclick = () => { fsVolumeBtn.onclick = () => {
audioPlayer.muted = !audioPlayer.muted; const activeEl = this.player.activeElement;
localStorage.setItem('muted', audioPlayer.muted); activeEl.muted = !activeEl.muted;
localStorage.setItem('muted', activeEl.muted);
updateFsVolumeUI(); updateFsVolumeUI();
}; };
@ -1498,8 +1507,9 @@ export class UIRenderer {
const currentVolume = this.player.userVolume; const currentVolume = this.player.userVolume;
const newVolume = Math.max(0, Math.min(1, currentVolume + delta)); const newVolume = Math.max(0, Math.min(1, currentVolume + delta));
if (delta > 0 && audioPlayer.muted) { const activeEl = this.player.activeElement;
audioPlayer.muted = false; if (delta > 0 && activeEl.muted) {
activeEl.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
} }
@ -1520,8 +1530,9 @@ export class UIRenderer {
const position = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const position = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const newVolume = position; const newVolume = position;
this.player.setVolume(newVolume); this.player.setVolume(newVolume);
if (audioPlayer.muted && newVolume > 0) { const activeEl = this.player.activeElement;
audioPlayer.muted = false; if (activeEl.muted && newVolume > 0) {
activeEl.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
} }
updateFsVolumeUI(); updateFsVolumeUI();
@ -1570,15 +1581,16 @@ export class UIRenderer {
isAdjustingFsVolume = false; isAdjustingFsVolume = false;
}); });
audioPlayer.addEventListener('volumechange', updateFsVolumeUI); this.player.activeElement.addEventListener('volumechange', updateFsVolumeUI);
updateFsVolumeUI(); updateFsVolumeUI();
} }
const update = () => { const update = () => {
if (document.getElementById('fullscreen-cover-overlay').style.display === 'none') return; if (document.getElementById('fullscreen-cover-overlay').style.display === 'none') return;
const duration = audioPlayer.duration || 0; const activeEl = this.player.activeElement;
const current = audioPlayer.currentTime || 0; const duration = activeEl.duration || 0;
const current = activeEl.currentTime || 0;
if (duration > 0) { if (duration > 0) {
// Only update progress if not currently seeking (user is dragging) // Only update progress if not currently seeking (user is dragging)
@ -2173,6 +2185,7 @@ export class UIRenderer {
}); });
const filteredTracks = await this.filterUserContent(recommendedTracks, 'track'); const filteredTracks = await this.filterUserContent(recommendedTracks, 'track');
this.lastRecommendedTracks = filteredTracks;
if (filteredTracks.length > 0) { if (filteredTracks.length > 0) {
this.renderListWithTracks(songsContainer, filteredTracks, true); this.renderListWithTracks(songsContainer, filteredTracks, true);

View file

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