Skip to content

Commit 180fbcc

Browse files
committed
Change animation and drag and drop
1 parent 8589e1f commit 180fbcc

File tree

3 files changed

+285
-82
lines changed

3 files changed

+285
-82
lines changed

static/app.js

Lines changed: 187 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const statusDot = document.getElementById('status-dot');
66
const statusText = document.getElementById('status');
77
const streamStatus = document.getElementById('stream-status');
88
const streamStatusText = document.getElementById('stream-status-text');
9-
const MAX_HISTORY_ITEMS = 10;
9+
const MAX_HISTORY_ITEMS = 5;
1010
const transcriptionEnabled = appConfig.transcriptionEnabled;
1111
let currentVideoId = null;
1212
let currentQueueId = null;
@@ -19,6 +19,25 @@ let serverAudioDuration = null; // Authoritative duration from server (ffprobe)
1919
let currentSpeed = 1.0; // Persists playback rate across track switches
2020
const defaultTitle = 'YouTube Radio';
2121

22+
// Soft click/tap feedback via Web Audio API (no external dependency)
23+
let _audioCtx = null;
24+
function playClickSound() {
25+
try {
26+
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
27+
const ctx = _audioCtx;
28+
const osc = ctx.createOscillator();
29+
const gain = ctx.createGain();
30+
osc.connect(gain);
31+
gain.connect(ctx.destination);
32+
osc.frequency.value = 880;
33+
osc.type = 'sine';
34+
gain.gain.setValueAtTime(0.07, ctx.currentTime);
35+
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
36+
osc.start(ctx.currentTime);
37+
osc.stop(ctx.currentTime + 0.08);
38+
} catch (e) { /* audio not available, ignore */ }
39+
}
40+
2241
// Prefetch configuration
2342
const prefetchThresholdSeconds = appConfig.prefetchThresholdSeconds;
2443
let prefetchTriggered = false;
@@ -167,6 +186,26 @@ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e
167186
// Initialize theme on page load
168187
initTheme();
169188

189+
// Speed persistence — restore saved playback speed from localStorage
190+
function initSpeed() {
191+
const saved = parseFloat(localStorage.getItem('playbackSpeed') || '1.0');
192+
if (!isNaN(saved) && saved > 0) {
193+
currentSpeed = saved;
194+
}
195+
// Highlight the matching button; fall back to 1x if the saved value no longer exists
196+
document.querySelectorAll('.btn-speed').forEach(btn => btn.classList.remove('active'));
197+
const activeBtn = document.getElementById(`speed-${currentSpeed}x`);
198+
if (activeBtn) {
199+
activeBtn.classList.add('active');
200+
} else {
201+
currentSpeed = 1.0;
202+
localStorage.setItem('playbackSpeed', '1.0');
203+
const btn1x = document.getElementById('speed-1x');
204+
if (btn1x) btn1x.classList.add('active');
205+
}
206+
}
207+
initSpeed();
208+
170209
// File polling functions
171210
async function waitForAudioFile(videoId, maxWaitSeconds = 60) {
172211
/**
@@ -397,6 +436,14 @@ player.addEventListener('playing', function () {
397436
hideStreamStatus();
398437
retryCount = 0; // Reset retry count when successfully playing
399438

439+
// Mark body as playing (used for queue icon pulse animation)
440+
document.body.classList.add('audio-playing');
441+
442+
// Reapply playback speed — some browsers silently reset it on load/play
443+
if (player.playbackRate !== currentSpeed) {
444+
player.playbackRate = currentSpeed;
445+
}
446+
400447
// Update MediaSession playback state
401448
if ('mediaSession' in navigator && navigator.mediaSession.playbackState !== undefined) {
402449
navigator.mediaSession.playbackState = 'playing';
@@ -419,6 +466,9 @@ player.addEventListener('pause', function () {
419466
hideStreamStatus();
420467
}
421468

469+
// Remove playing marker (stops queue icon pulse animation)
470+
document.body.classList.remove('audio-playing');
471+
422472
// Update MediaSession playback state
423473
if ('mediaSession' in navigator && navigator.mediaSession.playbackState !== undefined) {
424474
navigator.mediaSession.playbackState = 'paused';
@@ -1134,11 +1184,15 @@ async function renderQueue() {
11341184
if (itemType === 'summary') {
11351185
icon = '<i class="fas fa-calendar-week"></i>';
11361186
badge = '<span class="queue-badge summary-badge">Summary</span>';
1137-
onClick = isCurrentlyPlaying ? '' : `startSummaryFromQueue('${item.week_year}', ${item.id})`;
1187+
onClick = isCurrentlyPlaying
1188+
? 'playClickSound(); toggleCurrentTrack()'
1189+
: `playClickSound(); startSummaryFromQueue('${item.week_year}', ${item.id})`;
11381190
} else {
11391191
icon = '<i class="fab fa-youtube"></i>';
11401192
badge = '';
1141-
onClick = isCurrentlyPlaying ? '' : `startStreamFromQueue('${item.youtube_id}', ${item.id})`;
1193+
onClick = isCurrentlyPlaying
1194+
? 'playClickSound(); toggleCurrentTrack()'
1195+
: `playClickSound(); startStreamFromQueue('${item.youtube_id}', ${item.id})`;
11421196
}
11431197

11441198
let itemClasses = 'queue-item';
@@ -1175,19 +1229,19 @@ async function renderQueue() {
11751229
${icon}
11761230
<span class="queue-title">${escapeHtml(item.title)}</span>
11771231
${badge}
1178-
${isCurrentlyPlaying ? '<i class="fas fa-volume-up queue-playing-icon"></i>' : ''}
11791232
</div>
11801233
<div class="queue-row-bottom">
11811234
<div class="queue-drag-handle" title="Drag to reorder" onclick="event.stopPropagation();">
11821235
<i class="fas fa-grip-vertical"></i>
11831236
</div>
1237+
${isCurrentlyPlaying ? '<span class="playing-bars"><span></span><span></span><span></span></span>' : ''}
11841238
${resumeLabel}
1185-
<button onclick="event.stopPropagation(); removeFromQueue(${item.id})"
1186-
class="btn-remove-queue"
1187-
title="Remove from queue">
1188-
<i class="fas fa-times"></i>
1189-
</button>
11901239
</div>
1240+
<button onclick="event.stopPropagation(); playClickSound(); removeFromQueue(${item.id})"
1241+
class="btn-remove-queue"
1242+
title="Remove from queue">
1243+
<i class="fas fa-times"></i>
1244+
</button>
11911245
</div>
11921246
`;
11931247
}).join('');
@@ -1206,6 +1260,12 @@ let touchStartY = 0;
12061260
let touchCurrentY = 0;
12071261
let isTouchDragging = false;
12081262

1263+
// Swipe-to-remove state (horizontal swipe on queue items)
1264+
let swipeItem = null;
1265+
let swipeStartX = 0;
1266+
let swipeStartY = 0;
1267+
let swipeAxis = null; // 'h' = horizontal (remove), 'v' = vertical (scroll)
1268+
12091269
function initializeQueueDragAndDrop() {
12101270
const queueItems = document.querySelectorAll('.queue-item');
12111271

@@ -1217,11 +1277,17 @@ function initializeQueueDragAndDrop() {
12171277
item.addEventListener('drop', handleDrop);
12181278
item.addEventListener('dragleave', handleDragLeave);
12191279

1220-
// Touch events for mobile
1280+
// Touch events for mobile drag-to-reorder (only activates via .queue-drag-handle)
12211281
item.addEventListener('touchstart', handleTouchStart, { passive: false });
12221282
item.addEventListener('touchmove', handleTouchMove, { passive: false });
12231283
item.addEventListener('touchend', handleTouchEnd);
12241284
item.addEventListener('touchcancel', handleTouchEnd);
1285+
1286+
// Touch events for swipe-to-remove (activates on item body, not drag handle)
1287+
item.addEventListener('touchstart', handleSwipeTouchStart, { passive: true });
1288+
item.addEventListener('touchmove', handleSwipeTouchMove, { passive: false });
1289+
item.addEventListener('touchend', handleSwipeTouchEnd);
1290+
item.addEventListener('touchcancel', handleSwipeTouchCancel);
12251291
});
12261292
}
12271293

@@ -1445,6 +1511,78 @@ async function handleTouchEnd(e) {
14451511
touchCurrentY = 0;
14461512
}
14471513

1514+
// ── Swipe-to-remove handlers ─────────────────────────────────────────────────
1515+
function handleSwipeTouchStart(e) {
1516+
// Skip if touching the drag handle or remove button (those have their own handlers)
1517+
if (e.target.closest('.queue-drag-handle') || e.target.closest('.btn-remove-queue')) return;
1518+
// Skip if a drag-to-reorder is already in progress
1519+
if (isTouchDragging) return;
1520+
swipeItem = this;
1521+
swipeStartX = e.touches[0].clientX;
1522+
swipeStartY = e.touches[0].clientY;
1523+
swipeAxis = null;
1524+
}
1525+
1526+
function handleSwipeTouchMove(e) {
1527+
if (!swipeItem || swipeItem !== this) return;
1528+
// If drag-to-reorder started after our swipestart, abort swipe
1529+
if (isTouchDragging) { swipeItem = null; swipeAxis = null; return; }
1530+
1531+
const dx = e.touches[0].clientX - swipeStartX;
1532+
const dy = e.touches[0].clientY - swipeStartY;
1533+
1534+
if (!swipeAxis) {
1535+
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return; // too small to decide
1536+
swipeAxis = Math.abs(dx) > Math.abs(dy) ? 'h' : 'v';
1537+
}
1538+
1539+
if (swipeAxis === 'h') {
1540+
e.preventDefault(); // prevent page scroll during horizontal swipe
1541+
this.style.transform = `translateX(${dx}px)`;
1542+
this.style.opacity = String(Math.max(0.3, 1 - Math.abs(dx) / 180));
1543+
}
1544+
}
1545+
1546+
async function handleSwipeTouchEnd(e) {
1547+
if (!swipeItem || swipeItem !== this || swipeAxis !== 'h') {
1548+
swipeItem = null; swipeAxis = null;
1549+
return;
1550+
}
1551+
e.preventDefault(); // prevent the click event from firing after a swipe
1552+
const dx = e.changedTouches[0].clientX - swipeStartX;
1553+
const el = this;
1554+
swipeItem = null;
1555+
swipeAxis = null;
1556+
1557+
if (Math.abs(dx) >= 90) {
1558+
// Confirmed swipe — fly out and remove
1559+
playClickSound();
1560+
const queueId = parseInt(el.dataset.queueId);
1561+
el.style.transition = 'transform 0.25s ease, opacity 0.25s ease';
1562+
el.style.transform = `translateX(${dx > 0 ? 110 : -110}%)`;
1563+
el.style.opacity = '0';
1564+
setTimeout(() => removeFromQueue(queueId), 250);
1565+
} else {
1566+
// Not far enough — snap back
1567+
el.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
1568+
el.style.transform = '';
1569+
el.style.opacity = '';
1570+
setTimeout(() => { el.style.transition = ''; }, 300);
1571+
}
1572+
}
1573+
1574+
function handleSwipeTouchCancel() {
1575+
if (swipeItem && swipeItem === this) {
1576+
this.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
1577+
this.style.transform = '';
1578+
this.style.opacity = '';
1579+
setTimeout(() => { this.style.transition = ''; }, 300);
1580+
swipeItem = null;
1581+
swipeAxis = null;
1582+
}
1583+
}
1584+
// ── End swipe-to-remove ───────────────────────────────────────────────────────
1585+
14481586
// Auto-play next track when current track ends
14491587
player.addEventListener('ended', async function () {
14501588
console.log('Track ended, playing next...');
@@ -1466,7 +1604,7 @@ const weeklySummaryEnabled = appConfig.weeklySummaryEnabled;
14661604

14671605
async function fetchWeeklySummaries() {
14681606
try {
1469-
const res = await fetch('/weekly-summaries?limit=10');
1607+
const res = await fetch('/weekly-summaries?limit=5');
14701608
const data = await res.json();
14711609
return data || [];
14721610
} catch (e) {
@@ -1574,7 +1712,7 @@ async function playSummary(weekYear) {
15741712
// History Management
15751713
async function fetchHistory() {
15761714
try {
1577-
const res = await fetch('/history');
1715+
const res = await fetch(`/history?limit=${MAX_HISTORY_ITEMS}`);
15781716
const data = await res.json();
15791717
return data.history || [];
15801718
} catch (e) {
@@ -1841,6 +1979,20 @@ function pauseAudio() {
18411979
player.pause();
18421980
}
18431981

1982+
// Toggle play/pause on the currently-selected queue item.
1983+
// Also handles the "came back after stopping" case: if no src is loaded, restart the queue.
1984+
function toggleCurrentTrack() {
1985+
if (!player.src || player.src === window.location.href) {
1986+
playQueue();
1987+
return;
1988+
}
1989+
if (player.paused) {
1990+
player.play().catch(e => console.error('Failed to resume track:', e));
1991+
} else {
1992+
player.pause();
1993+
}
1994+
}
1995+
18441996
async function playAudio() {
18451997
// If nothing is loaded/playing, start the queue
18461998
if (!player.src || player.src === '' || (!isPlaying && player.paused && player.currentTime === 0)) {
@@ -1862,17 +2014,12 @@ function fastforward() {
18622014
function setSpeed(speed) {
18632015
currentSpeed = speed;
18642016
player.playbackRate = speed;
2017+
localStorage.setItem('playbackSpeed', String(speed));
18652018

18662019
// Update active button styling
1867-
document.querySelectorAll('.btn-speed').forEach(btn => {
1868-
btn.classList.remove('active');
1869-
});
1870-
1871-
const speedId = `speed-${speed}x`;
1872-
const activeBtn = document.getElementById(speedId);
1873-
if (activeBtn) {
1874-
activeBtn.classList.add('active');
1875-
}
2020+
document.querySelectorAll('.btn-speed').forEach(btn => btn.classList.remove('active'));
2021+
const activeBtn = document.getElementById(`speed-${speed}x`);
2022+
if (activeBtn) activeBtn.classList.add('active');
18762023

18772024
console.log(`Playback speed set to ${speed}x`);
18782025
}
@@ -2088,10 +2235,13 @@ document.addEventListener('visibilitychange', async function () {
20882235

20892236
// ── Player Bar Drag-to-Snap ──────────────────────────────────────────────────
20902237
// 4 snap levels (drag down = collapse from bottom):
2091-
// 0 = full | 1 = hide speed | 2 = hide speed+controls | 3 = hide all (audio only)
2238+
// 0 = full | 1 = hide speed | 2 = hide speed+controls | 3 = hide audio too
2239+
// Status indicator is always visible (it sits above the audio element).
2240+
// Both the pill handle and the status bar are draggable surfaces.
20922241
(function initPlayerDrag() {
20932242
const bar = document.getElementById('player-section');
20942243
const handle = document.getElementById('player-drag-handle');
2244+
const statusHandle = document.getElementById('player-status-handle');
20952245
const container = document.querySelector('.container');
20962246
if (!bar || !handle) return;
20972247

@@ -2104,10 +2254,10 @@ document.addEventListener('visibilitychange', async function () {
21042254
function getSnapOffsets() {
21052255
const speed = bar.querySelector('.speed-controls');
21062256
const controls = bar.querySelector('.playback-controls');
2107-
const status = bar.querySelector('.status-indicator');
2257+
const audio = bar.querySelector('audio');
21082258
const s1 = speed.offsetHeight + GAP;
21092259
const s2 = s1 + controls.offsetHeight + GAP;
2110-
const s3 = s2 + status.offsetHeight + GAP;
2260+
const s3 = s2 + audio.offsetHeight + GAP;
21112261
return [0, s1, s2, s3];
21122262
}
21132263

@@ -2160,11 +2310,19 @@ document.addEventListener('visibilitychange', async function () {
21602310
applySnap(getNearestSnap(getCurrentTranslateY()), true);
21612311
}
21622312

2163-
// Touch events
2164-
handle.addEventListener('touchstart', (e) => {
2165-
onDragStart(e.touches[0].clientY);
2166-
e.preventDefault();
2167-
}, { passive: false });
2313+
// Helper: attach drag-start to any element
2314+
function attachDragStart(el) {
2315+
if (!el) return;
2316+
el.addEventListener('touchstart', (e) => {
2317+
onDragStart(e.touches[0].clientY);
2318+
e.preventDefault();
2319+
}, { passive: false });
2320+
el.addEventListener('mousedown', (e) => { onDragStart(e.clientY); e.preventDefault(); });
2321+
}
2322+
2323+
// Both the pill handle and the status bar trigger drag
2324+
attachDragStart(handle);
2325+
attachDragStart(statusHandle);
21682326

21692327
window.addEventListener('touchmove', (e) => {
21702328
if (isDragging) { onDragMove(e.touches[0].clientY); e.preventDefault(); }
@@ -2173,7 +2331,6 @@ document.addEventListener('visibilitychange', async function () {
21732331
window.addEventListener('touchend', onDragEnd);
21742332

21752333
// Mouse events
2176-
handle.addEventListener('mousedown', (e) => { onDragStart(e.clientY); e.preventDefault(); });
21772334
window.addEventListener('mousemove', (e) => { if (isDragging) onDragMove(e.clientY); });
21782335
window.addEventListener('mouseup', onDragEnd);
21792336

0 commit comments

Comments
 (0)