add "add to playlist" button on player, add sleep timer feature
This commit is contained in:
parent
6635f5ed4d
commit
0234df5a7c
5 changed files with 240 additions and 101 deletions
27
index.html
27
index.html
|
|
@ -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>
|
||||
|
|
|
|||
75
js/app.js
75
js/app.js
|
|
@ -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() {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
107
js/events.js
107
js/events.js
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
84
js/player.js
84
js/player.js
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
styles.css
48
styles.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue