feat(player): a bookmark reopens every player as it was, once
The Plex player names the playlist it plays through (v=player:<id>:playlist:<pid>), so a bookmark rebuilds the queue from the playlist's own order while the item is still in it. Found in the browser and fixed: - after a reload the shell's player opened a second one over Home and the feed: it read the catalog row the page had fetched (a disabled query still reads the shared cache); it now waits for ready. - every reload under an open overlay left a dead history step: the first overlay to open takes over the entry the document was loaded on. - the feed's address was implicit, the same as the start-page landing, so a reloaded or bookmarked feed opened as Home: the feed now names itself (page=feed) like every page. - a linked file or video that is gone was said, but stayed in the address and was said again on every reload: its keys are taken off. The overlay address is written in one place (the entry is named once it is current), and the player keys only the codec reads are no longer exported.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
// The file an address names open on the Files page: the player's key wins over the viewer's, and only a
|
||||
// real row id opens anything — a fractional, zero, negative or garbled one would look up a row that
|
||||
// cannot exist and say the file is gone.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openFileFrom } from "./filesUrlState";
|
||||
|
||||
const from = (q: string) => openFileFrom(new URLSearchParams(q));
|
||||
|
||||
describe("openFileFrom", () => {
|
||||
it("reads the viewer's and the player's file", () => {
|
||||
expect(from("fview=3")).toEqual({ open: "view", id: 3 });
|
||||
expect(from("fplay=4")).toEqual({ open: "play", id: 4 });
|
||||
expect(from("fview=3&fplay=4")).toEqual({ open: "play", id: 4 });
|
||||
});
|
||||
|
||||
it("opens nothing for an id that is not a positive integer", () => {
|
||||
for (const raw of ["1.5", "0", "-2", "abc", ""]) expect(from(`fview=${raw}`)).toBeNull();
|
||||
expect(from("q=x")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,12 @@ describe("an overlay named in its entry's address", () => {
|
||||
expect(window.history.length).toBe(depth + 1);
|
||||
expect(search().get("watch")).toBe("v1");
|
||||
expect(search().get("q")).toBe("music");
|
||||
// What a page reads as Back lands (a host mounting there reopens a player its address names):
|
||||
// the entry under the player never carried it, so there is nothing to clear first.
|
||||
let landedOn = "";
|
||||
window.addEventListener("popstate", () => (landedOn = window.location.search), { once: true });
|
||||
await step(() => window.history.back());
|
||||
expect(landedOn).toBe("?q=music");
|
||||
expect(search().has("watch")).toBe(false);
|
||||
expect(search().get("q")).toBe("music");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload under an open overlay (a player the address names) loads the document on that overlay's
|
||||
// entry, and the page opens the overlay again. It takes that entry over: pushed over it instead, Back
|
||||
// would close the player onto the same page once more — one dead step for every reload. Its own file:
|
||||
// the entry is read as the module loads, so it is set up before the import.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
const search = () => new URLSearchParams(window.location.search);
|
||||
|
||||
describe("an overlay reopened on the entry a reload started from", () => {
|
||||
it("takes that entry over, and Back lands on the page under it", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
|
||||
window.history.pushState({ sfPage: "feed", _ov: 3 }, "", "/?q=music&watch=v1");
|
||||
const depth = window.history.length;
|
||||
const { useBackToClose } = await import("./history");
|
||||
|
||||
const holder: { unmount?: () => void } = {};
|
||||
const r = renderHook(() => useBackToClose(() => holder.unmount?.(), { watch: "v1" }));
|
||||
holder.unmount = r.unmount;
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.history.length).toBe(depth);
|
||||
expect(search().get("watch")).toBe("v1");
|
||||
|
||||
await act(async () => {
|
||||
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
window.history.back();
|
||||
await landed;
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.location.search).toBe("?q=music");
|
||||
});
|
||||
});
|
||||
+19
-11
@@ -290,6 +290,10 @@ let overlayCounter = 0;
|
||||
const nextOverlayToken = () => ++overlayCounter;
|
||||
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
|
||||
let historyDepth = 0;
|
||||
// The entry this document was loaded on, when it was an overlay's (a reload under an open player):
|
||||
// the first overlay to open takes it over rather than pushing another over it — pushed, Back would
|
||||
// close the player onto that same page again, one dead step per reload. Until the first push or Back.
|
||||
let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null;
|
||||
// Programmatic history.back() calls still awaiting their popstate — each must be ignored.
|
||||
let pendingProgrammatic = 0;
|
||||
let reconcileScheduled = false;
|
||||
@@ -307,11 +311,13 @@ function reconcileHistory(): void {
|
||||
const want = overlayStack.length;
|
||||
while (historyDepth < want && pendingProgrammatic === 0) {
|
||||
historyDepth++;
|
||||
window.history.pushState(
|
||||
{ ...window.history.state, _ov: nextOverlayToken() },
|
||||
"",
|
||||
overlayHref(historyDepth),
|
||||
);
|
||||
const here = window.history.state?._ov;
|
||||
if (historyDepth === 1 && here != null && here === reloadedOverlayEntry) {
|
||||
reloadedOverlayEntry = null;
|
||||
continue;
|
||||
}
|
||||
reloadedOverlayEntry = null;
|
||||
window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, "");
|
||||
}
|
||||
while (historyDepth > want) {
|
||||
historyDepth--;
|
||||
@@ -321,20 +327,21 @@ function reconcileHistory(): void {
|
||||
} else {
|
||||
// Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked.
|
||||
const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
|
||||
window.history.replaceState(st, "", overlayHref(historyDepth));
|
||||
window.history.replaceState(st, "");
|
||||
}
|
||||
}
|
||||
nameOverlaysHere();
|
||||
syncWaiters.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// The address of the entry `depth` overlays up: the keys of those overlays, none of the others'.
|
||||
// Undefined (the address as it is) while no overlay names any.
|
||||
function overlayHref(depth: number): string | undefined {
|
||||
// The address of the entry the open overlays stand on: their keys, every other overlay's cleared.
|
||||
// Undefined (the address as it is) while no overlay names any. Written by `nameOverlaysHere` alone:
|
||||
// a pushed entry starts as a copy of the one under it and is named once it is the current one.
|
||||
function overlayHref(): string | undefined {
|
||||
if (overlayUrlKeys.size === 0) return undefined;
|
||||
const updates: OverlayUrl = {};
|
||||
for (const k of overlayUrlKeys) updates[k] = null;
|
||||
for (const o of overlayStack.slice(0, depth)) Object.assign(updates, o.url);
|
||||
for (const o of overlayStack) Object.assign(updates, o.url);
|
||||
return urlWithParams(updates);
|
||||
}
|
||||
|
||||
@@ -342,7 +349,7 @@ function overlayHref(depth: number): string | undefined {
|
||||
// that, the current entry may be one about to be pushed over or stepped back from).
|
||||
function nameOverlaysHere(): void {
|
||||
if (historyDepth !== overlayStack.length || pendingProgrammatic > 0) return;
|
||||
const href = overlayHref(historyDepth);
|
||||
const href = overlayHref();
|
||||
if (href != null && href !== hrefNow())
|
||||
window.history.replaceState(window.history.state, "", href);
|
||||
}
|
||||
@@ -359,6 +366,7 @@ function ensureOverlayListener(): void {
|
||||
if (listenerInstalled) return;
|
||||
listenerInstalled = true;
|
||||
window.addEventListener("popstate", () => {
|
||||
reloadedOverlayEntry = null;
|
||||
if (pendingProgrammatic > 0) {
|
||||
pendingProgrammatic--; // our own back(): consume and ignore
|
||||
// The last one landed: an overlay that opened while it was in flight still owes its entry.
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// entry (lib/history's `useBackToClose`), so Back lands on the page as it was, without the player.
|
||||
// The list it plays from is the page's: the feed's filters, the open playlist (`pl`) — already named.
|
||||
// No position: the player resumes from the server's (`video_states`), which is where it was left.
|
||||
export const PLAYER_WATCH_KEY = "watch"; // the item of the list playing now
|
||||
export const PLAYER_RELATED_KEY = "wrel"; // a related video played in place over it
|
||||
export const PLAYER_PANEL_KEY = "wtab"; // the wide panel's tab, when it is not the description
|
||||
const PLAYER_WATCH_KEY = "watch"; // the item of the list playing now
|
||||
const PLAYER_RELATED_KEY = "wrel"; // a related video played in place over it
|
||||
const PLAYER_PANEL_KEY = "wtab"; // the wide panel's tab, when it is not the description
|
||||
export const PLAYER_KEYS = [PLAYER_WATCH_KEY, PLAYER_RELATED_KEY, PLAYER_PANEL_KEY] as const;
|
||||
|
||||
export type PlayerPanelTab = "description" | "related";
|
||||
@@ -24,7 +24,7 @@ export function playerParamUpdates(state: PlayerUrlState): Record<string, string
|
||||
}
|
||||
|
||||
/** The player an address names, or null when it names none. */
|
||||
export function playerFrom(params: URLSearchParams): PlayerUrlState | null {
|
||||
function playerFrom(params: URLSearchParams): PlayerUrlState | null {
|
||||
const watch = params.get(PLAYER_WATCH_KEY);
|
||||
if (!watch) return null;
|
||||
return {
|
||||
|
||||
@@ -57,6 +57,16 @@ describe("plexParamUpdates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("writes an unsorted view as such, so a link to it is not sorted by the remembered sort", () => {
|
||||
const unsorted = { ...BASE, sort: null };
|
||||
expect(plexParamUpdates(unsorted).psort).toBe("none");
|
||||
const back = plexParamsToState(applyUpdates(plexParamUpdates(unsorted)), {
|
||||
...BASE,
|
||||
sort: { key: "year", dir: "desc" },
|
||||
});
|
||||
expect(back?.sort).toBeNull();
|
||||
});
|
||||
|
||||
it("a link to the default view opens it over another remembered one", () => {
|
||||
const params = applyUpdates(plexParamUpdates(BASE));
|
||||
const back = plexParamsToState(params, {
|
||||
|
||||
@@ -233,6 +233,13 @@ export function urlWithParams(updates: Record<string, string | null>): string {
|
||||
return hrefHere(params);
|
||||
}
|
||||
|
||||
/** Take `keys` off the current entry's address: what a link named and the page could not show (a file
|
||||
* or a video that is gone, said once) must not stay to be said again on every reload, or shared. */
|
||||
export function dropFromAddress(keys: readonly string[]): void {
|
||||
const updates = Object.fromEntries(keys.map((k) => [k, null]));
|
||||
window.history.replaceState(window.history.state, "", urlWithParams(updates));
|
||||
}
|
||||
|
||||
/** The channel page's address, opened over the page on screen: the shell's own keys (the page, an
|
||||
* entry link not yet read) and the channel — none of the page's keys, none of another channel's view
|
||||
* (the channel page writes its own, lib/channelPageUrlState), and no player (opening a channel closes
|
||||
|
||||
Reference in New Issue
Block a user