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>
<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>
@ -2212,36 +2216,41 @@
<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 +5624,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

View file

@ -192,7 +192,7 @@ function initializeCasting(audioPlayer, castBtn) {
}
}
function initializeKeyboardShortcuts(player, audioPlayer) {
function initializeKeyboardShortcuts(player, _audioPlayer) {
const keyActionMap = {
playPause: () => {
trackKeyboardShortcut('Space');
@ -200,11 +200,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');
@ -224,7 +224,8 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
},
mute: () => {
trackKeyboardShortcut('M');
audioPlayer.muted = !audioPlayer.muted;
const el = player.activeElement;
el.muted = !el.muted;
},
shuffle: () => {
trackKeyboardShortcut('S');
@ -250,7 +251,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');
@ -424,8 +425,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');
});
@ -595,9 +597,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');
@ -609,7 +611,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
@ -897,9 +899,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);
}
});
@ -927,14 +929,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
@ -945,7 +947,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);
}
});

View file

@ -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,97 @@ 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();

View file

@ -61,155 +61,156 @@ 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-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 +238,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,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
const updateWaveform = async () => {
@ -374,37 +377,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 +400,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 +424,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 +448,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 +477,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 +493,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 +510,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 +526,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 +545,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 +565,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 +581,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 +601,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 +624,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 +778,40 @@ 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'];
if (item.isUnavailable && forbiddenForUnavailable.includes(action)) {
showNotification('This track is unavailable.');
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 (item.mixes && item.mixes.TRACK_MIX) {
navigate(`/mix/${item.mixes.TRACK_MIX}`);

View file

@ -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,58 @@ 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 +170,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 +331,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 +350,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 +366,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 +386,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 +402,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();
}
@ -421,7 +468,6 @@ export class Player {
this.hls.loadSource(url);
this.hls.attachMedia(video);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {});
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
@ -461,6 +507,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 +537,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 +599,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 +640,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 +668,74 @@ 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 +751,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 +776,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 +832,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 +872,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 +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) {
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 +1053,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 +1067,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 +1089,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 +1150,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 +1247,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 +1359,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 +1376,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 +1398,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 +1439,7 @@ export class Player {
this.sleepTimer = setTimeout(
() => {
this.audio.pause();
this.activeElement.pause();
this.clearSleepTimer();
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 = {
ENABLED_KEY: 'analytics-enabled',

119
js/ui.js
View file

@ -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';
@ -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)
@ -2173,6 +2185,7 @@ export class UIRenderer {
});
const filteredTracks = await this.filterUserContent(recommendedTracks, 'track');
this.lastRecommendedTracks = filteredTracks;
if (filteredTracks.length > 0) {
this.renderListWithTracks(songsContainer, filteredTracks, true);

View file

@ -3122,6 +3122,58 @@ 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;