add "add to playlist" button on player, add sleep timer feature

This commit is contained in:
Samidy 2026-01-08 14:14:46 +03:00
parent 6635f5ed4d
commit 0234df5a7c
5 changed files with 240 additions and 101 deletions

View file

@ -804,6 +804,12 @@
<div class="player-actions-row">
<button id="now-playing-like-btn" class="like-btn" data-action="toggle-like" title="Save to Favorites" style="display: none;">
</button>
<button id="now-playing-add-playlist-btn" title="Add to Playlist" class="desktop-only">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button id="now-playing-mix-btn" class="mix-btn" data-action="track-mix" title="Track Mix" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
@ -819,12 +825,11 @@
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<button id="cast-btn" title="Cast" class="desktop-only">
<button id="mobile-add-playlist-btn" class="mobile-only">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6"></path>
<path d="M2 12a9 9 0 0 1 9 9"></path>
<path d="M2 17a5 5 0 0 1 5 5"></path>
<path d="M2 22h.01"></path>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button id="queue-btn" title="Queue">
@ -836,10 +841,22 @@
<div id="volume-bar" class="volume-bar">
<div id="volume-fill" class="volume-fill"></div>
</div>
<button id="sleep-timer-btn" title="Sleep Timer">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12,6 12,12 16,14"/>
</svg>
</button>
</div>
</div>
</footer>
</div>
<script type="module" src="js/app.js"></script>
</body>
</html>
</div>
</html>
</div>
</div>

View file

@ -16,78 +16,7 @@ import { db } from './db.js';
import { syncManager } from './firebase/sync.js';
import { registerSW } from 'virtual:pwa-register';
function initializeCasting(audioPlayer, castBtn) {
if (!castBtn) return;
if ('remote' in audioPlayer) {
audioPlayer.remote.watchAvailability((available) => {
if (available) {
castBtn.style.display = 'flex';
castBtn.classList.add('available');
}
}).catch(err => {
console.log('Remote playback not available:', err);
if (window.innerWidth > 768) {
castBtn.style.display = 'flex';
}
});
castBtn.addEventListener('click', () => {
if (!audioPlayer.src) {
alert('Please play a track first to enable casting.');
return;
}
audioPlayer.remote.prompt().catch(err => {
if (err.name === 'NotAllowedError') return;
if (err.name === 'NotFoundError') {
alert('No remote playback devices (Chromecast/AirPlay) were found on your network.');
return;
}
console.log('Cast prompt error:', err);
});
});
audioPlayer.addEventListener('playing', () => {
if (audioPlayer.remote && audioPlayer.remote.state === 'connected') {
castBtn.classList.add('connected');
}
});
audioPlayer.addEventListener('pause', () => {
if (audioPlayer.remote && audioPlayer.remote.state === 'disconnected') {
castBtn.classList.remove('connected');
}
});
}
else if (audioPlayer.webkitShowPlaybackTargetPicker) {
castBtn.style.display = 'flex';
castBtn.classList.add('available');
castBtn.addEventListener('click', () => {
audioPlayer.webkitShowPlaybackTargetPicker();
});
audioPlayer.addEventListener('webkitplaybacktargetavailabilitychanged', (e) => {
if (e.availability === 'available') {
castBtn.classList.add('available');
}
});
audioPlayer.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', () => {
if (audioPlayer.webkitCurrentPlaybackTargetIsWireless) {
castBtn.classList.add('connected');
} else {
castBtn.classList.remove('connected');
}
});
}
else if (window.innerWidth > 768) {
castBtn.style.display = 'flex';
castBtn.addEventListener('click', () => {
alert('Casting is not supported in this browser. Try Chrome for Chromecast or Safari for AirPlay.');
});
}
}
function initializeKeyboardShortcuts(player, audioPlayer) {
document.addEventListener('keydown', (e) => {
@ -199,8 +128,7 @@ document.addEventListener('DOMContentLoaded', async () => {
initializeUIInteractions(player, api);
initializeKeyboardShortcuts(player, audioPlayer);
const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn);
// Restore UI state for the current track (like button, theme)
if (player.currentTrack) {
@ -1171,3 +1099,4 @@ function showKeyboardShortcuts() {
}
});
}

View file

@ -13,6 +13,7 @@ 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 sleepTimerBtn = document.getElementById('sleep-timer-btn');
// History tracking
let historyLoggedTrackId = null;
@ -113,6 +114,16 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
: (mode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One');
});
// Sleep Timer
sleepTimerBtn.addEventListener('click', () => {
if (player.isSleepTimerActive()) {
player.clearSleepTimer();
showNotification('Sleep timer cancelled');
} else {
showSleepTimerModal(player);
}
});
// Volume controls
const volumeBar = document.getElementById('volume-bar');
const volumeFill = document.getElementById('volume-fill');
@ -680,44 +691,96 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
});
}
}
function renderQueue(player) {
// This will be called from queue module
if (window.renderQueueFunction) {
window.renderQueueFunction();
const nowPlayingAddPlaylistBtn = document.getElementById('now-playing-add-playlist-btn');
if (nowPlayingAddPlaylistBtn) {
nowPlayingAddPlaylistBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (player.currentTrack) {
await handleTrackAction('add-to-playlist', player.currentTrack, player, api, lyricsManager, 'track', ui, scrobbler);
}
});
}
// Mobile add playlist button functionality
const mobileAddPlaylistBtn = document.getElementById('mobile-add-playlist-btn');
if (mobileAddPlaylistBtn) {
mobileAddPlaylistBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (player.currentTrack) {
await handleTrackAction('add-to-playlist', player.currentTrack, player, api, lyricsManager, 'track', ui, scrobbler);
}
});
}
}
function showSleepTimerModal(player) {
const modal = document.createElement('div');
modal.className = 'sleep-timer-modal';
modal.innerHTML = `
<div class="modal-overlay" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center;">
<div class="modal-content" style="background: var(--card); padding: 2rem; border-radius: var(--radius); max-width: 300px; width: 90%;">
<h3 style="text-align: center; margin-bottom: 1.5rem;">Sleep Timer</h3>
<div class="timer-options" style="display: flex; flex-direction: column; gap: 0.5rem;">
<button class="timer-option btn-secondary" data-minutes="5">5 minutes</button>
<button class="timer-option btn-secondary" data-minutes="15">15 minutes</button>
<button class="timer-option btn-secondary" data-minutes="30">30 minutes</button>
<button class="timer-option btn-secondary" data-minutes="60">1 hour</button>
<button class="timer-option btn-secondary" data-minutes="120">2 hours</button>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="number" id="custom-minutes" placeholder="Custom" min="1" max="480" style="flex: 1; padding: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--background); color: var(--foreground);">
<button class="timer-option btn-primary" id="custom-timer-btn" style="padding: 0.5rem 1rem;">Set</button>
</div>
</div>
<div class="modal-actions" style="display: flex; gap: 0.5rem; justify-content: center; margin-top: 1.5rem;">
<button id="cancel-sleep-timer" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
async function updateContextMenuLikeState(menu, track) {
const likeItem = menu.querySelector('[data-action="toggle-like"]');
if (likeItem) {
const isLiked = await db.isFavorite('track', track.id);
likeItem.textContent = isLiked ? 'Remove from Favorites' : 'Add to Favorites';
}
const mixItem = menu.querySelector('[data-action="track-mix"]');
if (mixItem) {
if (track.mixes && track.mixes.TRACK_MIX) {
mixItem.style.display = 'block';
} else {
mixItem.style.display = 'none';
modal.addEventListener('click', (e) => {
if (e.target.id === 'cancel-sleep-timer' || e.target.classList.contains('modal-overlay')) {
modal.remove();
return;
}
}
const timerOption = e.target.closest('.timer-option');
if (timerOption) {
const minutes = parseInt(timerOption.dataset.minutes);
if (minutes) {
player.setSleepTimer(minutes);
showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`);
modal.remove();
}
}
if (e.target.id === 'custom-timer-btn') {
const customInput = document.getElementById('custom-minutes');
const minutes = parseInt(customInput.value);
if (minutes && minutes > 0 && minutes <= 480) {
player.setSleepTimer(minutes);
showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`);
modal.remove();
} else {
showNotification('Please enter a valid number of minutes (1-480)');
}
}
});
}
function positionMenu(menu, x, y, anchorRect = null) {
// Temporarily show to measure dimensions
menu.style.visibility = 'hidden';
menu.style.display = 'block';
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let left = x;
let top = y;

View file

@ -17,9 +17,14 @@ export class Player {
this.preloadAbortController = null;
this.currentTrack = null;
// Sleep timer properties
this.sleepTimer = null;
this.sleepTimerEndTime = null;
this.sleepTimerInterval = null;
this.loadQueueState();
this.setupMediaSession();
window.addEventListener('beforeunload', () => {
this.saveQueueState();
});
@ -523,4 +528,81 @@ export class Player {
console.debug('Failed to update Media Session position:', error);
}
}
// Sleep Timer Methods
setSleepTimer(minutes) {
this.clearSleepTimer(); // Clear any existing timer
this.sleepTimerEndTime = Date.now() + (minutes * 60 * 1000);
this.sleepTimer = setTimeout(() => {
this.audio.pause();
this.clearSleepTimer();
this.updateSleepTimerUI();
}, minutes * 60 * 1000);
// Update UI every second
this.sleepTimerInterval = setInterval(() => {
this.updateSleepTimerUI();
}, 1000);
this.updateSleepTimerUI();
}
clearSleepTimer() {
if (this.sleepTimer) {
clearTimeout(this.sleepTimer);
this.sleepTimer = null;
}
if (this.sleepTimerInterval) {
clearInterval(this.sleepTimerInterval);
this.sleepTimerInterval = null;
}
this.sleepTimerEndTime = null;
this.updateSleepTimerUI();
}
getSleepTimerRemaining() {
if (!this.sleepTimerEndTime) return null;
const remaining = Math.max(0, this.sleepTimerEndTime - Date.now());
return Math.ceil(remaining / 1000); // Return seconds remaining
}
isSleepTimerActive() {
return this.sleepTimer !== null;
}
updateSleepTimerUI() {
const timerBtn = document.getElementById('sleep-timer-btn');
if (!timerBtn) return;
if (this.isSleepTimerActive()) {
const remaining = this.getSleepTimerRemaining();
if (remaining > 0) {
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
timerBtn.innerHTML = `<span style="font-size: 12px; font-weight: bold;">${minutes}:${seconds.toString().padStart(2, '0')}</span>`;
timerBtn.title = `Sleep Timer: ${minutes}:${seconds.toString().padStart(2, '0')} remaining`;
timerBtn.classList.add('active');
} else {
timerBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12,6 12,12 16,14"/>
</svg>
`;
timerBtn.title = 'Sleep Timer';
timerBtn.classList.remove('active');
}
} else {
timerBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12,6 12,12 16,14"/>
</svg>
`;
timerBtn.title = 'Sleep Timer';
timerBtn.classList.remove('active');
}
}
}

View file

@ -1570,6 +1570,37 @@ input:checked + .slider::before {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Sleep Timer Button */
#sleep-timer-btn {
position: relative;
font-size: 0.8rem;
font-weight: bold;
color: var(--foreground);
transition: all var(--transition);
}
#sleep-timer-btn:hover {
color: var(--highlight);
}
#sleep-timer-btn.active {
color: var(--primary);
text-shadow: 0 0 8px rgba(var(--highlight-rgb), 0.5);
}
#sleep-timer-btn svg {
width: 20px;
height: 20px;
}
#sleep-timer-btn span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
#context-menu {
display: none;
position: fixed;
@ -3802,3 +3833,20 @@ img:not([src]), img[src=''] {
}
/* Default responsive classes */
.mobile-only {
display: none !important;
}
/* Mobile-specific overrides */
@media (max-width: 768px) {
.mobile-only {
display: flex !important;
}
.desktop-only {
display: none !important;
}
}