@@ -6,7 +6,7 @@ const statusDot = document.getElementById('status-dot');
66const statusText = document . getElementById ( 'status' ) ;
77const streamStatus = document . getElementById ( 'stream-status' ) ;
88const streamStatusText = document . getElementById ( 'stream-status-text' ) ;
9- const MAX_HISTORY_ITEMS = 10 ;
9+ const MAX_HISTORY_ITEMS = 5 ;
1010const transcriptionEnabled = appConfig . transcriptionEnabled ;
1111let currentVideoId = null ;
1212let currentQueueId = null ;
@@ -19,6 +19,25 @@ let serverAudioDuration = null; // Authoritative duration from server (ffprobe)
1919let currentSpeed = 1.0 ; // Persists playback rate across track switches
2020const 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
2342const prefetchThresholdSeconds = appConfig . prefetchThresholdSeconds ;
2443let prefetchTriggered = false ;
@@ -167,6 +186,26 @@ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e
167186// Initialize theme on page load
168187initTheme ( ) ;
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
171210async 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;
12061260let touchCurrentY = 0 ;
12071261let 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+
12091269function 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
14491587player . addEventListener ( 'ended' , async function ( ) {
14501588 console . log ( 'Track ended, playing next...' ) ;
@@ -1466,7 +1604,7 @@ const weeklySummaryEnabled = appConfig.weeklySummaryEnabled;
14661604
14671605async 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
15751713async 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+
18441996async 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() {
18622014function 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