feat(recommendations): Infinite Radio
This commit is contained in:
parent
34ba920662
commit
473e5ba8b6
8 changed files with 828 additions and 436 deletions
73
index.html
73
index.html
|
|
@ -36,12 +36,16 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<video id="audio-player" crossorigin="anonymous" style="display: none"></video>
|
||||
<audio id="audio-player" crossorigin="anonymous" style="display: none"></audio>
|
||||
<video id="video-player" crossorigin="anonymous" style="display: none"></video>
|
||||
<div id="context-menu">
|
||||
<ul>
|
||||
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
|
||||
Shuffle play
|
||||
</li>
|
||||
<li data-action="start-infinite-radio" data-type-filter="track,album,playlist,user-playlist">
|
||||
Start Infinite Radio
|
||||
</li>
|
||||
<li data-action="start-mix" data-type-filter="album,track,video">Start mix</li>
|
||||
<li data-action="play-next">Play next</li>
|
||||
<li data-action="add-to-queue">Add to queue</li>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
32
js/app.js
32
js/app.js
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
410
js/events.js
410
js/events.js
|
|
@ -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}`);
|
||||
|
|
|
|||
497
js/player.js
497
js/player.js
|
|
@ -16,13 +16,16 @@ import {
|
|||
trackDateSettings,
|
||||
exponentialVolumeSettings,
|
||||
audioEffectsSettings,
|
||||
radioSettings,
|
||||
} from './storage.js';
|
||||
import { audioContextManager } from './audio-context.js';
|
||||
import { db } from './db.js';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
export class Player {
|
||||
constructor(audioElement, api, quality = 'HI_RES_LOSSLESS') {
|
||||
this.audio = audioElement;
|
||||
this.video = document.getElementById('video-player');
|
||||
this.api = api;
|
||||
this.quality = quality;
|
||||
this.queue = [];
|
||||
|
|
@ -53,6 +56,11 @@ export class Player {
|
|||
this.audio.addEventListener('canplay', () => {
|
||||
this.applyAudioEffects();
|
||||
});
|
||||
if (this.video) {
|
||||
this.video.addEventListener('canplay', () => {
|
||||
this.applyAudioEffects();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize dash.js player
|
||||
this.dashPlayer = MediaPlayer().create();
|
||||
|
|
@ -68,24 +76,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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
119
js/ui.js
|
|
@ -112,6 +112,7 @@ export class UIRenderer {
|
|||
this.vibrantColorCache = new Map();
|
||||
this.visualizer = null;
|
||||
this.renderLock = false;
|
||||
this.lastRecommendedTracks = [];
|
||||
|
||||
// Listen for dynamic color reset events
|
||||
window.addEventListener('reset-dynamic-color', () => {
|
||||
|
|
@ -988,13 +989,13 @@ export class UIRenderer {
|
|||
|
||||
if (videoContainer) {
|
||||
videoContainer.style.display = 'flex';
|
||||
const audioPlayer = document.getElementById('audio-player');
|
||||
if (audioPlayer && audioPlayer.parentElement !== videoContainer) {
|
||||
videoContainer.appendChild(audioPlayer);
|
||||
audioPlayer.style.display = 'block';
|
||||
audioPlayer.style.width = '100%';
|
||||
audioPlayer.style.height = '100%';
|
||||
audioPlayer.style.objectFit = 'contain';
|
||||
const videoPlayer = document.getElementById('video-player');
|
||||
if (videoPlayer && videoPlayer.parentElement !== videoContainer) {
|
||||
videoContainer.appendChild(videoPlayer);
|
||||
videoPlayer.style.display = 'block';
|
||||
videoPlayer.style.width = '100%';
|
||||
videoPlayer.style.height = '100%';
|
||||
videoPlayer.style.objectFit = 'contain';
|
||||
}
|
||||
}
|
||||
if (image) image.style.display = 'none';
|
||||
|
|
@ -1002,10 +1003,10 @@ export class UIRenderer {
|
|||
} else {
|
||||
if (videoContainer) {
|
||||
videoContainer.style.display = 'none';
|
||||
const audioPlayer = document.getElementById('audio-player');
|
||||
if (audioPlayer && audioPlayer.parentElement === videoContainer) {
|
||||
document.body.appendChild(audioPlayer);
|
||||
audioPlayer.style.display = 'none';
|
||||
const videoPlayer = document.getElementById('video-player');
|
||||
if (videoPlayer && videoPlayer.parentElement === videoContainer) {
|
||||
document.body.appendChild(videoPlayer);
|
||||
videoPlayer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
if (image) image.style.display = 'block';
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
52
styles.css
52
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue