Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion web/app/dev/stabilization/fixture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -83,6 +83,8 @@ export function StabilizationFixture() {
<PlayerOverlay />
<div className="fixture-tools">
<button onClick={() => { setPage("tv"); setActiveChannel(channels[0]); setActiveStream({ source: "CC0 live-player sample", addonName: "Test fixture", quality: "HD", size: "", url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" }); }}>Test guide mini-player</button>
<button onClick={() => { setPage("tv"); setActiveChannel(channels[0]); setActiveStream({ source: "Unavailable live-player sample", addonName: "Test fixture", quality: "HD", size: "", url: "https://example.invalid/unavailable.m3u8" }); }}>Test mini-player startup failure</button>
{testLiveUrl && <button onClick={() => { setPage("tv"); setActiveChannel(channels[0]); setActiveStream({ source: "Provider playback verification", addonName: "Live TV", quality: "Live", size: "", url: testLiveUrl }); }}>Test supplied IPTV source</button>}
<button onClick={() => { setSettings((old) => ({ ...old, homeServers: (["plex", "jellyfin", "emby"] as const).map((type) => ({ id: type, type, name: `Fixture ${type}`, url: `https://${type}.invalid`, token: "fixture-only", userId: "fixture", enabled: true })) })); setPage("library"); }}>Test home server libraries</button>
<button onClick={() => setActiveStream({ source: "YouTube player example", addonName: "Test fixture", quality: "", size: "", url: "https://www.youtube.com/watch?v=M7lc1UVf-VE" })}>Test YouTube embed</button>
<button onClick={() => setActiveStream({ source: "Browser conversion test", addonName: "Local fixture", quality: "540p", size: "", url: "http://127.0.0.1:3099/media/multi.mkv", remux: true })}>Test MKV browser player</button>
Expand Down
2 changes: 1 addition & 1 deletion web/app/dev/stabilization/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <iframe title="Mobile QA viewport" src="/dev/stabilization" style={{ display: "block", width: 390, height: 844, border: "1px solid #444", margin: "20px auto" }} />;
return <StabilizationFixture />;
return <StabilizationFixture testLiveUrl={process.env.ARVIO_TEST_LIVE_URL} />;
}
4 changes: 2 additions & 2 deletions web/app/tv-guide.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%; }
Expand Down
2 changes: 1 addition & 1 deletion web/components/player/PlayerOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1595,7 +1595,7 @@ function VideoPlayer({
))}
</video>
{dock.docked && <div className="player-dock-controls">
<span>ON AIR</span>
<span role="status">{error ? "Unavailable" : buffering ? "Connecting" : playing ? "LIVE" : "Paused"}</span>
<button type="button" onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} title={playing ? "Pause" : "Play"}>{playing ? <Pause size={20} /> : <Play size={20} />}</button>
<button type="button" onClick={dock.expand} aria-label="Expand player" title="Expand player"><Maximize size={20} /></button>
<button type="button" onClick={onClose} aria-label="Stop channel" title="Stop channel"><X size={20} /></button>
Expand Down
8 changes: 7 additions & 1 deletion web/lib/settingsOutbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pending[]>(key, []);
const previous = entries.find((entry) => entry.profileId === profileId);
Expand All @@ -32,6 +32,12 @@ export async function flushSettingsOutbox(auth: AuthClient): Promise<void> {
while (auth.session?.userId === userId) {
const entry = loadStored<Pending[]>(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<Pending[]>(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<Pending[]>(key, []).filter((pending) => pending.id !== entry.id));
Expand Down
3 changes: 3 additions & 0 deletions web/lib/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
19 changes: 18 additions & 1 deletion web/tests/tv-polish.test.cjs
Original file line number Diff line number Diff line change
@@ -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');

Expand Down Expand Up @@ -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);
});
Loading