@@ -11,6 +11,9 @@ const transcriptionEnabled = appConfig.transcriptionEnabled;
1111let currentVideoId = null ;
1212let currentQueueId = null ;
1313let isPlaying = false ;
14+ let lastQueueHash = null ;
15+ let loadingQueueId = null ;
16+ let isDraggingQueue = false ;
1417let currentTrackTitle = null ;
1518let serverAudioDuration = null ; // Authoritative duration from server (ffprobe)
1619const defaultTitle = 'YouTube Radio' ;
@@ -389,6 +392,7 @@ player.addEventListener('playing', function () {
389392 }
390393
391394 console . log ( 'Playback started/resumed' ) ;
395+ clearQueueItemLoading ( ) ;
392396 hideStreamStatus ( ) ;
393397 retryCount = 0 ; // Reset retry count when successfully playing
394398
@@ -713,6 +717,25 @@ async function fetchSavedPosition(videoId) {
713717 }
714718}
715719
720+ async function refreshPositionFromServer ( ) {
721+ if ( ! currentVideoId ) return ;
722+ try {
723+ const res = await fetch ( `/playback-position/${ currentVideoId } ` ) ;
724+ if ( ! res . ok ) return ;
725+ const data = await res . json ( ) ;
726+ const serverPos = data . position_seconds || 0 ;
727+ const drift = Math . abs ( serverPos - player . currentTime ) ;
728+ if ( drift > 15 ) {
729+ remoteLog ( 'log' , 'Position sync: seeking to server position' , {
730+ local : Math . round ( player . currentTime ) , server : Math . round ( serverPos ) , drift : Math . round ( drift )
731+ } ) ;
732+ player . currentTime = serverPos ;
733+ }
734+ } catch ( e ) {
735+ console . warn ( 'Failed to refresh position from server:' , e ) ;
736+ }
737+ }
738+
716739// Queue Management
717740async function fetchQueue ( ) {
718741 try {
@@ -897,6 +920,7 @@ async function playNext() {
897920}
898921
899922async function startStreamFromQueue ( youtube_video_id , queue_id ) {
923+ setQueueItemLoading ( queue_id ) ;
900924 // Reset position save throttle so first save of new track isn't suppressed
901925 lastPositionSaveTime = 0 ;
902926
@@ -907,7 +931,7 @@ async function startStreamFromQueue(youtube_video_id, queue_id) {
907931 const res = await fetch ( '/stream' , {
908932 method : 'POST' ,
909933 headers : { 'Content-Type' : 'application/json' } ,
910- body : JSON . stringify ( { youtube_video_id, skip_transcription } )
934+ body : JSON . stringify ( { youtube_video_id, skip_transcription, queue_id } )
911935 } ) ;
912936 const data = await res . json ( ) ;
913937 updateStatus ( data . status || data . detail , res . ok ? 'streaming' : 'error' ) ;
@@ -973,16 +997,19 @@ async function startStreamFromQueue(youtube_video_id, queue_id) {
973997 }
974998 } ) ;
975999 } else {
1000+ clearQueueItemLoading ( ) ;
9761001 updateStatus ( 'Failed to start stream' , 'error' ) ;
9771002 }
9781003 } catch ( error ) {
1004+ clearQueueItemLoading ( ) ;
9791005 updateStatus ( 'Failed to start stream' , 'error' ) ;
9801006 console . error ( error ) ;
9811007 }
9821008}
9831009
9841010async function startSummaryFromQueue ( weekYear , queue_id ) {
9851011 try {
1012+ setQueueItemLoading ( queue_id ) ;
9861013 // For summaries, we directly play the audio file from the server
9871014 updateStatus ( 'Loading weekly summary...' , 'streaming' ) ;
9881015
@@ -1020,16 +1047,36 @@ async function startSummaryFromQueue(weekYear, queue_id) {
10201047 console . error ( 'Audio playback failed:' , e ) ;
10211048 updateStatus ( 'Failed to play summary' , 'error' ) ;
10221049 isPlaying = false ;
1050+ clearQueueItemLoading ( ) ;
10231051 } ) ;
10241052
10251053 updateStatus ( 'Playing: ' + ( currentItem ? currentItem . title : 'Weekly Summary' ) , 'streaming' ) ;
10261054 } catch ( error ) {
1055+ clearQueueItemLoading ( ) ;
10271056 updateStatus ( 'Failed to play summary' , 'error' ) ;
10281057 console . error ( error ) ;
10291058 isPlaying = false ;
10301059 }
10311060}
10321061
1062+ function setQueueItemLoading ( queueId ) {
1063+ loadingQueueId = queueId ;
1064+ _applyQueueLoadingClass ( ) ;
1065+ }
1066+
1067+ function clearQueueItemLoading ( ) {
1068+ loadingQueueId = null ;
1069+ _applyQueueLoadingClass ( ) ;
1070+ }
1071+
1072+ function _applyQueueLoadingClass ( ) {
1073+ document . querySelectorAll ( '.queue-item' ) . forEach ( el => el . classList . remove ( 'queue-item-loading' ) ) ;
1074+ if ( loadingQueueId != null ) {
1075+ const target = document . querySelector ( `.queue-item[data-queue-id="${ loadingQueueId } "]` ) ;
1076+ if ( target ) target . classList . add ( 'queue-item-loading' ) ;
1077+ }
1078+ }
1079+
10331080async function renderQueue ( ) {
10341081 const queueContainer = document . getElementById ( 'queue-list' ) ;
10351082 const queueCountEl = document . getElementById ( 'queue-count' ) ;
@@ -1086,6 +1133,8 @@ async function renderQueue() {
10861133
10871134 // Initialize drag-and-drop after rendering
10881135 initializeQueueDragAndDrop ( ) ;
1136+ // Restore loading shimmer if still active (survives DOM rebuild)
1137+ _applyQueueLoadingClass ( ) ;
10891138}
10901139
10911140// Queue drag-and-drop functionality
@@ -1116,13 +1165,15 @@ function initializeQueueDragAndDrop() {
11161165}
11171166
11181167function handleDragStart ( e ) {
1168+ isDraggingQueue = true ;
11191169 draggedElement = this ;
11201170 this . classList . add ( 'dragging' ) ;
11211171 e . dataTransfer . effectAllowed = 'move' ;
11221172 e . dataTransfer . setData ( 'text/html' , this . innerHTML ) ;
11231173}
11241174
11251175function handleDragEnd ( e ) {
1176+ isDraggingQueue = false ;
11261177 this . classList . remove ( 'dragging' ) ;
11271178 // Remove all drag-over classes
11281179 document . querySelectorAll ( '.queue-item' ) . forEach ( item => {
@@ -1211,6 +1262,7 @@ function handleTouchStart(e) {
12111262
12121263 draggedElement = this ;
12131264 isTouchDragging = true ;
1265+ isDraggingQueue = true ;
12141266
12151267 const touch = e . touches [ 0 ] ;
12161268 touchStartY = touch . clientY ;
@@ -1327,6 +1379,7 @@ async function handleTouchEnd(e) {
13271379 draggedElement = null ;
13281380 draggedOverElement = null ;
13291381 isTouchDragging = false ;
1382+ isDraggingQueue = false ;
13301383 touchStartY = 0 ;
13311384 touchCurrentY = 0 ;
13321385}
@@ -1769,6 +1822,25 @@ async function fetchStatus() {
17691822 const res = await fetch ( '/status' ) ;
17701823 const data = await res . json ( ) ;
17711824 updateStatus ( data . status , data . status ) ;
1825+
1826+ // Queue sync: detect changes from other devices
1827+ const serverHash = data . queue_hash ;
1828+ if ( serverHash !== undefined ) {
1829+ if ( lastQueueHash !== null && lastQueueHash !== serverHash && ! isDraggingQueue ) {
1830+ remoteLog ( 'log' , 'Queue hash changed, refreshing queue' , { old : lastQueueHash , new : serverHash } ) ;
1831+ await renderQueue ( ) ;
1832+ }
1833+ lastQueueHash = serverHash ;
1834+ }
1835+
1836+ // Sync "now playing" indicator when this device is passive (not playing).
1837+ // No status guard needed: backend correctly sets current_queue_id=null on /stop,
1838+ // so a non-null value always means something was started and not yet stopped.
1839+ const serverQueueId = data . current_queue_id ;
1840+ if ( serverQueueId !== undefined && serverQueueId !== currentQueueId && ! isPlaying ) {
1841+ currentQueueId = serverQueueId ;
1842+ await renderQueue ( ) ;
1843+ }
17721844 } catch ( error ) {
17731845 console . error ( 'Failed to fetch status:' , error ) ;
17741846 }
@@ -1929,6 +2001,17 @@ window.onclick = function (event) {
19292001 }
19302002}
19312003
2004+ // Resync state when tab becomes visible again (e.g. switching from PC to phone)
2005+ document . addEventListener ( 'visibilitychange' , async function ( ) {
2006+ if ( document . hidden ) return ;
2007+ remoteLog ( 'log' , 'Page became visible, resyncing state' ) ;
2008+ await fetchStatus ( ) ;
2009+ await renderQueue ( ) ;
2010+ if ( currentVideoId && player . paused ) {
2011+ await refreshPositionFromServer ( ) ;
2012+ }
2013+ } ) ;
2014+
19322015// Poll status every 3 seconds
19332016setInterval ( fetchStatus , 3000 ) ;
19342017
0 commit comments