Skip to content

Commit 584213c

Browse files
committed
Add PWA icons/manifest and visibility resync
Add PWA assets and manifest and improve foreground resynchronization for playback state. index.html now includes icon links, apple-touch-icon, manifest and theme color; new icon and favicon files and manifest.webmanifest were added. App.svelte registers visibility/pageshow handlers to call a new resyncFromBackground helper when the tab is foregrounded. stores.js introduces forceResync to drop the local playback anchor so the next server snapshot re-anchors playback. websocket.js adds lastMessageAt, a resync watchdog, reconnectNow and resyncFromBackground logic to detect "zombie" sockets after backgrounding and force immediate reconnects or re-anchoring; timers are cleaned up on disconnect. These changes address mobile browsers suspending timers/WS and causing playback position drift.
1 parent 59bb6d8 commit 584213c

9 files changed

Lines changed: 100 additions & 3 deletions

File tree

frontend/index.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8" />
5-
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
5+
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
6+
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
7+
<link rel="apple-touch-icon" href="/icon-192.png" />
8+
<link rel="manifest" href="/manifest.webmanifest" />
9+
<meta name="theme-color" content="#aa3bff" />
610
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
711
<title>Nagare</title>
812
</head>

frontend/public/favicon.ico

352 KB
Binary file not shown.

frontend/public/icon-192.png

32.6 KB
Loading

frontend/public/icon-512.png

201 KB
Loading
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "Nagare",
3+
"short_name": "Nagare",
4+
"description": "Subtitle mining tool for Emby / Jellyfin / Plex",
5+
"start_url": "/",
6+
"display": "standalone",
7+
"background_color": "#0f0f0f",
8+
"theme_color": "#aa3bff",
9+
"icons": [
10+
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
11+
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
12+
]
13+
}

frontend/src/App.svelte

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script>
22
import { onMount, onDestroy } from 'svelte';
3-
import { connectWebSocket, disconnect } from './lib/websocket.js';
3+
import { connectWebSocket, disconnect, resyncFromBackground } from './lib/websocket.js';
44
import { getConfig, getDialogByCardId, getDialogByNoteId, getHistorySubtitles, getPendingEnrichments } from './lib/api.js';
55
import {
66
activeHistoryItemId,
@@ -50,11 +50,22 @@
5050
}
5151
}
5252
53+
function handlePageVisible() {
54+
if (document.visibilityState === 'visible') {
55+
resyncFromBackground();
56+
}
57+
}
58+
5359
onMount(async () => {
5460
startYomitanObserver();
5561
connectWebSocket();
5662
syncRouteFromLocation();
5763
64+
// Re-sync with the server session when the tab is foregrounded again — mobile
65+
// browsers suspend timers/sockets in the background, leaving subs out of sync.
66+
document.addEventListener('visibilitychange', handlePageVisible);
67+
window.addEventListener('pageshow', handlePageVisible);
68+
5869
try {
5970
const [config, pending] = await Promise.all([
6071
getConfig(),
@@ -68,6 +79,8 @@
6879
});
6980
7081
onDestroy(() => {
82+
document.removeEventListener('visibilitychange', handlePageVisible);
83+
window.removeEventListener('pageshow', handlePageVisible);
7184
disconnect();
7285
stopYomitanObserver();
7386
});

frontend/src/lib/stores.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,17 @@ export function syncPositionFromSessionState(state) {
320320
syncActiveLineWithPosition(nextPosition);
321321
}
322322

323+
/**
324+
* Discard the local playback clock so the next authoritative server snapshot
325+
* re-anchors us unconditionally. Used when returning from the background, where
326+
* suspended timers / a frozen WebSocket can leave the projected position drifted
327+
* out of sync with the real server playback position.
328+
*/
329+
export function forceResync() {
330+
_playbackAnchor = null;
331+
_lastServerObservation = null;
332+
}
333+
323334
setInterval(() => {
324335
if (isSeekLocked()) return;
325336

frontend/src/lib/websocket.js

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { get } from 'svelte/store';
2-
import { activeHistoryItemId, sessionState, pendingCards, connected, ankiStatus, enhancementQueue, syncPositionFromSessionState, isSeekLocked, isPlayLocked, applySubtitlePayload, applyAudioTracksPayload, showErrorToast, showToast } from './stores.js';
2+
import { activeHistoryItemId, sessionState, pendingCards, connected, ankiStatus, enhancementQueue, syncPositionFromSessionState, isSeekLocked, isPlayLocked, applySubtitlePayload, applyAudioTracksPayload, showErrorToast, showToast, forceResync } from './stores.js';
33

44
let ws = null;
55
let reconnectTimer = null;
6+
let lastMessageAt = 0;
7+
let resyncWatchdog = null;
68

79
export function connectWebSocket() {
810
if (ws && ws.readyState === WebSocket.OPEN) return;
@@ -25,6 +27,8 @@ export function connectWebSocket() {
2527
connected.set(false);
2628
ankiStatus.set({ state: 'unknown', message: null });
2729
enhancementQueue.set([]);
30+
// Drop the stale playback clock so the fresh `init` snapshot re-anchors cleanly.
31+
forceResync();
2832
console.log('WebSocket disconnected, reconnecting in 2s...');
2933
reconnectTimer = setTimeout(connectWebSocket, 2000);
3034
};
@@ -34,6 +38,7 @@ export function connectWebSocket() {
3438
};
3539

3640
ws.onmessage = (event) => {
41+
lastMessageAt = Date.now();
3742
try {
3843
const msg = JSON.parse(event.data);
3944
handleMessage(msg);
@@ -43,6 +48,53 @@ export function connectWebSocket() {
4348
};
4449
}
4550

51+
/** Tear down the current socket and reconnect immediately, skipping the backoff timer. */
52+
function reconnectNow() {
53+
if (reconnectTimer) {
54+
clearTimeout(reconnectTimer);
55+
reconnectTimer = null;
56+
}
57+
if (ws) {
58+
// Detach handlers so the imminent close doesn't schedule a duplicate reconnect.
59+
ws.onclose = null;
60+
ws.onerror = null;
61+
try { ws.close(); } catch (_) { /* ignore */ }
62+
ws = null;
63+
}
64+
connectWebSocket();
65+
}
66+
67+
/**
68+
* Called when the page returns to the foreground (e.g. mobile tab resumed).
69+
* Mobile browsers freeze timers and can leave the WebSocket in a "zombie" state
70+
* that no longer delivers messages, so the local playback clock drifts out of
71+
* sync. Re-anchor on the next server snapshot and revive the socket if needed.
72+
*/
73+
export function resyncFromBackground() {
74+
// Re-anchor to whatever the server reports next, discarding drifted projection.
75+
forceResync();
76+
77+
// Dead or closing socket: reconnect right away instead of waiting out the backoff.
78+
if (!ws || ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) {
79+
reconnectNow();
80+
return;
81+
}
82+
83+
// Socket claims to be open. It may be a zombie after backgrounding — watch for a
84+
// fresh message and force a reconnect if none arrives shortly (the server pushes
85+
// state every 50ms while connected).
86+
if (ws.readyState === WebSocket.OPEN) {
87+
const seenAt = lastMessageAt;
88+
clearTimeout(resyncWatchdog);
89+
resyncWatchdog = setTimeout(() => {
90+
if (lastMessageAt === seenAt) {
91+
console.log('No WS traffic after resume, reconnecting');
92+
reconnectNow();
93+
}
94+
}, 1500);
95+
}
96+
}
97+
4698
function handleMessage(msg) {
4799
if (msg.anki_status) {
48100
ankiStatus.set(msg.anki_status);
@@ -126,4 +178,8 @@ export function disconnect() {
126178
clearTimeout(reconnectTimer);
127179
reconnectTimer = null;
128180
}
181+
if (resyncWatchdog) {
182+
clearTimeout(resyncWatchdog);
183+
resyncWatchdog = null;
184+
}
129185
}

icon.ico

352 KB
Binary file not shown.

0 commit comments

Comments
 (0)