fix(ui): E31 S2 — the tab names the object, the channel header wraps, the player fits a short window
- The browser tab names the place and the object: PageHeading reports the view's own title (lib/documentTitle), so a channel, playlist or any Plex view reads "<object> · <module> · Siftlode" however it was opened; the Feed is named too; a season adds its show (tabTitle). - The channel page's tab + action row wraps and lets the tag lane fold into "+N": at 1024 it ran past the edge and the page scrolled sideways. - The YouTube player card's width is capped from the window height less the chrome measured under the video: at 133% zoom (685px) it cut its channel row. - Plex certificate words (Not Rated, Approved, Passed) are named through the vocabulary on the info badge, show page, collection, filter and chips. - DESIGN.md: the zoom target is 125% and 133%, not 200% (decision log).
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { type RefObject, useLayoutEffect, useRef, useSyncExternalStore } from "react";
|
||||
|
||||
// The browser tab names the place AND the object in it (DESIGN § Composition and the reader, "Where am
|
||||
// I"): "Nicolas Cage · Plex · Siftlode". The place is the module (NavigationProvider); the object is the
|
||||
// view's own visible title, so it is read from the element that already shows it (`PageHeading`), not
|
||||
// handed over a second time by every view — a view with a title on screen names the tab by having it.
|
||||
// The latest mounted title wins (a view stacked over another); an empty one (still loading) is skipped.
|
||||
|
||||
type Entry = { text: string };
|
||||
let entries: Entry[] = [];
|
||||
const subs = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
for (const cb of subs) cb();
|
||||
}
|
||||
|
||||
function snapshot(): string | null {
|
||||
let found: string | null = null;
|
||||
for (const e of entries) if (e.text) found = e.text;
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The object the screen shows, for the tab title; null when the view has no title of its own. */
|
||||
export function useDocumentObjectTitle(): string | null {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
subs.add(cb);
|
||||
return () => subs.delete(cb);
|
||||
},
|
||||
snapshot,
|
||||
snapshot,
|
||||
);
|
||||
}
|
||||
|
||||
/** Names the tab after what `el` shows (its text), or after `override` when given (a season page's
|
||||
* heading is "Season 1"; the tab wants the show too). Re-read after every render, so a title that
|
||||
* arrives with its data, or changes in place, follows. */
|
||||
export function useNameTabAfter(el: RefObject<HTMLElement | null>, override?: string): void {
|
||||
const entry = useRef<Entry | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const e: Entry = { text: "" };
|
||||
entry.current = e;
|
||||
entries = [...entries, e];
|
||||
return () => {
|
||||
entries = entries.filter((x) => x !== e);
|
||||
entry.current = null;
|
||||
emit();
|
||||
};
|
||||
}, []);
|
||||
// No dependency list: the text is the DOM's, read after each render of the heading.
|
||||
useLayoutEffect(() => {
|
||||
const e = entry.current;
|
||||
const text = (override ?? el.current?.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!e || e.text === text) return;
|
||||
e.text = text;
|
||||
emit();
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,10 @@ describe("a vocabulary word", () => {
|
||||
["autoTag", "Hungarian", "Magyar"],
|
||||
["profile", "Video only (MP4)", "Csak videó (MP4)"],
|
||||
["topic", "Lifestyle_(sociology)", "Életmód"],
|
||||
// E31 S1: Plex's certificate words came through in English on the info badge and the filter.
|
||||
["certificate", "Not Rated", "Nincs besorolva"],
|
||||
["certificate", "Approved", "Jóváhagyva"],
|
||||
["certificate", "Passed", "Engedélyezve"],
|
||||
] as const)("%s %j reads %j in Hungarian", (group, word, shown) => {
|
||||
expect(vocab(hu, group, word)).toBe(shown);
|
||||
});
|
||||
@@ -27,6 +31,11 @@ describe("a vocabulary word", () => {
|
||||
expect(vocab(en, "topic", "Pop_music", "Pop music")).toBe("Pop music");
|
||||
});
|
||||
|
||||
it("a certificate code reads the same in every language", () => {
|
||||
expect(vocab(hu, "certificate", "PG-13")).toBe("PG-13");
|
||||
expect(vocab(hu, "certificate", "TV-MA")).toBe("TV-MA");
|
||||
});
|
||||
|
||||
it("an unknown word is shown, not hidden or keyed", () => {
|
||||
expect(vocab(hu, "crewJob", "Gaffer")).toBe("Gaffer");
|
||||
expect(vocab(hu, "topic", "Knitting_(craft)", "Knitting (craft)")).toBe("Knitting (craft)");
|
||||
|
||||
@@ -2,23 +2,31 @@ import type { TFunction } from "i18next";
|
||||
import type { PlexTrackNaming } from "./api";
|
||||
|
||||
// E30: the closed vocabularies the server stores in ENGLISH — a TMDB crew job or department, an
|
||||
// auto-tag, a built-in download profile, a YouTube topic — named in the reader's language. The data
|
||||
// auto-tag, a built-in download profile, a YouTube topic, a Plex certificate's word ("Not Rated"; a
|
||||
// code such as "PG-13" reads the same everywhere and falls through as stored) — named in the reader's language. The data
|
||||
// stays language-neutral (it is filtered and matched on); only the NAME is translated, here, from the
|
||||
// `vocab` locale area. A word the area does not hold shows as stored, so a new server value is never
|
||||
// hidden — and the backend's own lists are held key-for-key to the area by backendIds.contract.
|
||||
|
||||
type Group = "crewJob" | "department" | "autoTag" | "profile" | "topic";
|
||||
type Group = "crewJob" | "department" | "autoTag" | "profile" | "topic" | "certificate";
|
||||
|
||||
/** A vocabulary word's key: its letters and digits ("Health & Fitness" → "HealthFitness"). */
|
||||
export function vocabKey(word: string): string {
|
||||
return word.replace(/[^A-Za-z0-9]/g, "");
|
||||
}
|
||||
|
||||
/** The i18n key of one word of `group` ("" when the word has no letters or digits) — for a caller
|
||||
* whose `t` is not i18next's own (a pure builder handed a narrow `t`). */
|
||||
export function vocabI18nKey(group: Group, word: string): string {
|
||||
const key = vocabKey(word);
|
||||
return key ? `vocab.${group}.${key}` : "";
|
||||
}
|
||||
|
||||
/** One stored English word of `group`, in the reader's language (`fallback`, by default the word
|
||||
* itself, when the area does not hold it). */
|
||||
export function vocab(t: TFunction, group: Group, word: string, fallback = word): string {
|
||||
const key = vocabKey(word);
|
||||
return key ? t(`vocab.${group}.${key}`, { defaultValue: fallback }) : fallback;
|
||||
const key = vocabI18nKey(group, word);
|
||||
return key ? t(key, { defaultValue: fallback }) : fallback;
|
||||
}
|
||||
|
||||
/** A download profile's name: a built-in one is seeded under an English name and named here; a user's
|
||||
|
||||
Reference in New Issue
Block a user