Browse Source

Add Arozcast auto-reconnect logic

Implement automatic reconnection for Arozcast across Movie, Musicify and Photo UIs. Adds reconnect state (timer, count, pending room code), backoff delays, and helper flows: _startCastReconnect/_attemptCastReconnect/_castDidReconnect (and equivalents for music and photo). On socket close, code now schedules reconnect attempts (instead of immediately resuming locally) and suppresses reconnect when explicitly disconnecting. Reconnect attempts use an 8s open timeout and increasing delays [2s,4s,8s,16s,30s]; on successful reconnect the remote state (media load, position, play/pause, volume or current photo) is restored and heartbeats/watch timers resumed. Also adds visibilitychange handlers to try reconnecting immediately when the tab becomes visible, and ensures pending reconnect timers are cleared on disconnect/close.
Toby Chui 1 tuần trước cách đây
mục cha
commit
7c28eddb2b
3 tập tin đã thay đổi với 295 bổ sung21 xóa
  1. 91 1
      src/web/Movie/index.html
  2. 127 19
      src/web/Musicify/musicify.js
  3. 77 1
      src/web/Photo/photo.js

+ 91 - 1
src/web/Movie/index.html

@@ -1320,6 +1320,9 @@ var castLastSeen    = 0;
 var castCurrentTime = 0;
 var castDuration    = 0;
 var castIsPlaying   = false;
+var castReconnectTimer  = null;   // setTimeout handle for next reconnect attempt
+var castReconnectCount  = 0;      // number of attempts made so far
+var castPendingCode     = null;   // room code kept alive during reconnect
 
 function _castConnected() {
     return castWs !== null && castWs.readyState === WebSocket.OPEN;
@@ -1418,9 +1421,10 @@ function connectCast() {
                 clearInterval(castPingTimer); clearInterval(castWatchTimer);
                 castPingTimer = castWatchTimer = null;
                 var wasActive = castMode;
+                var savedCode = castCode;
                 castMode = false; castCode = null; castWs = null;
                 $('#ctrl-cast, #ctrl-cast-top').removeClass('casting');
-                if (wasActive) { _castResumeLocally(); }
+                if (wasActive) { _startCastReconnect(savedCode); }
             };
 
             castWs.onerror = function() {
@@ -1432,6 +1436,9 @@ function connectCast() {
 }
 
 function disconnectCast() {
+    // Cancel any pending auto-reconnect before tearing down
+    clearTimeout(castReconnectTimer); castReconnectTimer = null;
+    castReconnectCount = 0; castPendingCode = null;
     if (_castConnected()) { _castSend('media.stop', {}); }
     if (castWs) { castWs.onclose = null; castWs.close(); castWs = null; }
     clearInterval(castPingTimer); clearInterval(castWatchTimer);
@@ -1460,6 +1467,77 @@ function _castResumeLocally() {
     showToast('Arozcast disconnected — click play to resume');
 }
 
+// ── Arozcast auto-reconnect ───────────────────────────────────────────────────
+var _CAST_RECONNECT_DELAYS = [2000, 4000, 8000, 16000, 30000];
+
+function _startCastReconnect(code) {
+    if (!code || castReconnectCount >= _CAST_RECONNECT_DELAYS.length) {
+        if (castReconnectCount > 0) { _castResumeLocally(); }
+        castReconnectCount = 0; castPendingCode = null;
+        return;
+    }
+    castPendingCode = code;
+    var delay = _CAST_RECONNECT_DELAYS[castReconnectCount++];
+    clearTimeout(castReconnectTimer);
+    castReconnectTimer = setTimeout(function() {
+        castReconnectTimer = null;
+        _attemptCastReconnect();
+    }, delay);
+    showToast('Arozcast disconnected — reconnecting…');
+}
+
+function _attemptCastReconnect() {
+    if (!castPendingCode) return;
+    var code = castPendingCode;
+    var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
+    wsUrl.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+    var ws = new WebSocket(wsUrl.toString());
+    // If the socket doesn't open within 8 s, count as a failed attempt
+    var openTimer = setTimeout(function() {
+        ws.onopen = ws.onclose = ws.onerror = null; ws.close();
+        _startCastReconnect(code);
+    }, 8000);
+    ws.onopen  = function() { clearTimeout(openTimer); castReconnectCount = 0; castPendingCode = null; _castDidReconnect(ws, code); };
+    ws.onerror = function() {};   // let onclose handle it
+    ws.onclose = function() { clearTimeout(openTimer); _startCastReconnect(code); };
+}
+
+function _castDidReconnect(ws, code) {
+    castWs = ws; castCode = code; castMode = true; castLastSeen = Date.now();
+    ws.onmessage = function(evt) {
+        castLastSeen = Date.now();
+        try { _handleCastMessage(JSON.parse(evt.data)); } catch(e) {}
+    };
+    ws.onclose = function() {
+        clearInterval(castPingTimer); clearInterval(castWatchTimer);
+        castPingTimer = castWatchTimer = null;
+        var wasActive = castMode;
+        var savedCode = castCode;
+        castMode = false; castCode = null; castWs = null;
+        $('#ctrl-cast, #ctrl-cast-top').removeClass('casting');
+        if (wasActive) { _startCastReconnect(savedCode); }
+    };
+    // Re-announce and restore full media state at the last known remote position
+    _castSend('peer.hello', {});
+    var vid = document.getElementById('main-video');
+    _castSend('media.volume', { volume: vid.volume * 100, muted: vid.muted });
+    if (playingIndex >= 0 && currentEpisodes && currentEpisodes[playingIndex]) {
+        var ep  = currentEpisodes[playingIndex];
+        var ext = ep.ext ? ep.ext.toLowerCase().replace(/^\./, '') : '';
+        var src = isWebPlayable(ext)
+            ? MEDIA_API + '?file=' + encodeURIComponent(ep.filepath)
+            : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
+        _castSend('media.load', { filepath: ep.filepath, name: ep.name, type: 'video', src: src, startTime: castCurrentTime });
+        _castSend(castIsPlaying ? 'media.play' : 'media.pause', {});
+    }
+    castPingTimer  = setInterval(function() { _castSend('peer.heartbeat', {}); }, 5000);
+    castWatchTimer = setInterval(function() {
+        if (Date.now() - castLastSeen > 12000 && castWs) { castWs.close(); }
+    }, 4000);
+    $('#ctrl-cast, #ctrl-cast-top').addClass('casting');
+    showToast('Arozcast reconnected — resuming from ' + formatTime(castCurrentTime));
+}
+
 function _handleCastMessage(msg) {
     if (msg.topic === 'status.update') {
         castCurrentTime = msg.payload.currentTime || 0;
@@ -1925,6 +2003,15 @@ $(document).ready(function () {
         };
     } catch(e) {}
 
+    // When the user returns to this tab after the phone was asleep, reconnect immediately
+    document.addEventListener('visibilitychange', function() {
+        if (document.visibilityState === 'visible' && castPendingCode) {
+            clearTimeout(castReconnectTimer);
+            castReconnectTimer = null;
+            _attemptCastReconnect();
+        }
+    });
+
     // Clean up cast on page close
     window.addEventListener('beforeunload', function() {
         if (_castConnected()) {
@@ -2428,6 +2515,9 @@ function highlightPlayingEpisode(idx) {
 }
 
 function closePlayer() {
+    // Always cancel any pending auto-reconnect
+    clearTimeout(castReconnectTimer); castReconnectTimer = null;
+    castReconnectCount = 0; castPendingCode = null;
     if (castMode && _castConnected()) {
         _castSend('media.stop', {});
         castWs.onclose = null;

+ 127 - 19
src/web/Musicify/musicify.js

@@ -110,6 +110,9 @@ function musicifyApp() {
         _castPingTimer: null,
         _castWatchTimer: null,
         _castLastSeen: 0,
+        _castReconnectTimer: null,
+        _castReconnectCount: 0,
+        _castPendingCode: null,
 
         // ── Internal refs ────────────────────────────────────────────────────
         _audio: null,
@@ -191,6 +194,15 @@ function musicifyApp() {
                 };
             } catch(e) {}
 
+            // When the user returns to this tab after the phone was asleep, reconnect immediately
+            document.addEventListener('visibilitychange', function() {
+                if (document.visibilityState === 'visible' && self._castPendingCode) {
+                    clearTimeout(self._castReconnectTimer);
+                    self._castReconnectTimer = null;
+                    self._attemptCastReconnect();
+                }
+            });
+
             // Responsive sidebar
             this.sidebarOpen = window.innerWidth > 768;
             var resizeT;
@@ -1339,28 +1351,12 @@ function musicifyApp() {
                 clearInterval(self._castWatchTimer);
                 self._castPingTimer = null;
                 self._castWatchTimer = null;
-
-                if (self.castMode) {
-                    // Reload local audio, seek to last known remote position, leave paused
-                    if (self.currentTrack) {
-                        var resumeAt = self.currentTime;
-                        self._audio.src = ao_root + 'media?file=' + encodeURIComponent(self.currentTrack.filepath);
-                        self._audio.volume = self.volume / 100;
-                        self._audio.muted = self.isMuted;
-                        self._audio.load();
-                        if (resumeAt > 0) {
-                            self._audio.addEventListener('loadedmetadata', function() {
-                                self._audio.currentTime = resumeAt;
-                            }, { once: true });
-                        }
-                    }
-                    self.isPlaying = false;
-                    self._showToast('Arozcast disconnected — click play to resume locally', 'error');
-                }
-
+                var wasActive = self.castMode;
+                var savedCode = self.castCode;
                 self.castConnected = false;
                 self.castMode = false;
                 self._castWs = null;
+                if (wasActive) { self._startCastReconnect(savedCode); }
             };
 
             ws.onerror = function() {
@@ -1381,7 +1377,111 @@ function musicifyApp() {
             };
         },
 
+        // ── Auto-reconnect helpers ────────────────────────────────────────────
+        _startCastReconnect(code) {
+            var self = this;
+            var DELAYS = [2000, 4000, 8000, 16000, 30000];
+            if (!code || this._castReconnectCount >= DELAYS.length) {
+                if (this._castReconnectCount > 0) {
+                    // All retries exhausted — fall back to local playback
+                    if (this.currentTrack) {
+                        var resumeAt = this.currentTime;
+                        this._audio.src = ao_root + 'media?file=' + encodeURIComponent(this.currentTrack.filepath);
+                        this._audio.volume = this.volume / 100;
+                        this._audio.muted = this.isMuted;
+                        this._audio.load();
+                        if (resumeAt > 0) {
+                            this._audio.addEventListener('loadedmetadata', function() {
+                                self._audio.currentTime = resumeAt;
+                            }, { once: true });
+                        }
+                        this.isPlaying = false;
+                        this._showToast('Arozcast: reconnect failed — resuming locally', 'error');
+                    }
+                }
+                this._castReconnectCount = 0; this._castPendingCode = null;
+                return;
+            }
+            this._castPendingCode = code;
+            var delay = DELAYS[this._castReconnectCount++];
+            clearTimeout(this._castReconnectTimer);
+            this._castReconnectTimer = setTimeout(function() {
+                self._castReconnectTimer = null;
+                self._attemptCastReconnect();
+            }, delay);
+            this._showToast('Arozcast disconnected — reconnecting…');
+        },
+
+        _attemptCastReconnect() {
+            var self = this;
+            if (!this._castPendingCode) return;
+            var code = this._castPendingCode;
+            var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
+            wsUrl.protocol = (location.protocol === 'https:') ? 'wss:' : 'ws:';
+            var ws = new WebSocket(wsUrl.toString());
+            var openTimer = setTimeout(function() {
+                ws.onopen = ws.onclose = ws.onerror = null; ws.close();
+                self._startCastReconnect(code);
+            }, 8000);
+            ws.onopen  = function() { clearTimeout(openTimer); self._castReconnectCount = 0; self._castPendingCode = null; self._castDidReconnect(ws, code); };
+            ws.onerror = function() {};
+            ws.onclose = function() { clearTimeout(openTimer); self._startCastReconnect(code); };
+        },
+
+        _castDidReconnect(ws, code) {
+            var self = this;
+            this._castWs = ws;
+            this.castCode = code;
+            this.castMode = true;
+            this.castConnected = true;
+            this._castLastSeen = Date.now();
+            ws.onmessage = function(evt) {
+                self._castLastSeen = Date.now();
+                try {
+                    var msg = JSON.parse(evt.data);
+                    if (msg.topic === 'status.update') {
+                        if (!self.isSeeking) self.currentTime = msg.payload.currentTime || 0;
+                        self.duration = msg.payload.duration || 0;
+                        self.isPlaying = msg.payload.isPlaying || false;
+                    } else if (msg.topic === 'media.ended') {
+                        self._onEnded();
+                    }
+                } catch(e) {}
+            };
+            ws.onclose = function() {
+                clearInterval(self._castPingTimer); clearInterval(self._castWatchTimer);
+                self._castPingTimer = null; self._castWatchTimer = null;
+                var wasActive = self.castMode;
+                var savedCode = self.castCode;
+                self.castConnected = false; self.castMode = false; self._castWs = null;
+                if (wasActive) { self._startCastReconnect(savedCode); }
+            };
+            // Re-announce and restore full media state at the last known remote position
+            ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
+            this._castSend('media.volume', { volume: this.volume, muted: this.isMuted });
+            if (this.currentTrack) {
+                this._castSend('media.load', {
+                    filepath: this.currentTrack.filepath,
+                    name: this.currentTrack.name,
+                    artist: this.getArtistLabel(this.currentTrack),
+                    cover: this.currentTrack.cover || '',
+                    type: 'audio',
+                    startTime: this.currentTime
+                });
+                this._castSend(this.isPlaying ? 'media.play' : 'media.pause', {});
+            }
+            clearInterval(this._castPingTimer); clearInterval(this._castWatchTimer);
+            this._castPingTimer = setInterval(function() { self._castSend('peer.heartbeat', {}); }, 5000);
+            this._castWatchTimer = setInterval(function() {
+                if (Date.now() - self._castLastSeen > 12000 && self._castWs) self._castWs.close();
+            }, 4000);
+            this._showToast('Arozcast reconnected — resuming');
+        },
+
         disconnectCast() {
+            // Cancel any pending auto-reconnect before tearing down
+            clearTimeout(this._castReconnectTimer); this._castReconnectTimer = null;
+            this._castReconnectCount = 0; this._castPendingCode = null;
             clearInterval(this._castPingTimer);
             clearInterval(this._castWatchTimer);
             this._castPingTimer = null;
@@ -1390,6 +1490,7 @@ function musicifyApp() {
             this.castConnected = false;
             this.showCastModal = false;
             if (this._castWs) {
+                this._castWs.onclose = null;   // suppress reconnect trigger
                 this._castWs.close();
                 this._castWs = null;
             }
@@ -1399,10 +1500,17 @@ function musicifyApp() {
 
             // Resume local playback from current track
             if (this.currentTrack) {
+                var resumeAt = this.currentTime;
+                var self = this;
                 this._audio.src = ao_root + 'media?file=' + encodeURIComponent(this.currentTrack.filepath);
                 this._audio.volume = this.volume / 100;
                 this._audio.muted = this.isMuted;
                 this._audio.load();
+                if (resumeAt > 0) {
+                    this._audio.addEventListener('loadedmetadata', function() {
+                        self._audio.currentTime = resumeAt;
+                    }, { once: true });
+                }
                 this._audio.play().catch(function() {});
             }
             this._showToast('Disconnected from Arozcast');

+ 77 - 1
src/web/Photo/photo.js

@@ -879,6 +879,9 @@ let _photoCastPingTimer = null;
 let _photoCastWatchTimer = null;
 let _photoCastLastSeen = 0;
 let _currentCastFilepath = null;
+let _photoCastReconnectTimer = null;
+let _photoCastReconnectCount = 0;
+let _photoCastPendingCode    = null;
 
 function _photoCastConnected() { return _photoCastWs !== null && _photoCastWs.readyState === WebSocket.OPEN; }
 
@@ -966,9 +969,11 @@ function connectPhotoCast() {
             ws.onclose = function() {
                 clearInterval(_photoCastPingTimer);
                 clearInterval(_photoCastWatchTimer);
+                var savedCode = _photoCastCode;
                 _photoCastWs = null;
                 _photoCastCode = null;
                 document.getElementById('cast-photo-btn').classList.remove('casting');
+                _startPhotoCastReconnect(savedCode);
             };
 
             ws.onerror = function() { err.textContent = 'Connection error.'; };
@@ -979,8 +984,11 @@ function connectPhotoCast() {
 }
 
 function disconnectPhotoCast() {
+    // Cancel any pending auto-reconnect before tearing down
+    clearTimeout(_photoCastReconnectTimer); _photoCastReconnectTimer = null;
+    _photoCastReconnectCount = 0; _photoCastPendingCode = null;
     if (_photoCastWs) {
-        _photoCastWs.onclose = null;
+        _photoCastWs.onclose = null;   // suppress reconnect trigger
         _photoCastWs.close();
         _photoCastWs = null;
     }
@@ -999,3 +1007,71 @@ function _photoCastSendPhoto(filepath) {
         payload: { filepath: filepath, name: filepath.split('/').pop(), type: 'photo', src: fileUrl }
     }));
 }
+
+// ── Arozcast photo auto-reconnect ─────────────────────────────────────────────
+var _PHOTO_CAST_RECONNECT_DELAYS = [2000, 4000, 8000, 16000, 30000];
+
+function _startPhotoCastReconnect(code) {
+    if (!code || _photoCastReconnectCount >= _PHOTO_CAST_RECONNECT_DELAYS.length) {
+        _photoCastReconnectCount = 0; _photoCastPendingCode = null;
+        return;
+    }
+    _photoCastPendingCode = code;
+    var delay = _PHOTO_CAST_RECONNECT_DELAYS[_photoCastReconnectCount++];
+    clearTimeout(_photoCastReconnectTimer);
+    _photoCastReconnectTimer = setTimeout(function() {
+        _photoCastReconnectTimer = null;
+        _attemptPhotoCastReconnect();
+    }, delay);
+}
+
+function _attemptPhotoCastReconnect() {
+    if (!_photoCastPendingCode) return;
+    var code = _photoCastPendingCode;
+    var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
+    wsUrl.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+    var ws = new WebSocket(wsUrl.toString());
+    var openTimer = setTimeout(function() {
+        ws.onopen = ws.onclose = ws.onerror = null; ws.close();
+        _startPhotoCastReconnect(code);
+    }, 8000);
+    ws.onopen  = function() { clearTimeout(openTimer); _photoCastReconnectCount = 0; _photoCastPendingCode = null; _photoCastDidReconnect(ws, code); };
+    ws.onerror = function() {};
+    ws.onclose = function() { clearTimeout(openTimer); _startPhotoCastReconnect(code); };
+}
+
+function _photoCastDidReconnect(ws, code) {
+    _photoCastWs = ws;
+    _photoCastCode = code;
+    _photoCastLastSeen = Date.now();
+    ws.onmessage = function() { _photoCastLastSeen = Date.now(); };
+    ws.onclose = function() {
+        clearInterval(_photoCastPingTimer); clearInterval(_photoCastWatchTimer);
+        var savedCode = _photoCastCode;
+        _photoCastWs = null; _photoCastCode = null;
+        document.getElementById('cast-photo-btn').classList.remove('casting');
+        _startPhotoCastReconnect(savedCode);
+    };
+    // Re-announce and re-push the current photo
+    ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
+    if (_currentCastFilepath) _photoCastSendPhoto(_currentCastFilepath);
+    clearInterval(_photoCastPingTimer); clearInterval(_photoCastWatchTimer);
+    _photoCastPingTimer = setInterval(function() {
+        if (_photoCastConnected()) ws.send(JSON.stringify({ topic: 'peer.heartbeat', payload: {} }));
+    }, 5000);
+    _photoCastWatchTimer = setInterval(function() {
+        if (Date.now() - _photoCastLastSeen > 12000) { ws.close(); }
+    }, 4000);
+    document.getElementById('cast-photo-btn').classList.add('casting');
+    var stat = document.getElementById('cast-photo-status');
+    if (stat) { stat.style.display = 'block'; stat.textContent = 'Reconnected to room ' + code; }
+}
+
+// When the user returns to this tab after the phone was asleep, reconnect immediately
+document.addEventListener('visibilitychange', function() {
+    if (document.visibilityState === 'visible' && _photoCastPendingCode) {
+        clearTimeout(_photoCastReconnectTimer);
+        _photoCastReconnectTimer = null;
+        _attemptPhotoCastReconnect();
+    }
+});