diff --git a/web/app/dev/stabilization/fixture.tsx b/web/app/dev/stabilization/fixture.tsx
index f03b46b07..6a4da6bb1 100644
--- a/web/app/dev/stabilization/fixture.tsx
+++ b/web/app/dev/stabilization/fixture.tsx
@@ -29,7 +29,7 @@ const posters = ["/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg", "/qJ2tW6WMUDux911r6m7haRef0
const titles = ["Dune: Part Two", "The Dark Knight", "Interstellar", "The Shawshank Redemption"];
const media: MediaItem[] = Array.from({ length: 24 }, (_, i) => ({ id: -i - 1, mediaType: "movie", title: titles[i % 4], year: "2024", image: `https://image.tmdb.org/t/p/w500${posters[i % 4]}`, backdrop: `https://image.tmdb.org/t/p/w780${posters[i % 4]}`, rating: "8.4", overview: "Controlled test data", activityAt: 100 - i }));
-export function StabilizationFixture() {
+export function StabilizationFixture({ testLiveUrl }: { testLiveUrl?: string } = {}) {
const [ready, setReady] = useState(false);
useEffect(() => setReady(true), []);
const [page, setPage] = useState("tv");
@@ -83,6 +83,8 @@ export function StabilizationFixture() {
+
+ {testLiveUrl &&
}
diff --git a/web/app/dev/stabilization/page.tsx b/web/app/dev/stabilization/page.tsx
index 3e77d89a6..49418dae3 100644
--- a/web/app/dev/stabilization/page.tsx
+++ b/web/app/dev/stabilization/page.tsx
@@ -6,5 +6,5 @@ export const dynamic = "force-dynamic";
export default async function Page({ searchParams }: { searchParams: Promise<{ mobile?: string }> }) {
if (process.env.NODE_ENV !== "development" || process.env.ARVIO_UI_FIXTURES !== "true") notFound();
if ((await searchParams).mobile === "1") return
;
- return
;
+ return
;
}
diff --git a/web/app/tv-guide.css b/web/app/tv-guide.css
index 802f9baf1..4e4d6130c 100644
--- a/web/app/tv-guide.css
+++ b/web/app/tv-guide.css
@@ -9,10 +9,10 @@
.player-overlay.player-docked .player-error p { font-size: 13px; }
.player-overlay.player-docked .player-error-actions { flex-wrap: wrap; gap: 4px; }
.player-overlay.player-docked .player-error-actions button { font-size: 12px; padding: 6px; min-height: 32px; }
-.player-dock-controls { position: absolute; z-index: 5; bottom: 8px; inset-inline: 8px; display: flex; gap: 6px; align-items: center; pointer-events: none; }
+.player-dock-controls { position: absolute; z-index: 50; bottom: 8px; inset-inline: 8px; display: flex; gap: 6px; align-items: center; pointer-events: none; }
.player-dock-controls span { color: white; background: #000b; border-radius: 3px; padding: 4px 6px; font-size: 11px; margin-inline-end: auto; }
.player-dock-controls button, .player-dock-return { pointer-events: auto; width: 36px; height: 36px; display: grid; place-items: center; border: 0; border-radius: 4px; background: #000b; color: white; }
-.player-dock-return { position: absolute; inset-inline-end: 24px; top: 90px; z-index: 20; }
+.player-dock-return { position: absolute; inset-inline-end: 24px; top: 90px; z-index: 50; }
.player-dock-controls button:focus-visible, .player-dock-return:focus-visible { outline: 2px solid white; }
.livetv-preview-play { position: absolute; inset: 0; display: grid; place-items: center; width: 100%; height: 100%; border: 0; border-radius: inherit; background: #0003; color: white; cursor: pointer; }
.livetv-preview-play svg { box-sizing: content-box; padding: 12px; background: #000b; border: 1px solid #ffffff80; border-radius: 50%; }
diff --git a/web/components/player/PlayerOverlay.tsx b/web/components/player/PlayerOverlay.tsx
index 45da44afa..145dae401 100644
--- a/web/components/player/PlayerOverlay.tsx
+++ b/web/components/player/PlayerOverlay.tsx
@@ -1595,7 +1595,7 @@ function VideoPlayer({
))}
{dock.docked &&
-
ON AIR
+
{error ? "Unavailable" : buffering ? "Connecting" : playing ? "LIVE" : "Paused"}
diff --git a/web/lib/settingsOutbox.ts b/web/lib/settingsOutbox.ts
index 32e56e62d..6efe894e5 100644
--- a/web/lib/settingsOutbox.ts
+++ b/web/lib/settingsOutbox.ts
@@ -13,7 +13,7 @@ export function hasPendingSettings(auth: AuthClient, profileId?: string | null)
}
export function queueSettings(auth: AuthClient, profileId: string, settings: AppSettings, baseline: AppSettings | null) {
- if (!auth.session) return;
+ if (!auth.session || !baseline) return;
const key = keyFor(auth.session.userId);
const entries = loadStored
(key, []);
const previous = entries.find((entry) => entry.profileId === profileId);
@@ -32,6 +32,12 @@ export async function flushSettingsOutbox(auth: AuthClient): Promise {
while (auth.session?.userId === userId) {
const entry = loadStored(key, [])[0];
if (!entry) return;
+ // Older clients queued full default snapshots before profile hydration.
+ // They have no acknowledged baseline, so cannot safely describe user edits.
+ if (!entry.baseline) {
+ saveStored(key, loadStored(key, []).filter(pending => pending.id !== entry.id));
+ continue;
+ }
await saveCloudSettings(auth, entry.settings, [], entry.profileId, [], entry.baseline, entry.changedAt);
// Do not acknowledge a newer edit queued while the request was in flight.
saveStored(key, loadStored(key, []).filter((pending) => pending.id !== entry.id));
diff --git a/web/lib/store.tsx b/web/lib/store.tsx
index 51cb12e72..9658e9fc5 100644
--- a/web/lib/store.tsx
+++ b/web/lib/store.tsx
@@ -1329,6 +1329,9 @@ export function AppProvider({
} catch {
baseline = null;
}
+ // Profile hydration is asynchronous. Until this profile has an acknowledged
+ // baseline, settings still belong to the previous profile or browser defaults.
+ if (!baseline) return;
const accountId = authClient.session?.userId;
const submitted = { settings, activeProfileId };
setSettingsSyncState("pending");
diff --git a/web/tests/tv-polish.test.cjs b/web/tests/tv-polish.test.cjs
index e67de55a4..b6ae73e54 100644
--- a/web/tests/tv-polish.test.cjs
+++ b/web/tests/tv-polish.test.cjs
@@ -1,6 +1,6 @@
const test = require('node:test');
const assert = require('node:assert/strict');
-const { load } = require('./load.cjs');
+const { load, storage } = require('./load.cjs');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
@@ -41,3 +41,20 @@ test('late cloud hydration keeps the profile selected on this device', () => {
assert.equal(hydratedProfileId(null, profiles, 'missing'), 'arvind');
assert.equal(hydratedProfileId('deleted', [], 'missing'), null);
});
+
+test('unhydrated settings never overwrite cloud playlists, including legacy queued defaults', async () => {
+ const persisted = storage();
+ const writes = [];
+ const auth = { session: { userId: 'test' } };
+ const outbox = load('lib/settingsOutbox.ts', { './storage': persisted, './cloud': { saveCloudSettings: async (...args) => writes.push(args) } });
+ outbox.queueSettings(auth, 'new-profile', { iptvPlaylists: [] }, null);
+ assert.equal(outbox.hasPendingSettings(auth), false);
+ persisted.saveStored('arvio.web.settingsOutbox.v1:test', [{ id: 'legacy', profileId: 'new-profile', settings: { iptvPlaylists: [] }, baseline: null, changedAt: 1 }]);
+ await outbox.flushSettingsOutbox(auth);
+ assert.equal(writes.length, 0);
+ assert.equal(outbox.hasPendingSettings(auth), false);
+ // An explicit deletion after hydration must still sync.
+ outbox.queueSettings(auth, 'new-profile', { iptvPlaylists: [] }, { iptvPlaylists: [{ id: 'one' }] });
+ await outbox.flushSettingsOutbox(auth);
+ assert.equal(writes.length, 1);
+});