fix: review round 1 — row press after a rename, related reset, dead items on removal, honest Prev/Next
- A press that begins while a field in the row is edited only ends the editing (rowOpen latches it at pointerdown); the card layout's row press is now tested. - A related video reset in the player forgets the session's stored position. - Removing the playing video passes over known-dead items and steps the way it went. - Prev/Next off only because of dead items stay focusable and say why (HU/EN). - The docked bar's scroller leaves room for focus rings. - A focus-opened About card is not closed by its own scroll into view. - EN "Clear watch progress" for the video reset, distinct from a page's "Reset view". - The signed-out title read goes to the page yt-dlp read; the retitle writes are conditional. - Tests: the cap of three errors, the bar's own Fullscreen, the group glyph, the backfill's reads.
This commit is contained in:
@@ -7,7 +7,8 @@ and safe to re-run: it only touches assets still missing the field, and only re-
|
||||
Facebook sources (a direct media URL like a reddit HLS manifest carries no channel and its signed
|
||||
URL may have expired — those just get a locally-generated poster instead). A Facebook download
|
||||
titled with the source's placeholder ("Facebook", "Video") is retitled from a signed-out read of its
|
||||
page, and so is each download name that only repeated the placeholder.
|
||||
page, and so is each download name that only repeated the placeholder. That page read
|
||||
(`public_title_info`) also titles every new download, from the worker.
|
||||
|
||||
Run once per environment:
|
||||
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
|
||||
@@ -16,7 +17,7 @@ Run once per environment:
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import yt_dlp
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
@@ -88,10 +89,13 @@ def _retitle(db: Session, a: MediaAsset, adapter: adapters.SourceAdapter, url: s
|
||||
named = normalize_title(adapter.title(info))
|
||||
if not named or adapter.is_placeholder_title(named):
|
||||
return False
|
||||
jobs = db.execute(select(DownloadJob).where(DownloadJob.asset_id == a.id)).scalars()
|
||||
for job in jobs:
|
||||
if job.display_name == a.title:
|
||||
job.display_name = named[:DISPLAY_TEXT_MAX]
|
||||
# One conditional write, not a read and a compare: a name its owner gives while this runs (the
|
||||
# poster step can take seconds before the commit) is not overwritten by what was read before it.
|
||||
db.execute(
|
||||
update(DownloadJob)
|
||||
.where(DownloadJob.asset_id == a.id, DownloadJob.display_name == a.title)
|
||||
.values(display_name=named[:DISPLAY_TEXT_MAX])
|
||||
)
|
||||
a.title = named
|
||||
return True
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yt_dlp
|
||||
from sqlalchemy import update
|
||||
from yt_dlp.utils import download_range_func
|
||||
|
||||
from app import logs, sysconfig
|
||||
@@ -587,8 +588,10 @@ def _download(
|
||||
# too. Same probe the upload ingest uses.
|
||||
vcodec, acodec, width, height, duration_s = probe.resolve_media_facts(produced, info)
|
||||
# A placeholder title with nothing behind it ("Facebook" from a signed-in read) borrows the
|
||||
# page's text from one signed-out read; the download itself stays the signed-in one.
|
||||
titled = backfill.public_title_info(info, url, adapter, asset_id)
|
||||
# page's text from one signed-out read; the download itself stays the signed-in one. The page
|
||||
# yt-dlp read, not the link queued: a group post's link is rewritten to its video, and
|
||||
# signed out the post itself is the login wall.
|
||||
titled = backfill.public_title_info(info, info.get("webpage_url") or url, adapter, asset_id)
|
||||
meta = storage.MediaMeta(
|
||||
video_id=info.get("id") or source_ref,
|
||||
title=normalize_title(adapter.title(titled)) or source_ref,
|
||||
@@ -1120,14 +1123,20 @@ def _process_clip(job_id: int, asset_id: int) -> None:
|
||||
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||
# A video outside the catalog was queued as "Clip (clip)": the fetch has its title now.
|
||||
placeholder = service.clip_title(None, None)
|
||||
named = None
|
||||
if asset and fetched_title and asset.title == placeholder:
|
||||
named = service.clip_title(fetched_title, None)
|
||||
asset.title = named
|
||||
if job_row := db.get(DownloadJob, job_id):
|
||||
if job_row.display_name == placeholder:
|
||||
fields["display_name"] = named[:DISPLAY_TEXT_MAX]
|
||||
title = asset.title if asset else None
|
||||
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||
if finalized and named:
|
||||
# Only a name that still repeats the placeholder, in one conditional write: one its
|
||||
# owner gave meanwhile stays.
|
||||
db.execute(
|
||||
update(DownloadJob)
|
||||
.where(DownloadJob.id == job_id, DownloadJob.display_name == placeholder)
|
||||
.values(display_name=named[:DISPLAY_TEXT_MAX])
|
||||
)
|
||||
db.commit()
|
||||
if finalized:
|
||||
_notify_download_done(db, user_id, title, job_id, kind="edit")
|
||||
|
||||
@@ -6,11 +6,18 @@ no post text to fall back on, and the download name copied the placeholder too.
|
||||
the same reel (yt-dlp 2026.08.19, as on prod) still carries the post's text in its description.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
from sqlalchemy import update
|
||||
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import adapters, backfill, pipeline
|
||||
from app.downloads.models import DownloadJob, MediaAsset
|
||||
|
||||
_URL = "https://www.facebook.com/reel/1860682468422402/"
|
||||
# The page yt-dlp says it read for it (`webpage_url`); a group post's link resolves the same way.
|
||||
_PAGE = "https://www.facebook.com/watch/?v=1860682468422402"
|
||||
# The measured signed-out read of that reel.
|
||||
_PUBLIC = {
|
||||
"title": "163K reactions · 14K shares | sorry not helping #workhumor #officelife | Work dot exe",
|
||||
@@ -20,12 +27,13 @@ _PUBLIC = {
|
||||
|
||||
|
||||
def _fake_ydl(monkeypatch, outcome):
|
||||
"""yt-dlp faked at its `YoutubeDL` boundary: records each read's options, answers `outcome`."""
|
||||
"""yt-dlp faked at its `YoutubeDL` boundary: records each read (its options and its `url`),
|
||||
answers `outcome`."""
|
||||
seen: list[dict] = []
|
||||
|
||||
class FakeYDL:
|
||||
def __init__(self, opts):
|
||||
seen.append(opts)
|
||||
self.opts = opts
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
@@ -34,6 +42,7 @@ def _fake_ydl(monkeypatch, outcome):
|
||||
return False
|
||||
|
||||
def extract_info(self, url, download):
|
||||
seen.append({**self.opts, "url": url})
|
||||
return outcome
|
||||
|
||||
monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", FakeYDL)
|
||||
@@ -61,6 +70,14 @@ def test_a_real_title_reads_nothing_more(monkeypatch):
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_a_placeholder_with_the_posts_text_behind_it_reads_nothing_more(monkeypatch):
|
||||
seen = _fake_ydl(monkeypatch, _PUBLIC)
|
||||
info = {"title": "Video", "description": "Office jokes, part two"}
|
||||
|
||||
assert backfill.public_title_info(info, _URL, adapters.FacebookAdapter(), 7) is info
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_a_placeholder_the_signed_out_page_cannot_better_stays(monkeypatch):
|
||||
_fake_ydl(monkeypatch, {"title": "Facebook", "description": None})
|
||||
fb = adapters.FacebookAdapter()
|
||||
@@ -81,7 +98,7 @@ def test_another_source_is_never_read_again(monkeypatch):
|
||||
def test_the_backfill_retitles_a_placeholder_download_and_the_names_that_repeated_it(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
_fake_ydl(monkeypatch, _PUBLIC)
|
||||
seen = _fake_ydl(monkeypatch, _PUBLIC)
|
||||
owner, other = make_user(), make_user()
|
||||
asset = MediaAsset(
|
||||
source_kind="url",
|
||||
@@ -120,6 +137,7 @@ def test_the_backfill_retitles_a_placeholder_download_and_the_names_that_repeate
|
||||
db.commit()
|
||||
|
||||
assert backfill.run()["retitled"] == 1
|
||||
assert len(seen) == 1
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(MediaAsset, asset.id).title == "sorry not helping"
|
||||
@@ -127,6 +145,66 @@ def test_the_backfill_retitles_a_placeholder_download_and_the_names_that_repeate
|
||||
assert db.get(DownloadJob, renamed.id).display_name == "Office jokes"
|
||||
# Run again: nothing left to retitle, and no page read for it.
|
||||
assert backfill.run()["retitled"] == 0
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch):
|
||||
# The owner renames the download between the backfill's retitle and its commit (the poster step
|
||||
# sits between them): the owner's name wins, it is not overwritten by what was read before it.
|
||||
_fake_ydl(monkeypatch, _PUBLIC)
|
||||
owner = make_user()
|
||||
asset = MediaAsset(
|
||||
source_kind="url",
|
||||
source_ref=_URL,
|
||||
format_sig="fb",
|
||||
status="ready",
|
||||
rel_path="facebook/x.mp4",
|
||||
uploader_url="https://www.facebook.com/1",
|
||||
title="Facebook",
|
||||
ref_count=1,
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
job = DownloadJob(
|
||||
user_id=owner.id,
|
||||
asset_id=asset.id,
|
||||
source_kind="url",
|
||||
source_ref=_URL,
|
||||
status="done",
|
||||
queue_pos=0,
|
||||
profile_snapshot={},
|
||||
display_name="Facebook",
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
|
||||
def rename():
|
||||
with SessionLocal() as other:
|
||||
other.execute(
|
||||
update(DownloadJob)
|
||||
.where(DownloadJob.id == job.id)
|
||||
.values(display_name="Office jokes")
|
||||
)
|
||||
other.commit()
|
||||
|
||||
def poster_while_renamed(a, root):
|
||||
# A rename that must wait for the backfill's write waits here, and lands after its commit.
|
||||
t = threading.Thread(target=rename)
|
||||
t.start()
|
||||
t.join(timeout=0.5)
|
||||
renamers.append(t)
|
||||
return None
|
||||
|
||||
renamers: list[threading.Thread] = []
|
||||
monkeypatch.setattr(backfill.editmod, "ensure_poster", poster_while_renamed)
|
||||
|
||||
assert backfill.run()["retitled"] == 1
|
||||
for t in renamers:
|
||||
t.join(timeout=10)
|
||||
assert not t.is_alive()
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).display_name == "Office jokes"
|
||||
|
||||
|
||||
def test_a_download_titled_facebook_is_named_from_the_signed_out_page(
|
||||
@@ -134,7 +212,7 @@ def test_a_download_titled_facebook_is_named_from_the_signed_out_page(
|
||||
):
|
||||
# The whole download path with yt-dlp and the media tools faked: the signed-in read gives only
|
||||
# the placeholder, and the file and the download's name get the post's text.
|
||||
_fake_ydl(monkeypatch, _PUBLIC)
|
||||
seen = _fake_ydl(monkeypatch, _PUBLIC)
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||
monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, produced, *_a: produced)
|
||||
@@ -168,12 +246,16 @@ def test_a_download_titled_facebook_is_named_from_the_signed_out_page(
|
||||
"id": "1860682468422402",
|
||||
"title": "Facebook",
|
||||
"uploader": "Work dot exe",
|
||||
"webpage_url": _PAGE,
|
||||
"requested_downloads": [{"filepath": str(f)}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", signed_in_read)
|
||||
pipeline._download(job.id, asset.id, "url", _URL, {}, owner.id)
|
||||
|
||||
# The signed-out read goes to the page yt-dlp read, not the link queued: a group post's link
|
||||
# resolves to its video, and signed out the post itself is the login wall.
|
||||
assert [r["url"] for r in seen] == [_PAGE]
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "done"
|
||||
assert db.get(MediaAsset, asset.id).title == "sorry not helping"
|
||||
|
||||
@@ -579,7 +579,7 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
||||
# enqueue_clip slices the source's title to the same width.
|
||||
# files.ingest_spooled passes through `spool_upload`'s value, bounded there.
|
||||
# routes enqueue_clip the `display_name=` keyword passes on the name bound above it.
|
||||
# pipeline._download / _process_clip / worker._finish_from_cache
|
||||
# pipeline._download / _process_clip / worker._finish_from_cache / backfill._retitle
|
||||
# slice a fetched or stored title to the column's width.
|
||||
#
|
||||
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
|
||||
|
||||
+2
-1
@@ -831,4 +831,5 @@ of a full-width panel and nothing on the right.
|
||||
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
|
||||
| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels |
|
||||
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
|
||||
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
|
||||
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
|
||||
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//
|
||||
// Below `md` a table is a list of cards, its columns flowing one after another. A column marked
|
||||
// `cardRow` takes a line of its own there — a row's lone "…" otherwise landed at the end of one
|
||||
// card's line and on the next line of another.
|
||||
// card's line and on the next line of another. A card is its row's press surface too, as the row is.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, screen } from "@testing-library/react";
|
||||
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
@@ -43,4 +43,21 @@ describe("DataTable cards", () => {
|
||||
expect(screen.getByText("more").parentElement!.className).toContain("basis-full");
|
||||
expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full");
|
||||
});
|
||||
|
||||
it("opens its object from a tap on the card, and leaves its control's tap to the control", () => {
|
||||
narrow(false);
|
||||
const open = vi.fn();
|
||||
render(
|
||||
<DataTable
|
||||
rows={[{ id: "a" }]}
|
||||
columns={columns}
|
||||
rowKey={(r) => r.id}
|
||||
rowOpen={() => open}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("1 MB"), { detail: 1 });
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByText("more"), { detail: 1 });
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
||||
|
||||
## What lives here
|
||||
|
||||
| area | components |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||
| area | components |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useInteractions,
|
||||
useFloatingNodeId,
|
||||
safePolygon,
|
||||
type OpenChangeReason,
|
||||
type Placement,
|
||||
type UseRoleProps,
|
||||
} from "@floating-ui/react";
|
||||
@@ -32,7 +33,8 @@ type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox";
|
||||
|
||||
export interface AnchoredPopoverOptions {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean, event?: Event) => void;
|
||||
/** `reason`: what opened or closed it (floating-ui's: "hover", "focus", "ancestor-scroll", …). */
|
||||
onOpenChange: (open: boolean, event?: Event, reason?: OpenChangeReason) => void;
|
||||
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards);
|
||||
* "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */
|
||||
mode?: PopoverMode;
|
||||
|
||||
@@ -9,18 +9,25 @@ import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
|
||||
// - a click that ends a text selection, or a press that travelled (a drag across the row's padding):
|
||||
// DESIGN.md "Text stays selectable" — no play or open fires;
|
||||
// - an event from a portal (a popover a cell opened): React bubbles it through the row, the DOM does
|
||||
// not, so only a target the row really contains counts.
|
||||
// not, so only a target the row really contains counts;
|
||||
// - a press that began while a field inside the row held the focus (its rename): that press ends the
|
||||
// editing — its mousedown blurs the field, which saves — and does nothing more. Read at pointerdown,
|
||||
// because by the click the blur has already put the row back as it is when nothing is edited.
|
||||
// The long-press menu's trailing click is swallowed by its own trigger (`useContextMenu`), before it
|
||||
// reaches the row.
|
||||
|
||||
// A field the focus can be editing in.
|
||||
const FIELD = 'input, textarea, select, [contenteditable="true"]';
|
||||
|
||||
// What counts as a control of its own. `[tabindex]` covers a focusable cell (a hover card's trigger);
|
||||
// the row's own roving tab stop is the row itself, which the containment check below leaves out.
|
||||
const CONTROL =
|
||||
'a, button, input, select, textarea, label, summary, [role="button"], [role="menuitem"], [role="checkbox"], [role="switch"], [contenteditable="true"], [tabindex]';
|
||||
|
||||
// Where each row's press began, for the drag slop. Per element: the table row and its card twin are
|
||||
// two elements, and a press on one must not be measured against the other.
|
||||
const pressedAt = new WeakMap<Element, { x: number; y: number }>();
|
||||
// Where each row's press began, for the drag slop, and whether it began while a field inside the row
|
||||
// was being edited. Per element: the table row and its card twin are two elements, and a press on one
|
||||
// must not be measured against the other.
|
||||
const pressedAt = new WeakMap<Element, { x: number; y: number; editing: boolean }>();
|
||||
|
||||
type Handlers = {
|
||||
onPointerDown?: (e: PointerEvent<HTMLElement>) => void;
|
||||
@@ -39,13 +46,18 @@ export function withRowOpen(
|
||||
return {
|
||||
...base,
|
||||
onPointerDown: (e: PointerEvent<HTMLElement>) => {
|
||||
pressedAt.set(e.currentTarget, { x: e.clientX, y: e.clientY });
|
||||
const row = e.currentTarget;
|
||||
const focused = document.activeElement;
|
||||
const editing = !!focused && row.contains(focused) && focused.matches(FIELD);
|
||||
pressedAt.set(row, { x: e.clientX, y: e.clientY, editing });
|
||||
onPointerDown?.(e);
|
||||
},
|
||||
onClick: (e: MouseEvent<HTMLElement>) => {
|
||||
onClick?.(e);
|
||||
if (e.defaultPrevented || !claims(e)) return;
|
||||
open();
|
||||
const claimed = !e.defaultPrevented && claims(e);
|
||||
// One press, one click: a later keyboard click (no press) is not judged by this one.
|
||||
pressedAt.delete(e.currentTarget);
|
||||
if (claimed) open();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -56,5 +68,6 @@ function claims(e: MouseEvent<HTMLElement>): boolean {
|
||||
if (!(target instanceof Element) || !row.contains(target)) return false;
|
||||
const control = target.closest(CONTROL);
|
||||
if (control && control !== row && row.contains(control)) return false;
|
||||
return !endsTextSelection(e) && !movedPastSlop(pressedAt.get(row) ?? null, e);
|
||||
const pressed = pressedAt.get(row) ?? null;
|
||||
return !pressed?.editing && !endsTextSelection(e) && !movedPastSlop(pressed, e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** An unavailable control's props. With a reason it stays focusable (`aria-disabled`, which
|
||||
* `FocusSafeButton` keeps inert to a click, and the reason in its tooltip — DESIGN.md "Unavailable
|
||||
* with a reason stays focusable"); without one it is plainly off. */
|
||||
export function unavailable(disabled: boolean | undefined, reason: string | undefined) {
|
||||
if (!disabled) return {};
|
||||
return reason ? { "aria-disabled": true as const } : { disabled: true };
|
||||
}
|
||||
@@ -1050,6 +1050,35 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(lastStart()?.start ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
// A reset from the related row is where it starts now: the position this session stored for it
|
||||
// (which a plain pick resumes from) is forgotten with it, not replayed and checkpointed back.
|
||||
it("wide: a related video reset from its row starts at 0 on a plain pick", async () => {
|
||||
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
||||
video_id: id,
|
||||
position_seconds: id === "rel2" ? 500 : 77,
|
||||
}));
|
||||
const reset = vi.spyOn(api, "clearState").mockResolvedValue(undefined as never);
|
||||
clock.current = 77;
|
||||
await playRel2(() => Promise.resolve());
|
||||
clock.current = 500;
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
await settle();
|
||||
const list = screen.getByTestId("player-related");
|
||||
const links = () => within(list).getAllByRole("link", { name: "Video rel2" });
|
||||
fireEvent.contextMenu(links()[0]!.closest("[data-video-id]")!);
|
||||
fireEvent.click(
|
||||
screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "card.resetState")!,
|
||||
);
|
||||
await waitFor(() => expect(reset).toHaveBeenCalledWith("rel2"));
|
||||
await settle();
|
||||
fireEvent.click(titleOf(links()));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
|
||||
expect(lastStart()?.start ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
// Its row stays in the list while it plays; a new player for the same video is never built (one
|
||||
// per video), so Restart there is a seek.
|
||||
it("wide: Restart on the related video already playing seeks it to 0", async () => {
|
||||
@@ -1654,9 +1683,10 @@ describe("PlayerModal — the docked bar's fullscreen", () => {
|
||||
it("keeps the way out in sight: glyphs only, and out of the scrolling part", () => {
|
||||
maximised(true);
|
||||
const out = screen.getByRole("button", { name: "player.exitMaximize" });
|
||||
const fulls = screen.getAllByLabelText("player.fullscreen");
|
||||
const full = fulls[fulls.length - 1]!;
|
||||
const bar = out.parentElement!;
|
||||
// The windowed panel keeps a Fullscreen of its own too; this is the bar's.
|
||||
const full = screen.getAllByLabelText("player.fullscreen").find((b) => bar.contains(b))!;
|
||||
expect(full).toBeTruthy();
|
||||
// The windowed row keeps its own Mark watched; this is the bar's.
|
||||
const watched = screen
|
||||
.getAllByRole("button", { name: "player.markWatched" })
|
||||
@@ -1898,15 +1928,88 @@ describe("PlayerModal — stepping over a video that cannot play", () => {
|
||||
expect(opened("B")).toBe(1);
|
||||
});
|
||||
|
||||
it("Previous is off when only dead videos are before you", async () => {
|
||||
// Off, and saying why: the counter shows a video before this one, so a dark button alone would not
|
||||
// tell (DESIGN "Unavailable with a reason stays focusable").
|
||||
it("Previous is off, with its reason, when only dead videos are before you", async () => {
|
||||
await open(0);
|
||||
fail(); // opened on it, auto-advance off: it stays, and it is known dead now
|
||||
await settle();
|
||||
expect(lastStart()?.videoId).toBe("A");
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
for (const b of screen.getAllByRole("button", { name: "player.previous" }))
|
||||
const prevs = screen.getAllByRole("button", { name: "player.previous" });
|
||||
for (const b of prevs) {
|
||||
expect((b as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(b.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(b.getAttribute("title")).toContain("player.noPlayableBefore");
|
||||
}
|
||||
prev();
|
||||
await settle();
|
||||
expect(lastStart()?.videoId).toBe("B");
|
||||
// Next has playable C ahead: on, and no reason.
|
||||
for (const b of screen.getAllByRole("button", { name: "player.next" }))
|
||||
expect(b.getAttribute("title")).not.toContain("player.noPlayableAfter");
|
||||
});
|
||||
|
||||
it("Previous at the queue's start is plainly off: the counter says why", async () => {
|
||||
await open(0);
|
||||
for (const b of screen.getAllByRole("button", { name: "player.previous" })) {
|
||||
expect((b as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(b.getAttribute("title")).not.toContain("player.noPlayableBefore");
|
||||
}
|
||||
});
|
||||
|
||||
// Removing the playing video passes over the ones known dead, the way a step does, and goes back
|
||||
// when nothing playable followed it.
|
||||
it("removing the last video goes back over a dead one to the one that plays", async () => {
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, B, C]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={vi.fn()}
|
||||
onRemoveFromList={async () => true}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
fail();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||
fireEvent.click(trash("C"));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
expect(opened("B")).toBe(1);
|
||||
});
|
||||
|
||||
// The player gives up after three in a row: it stays on the third's error, and all three are
|
||||
// remembered — a later step passes over them.
|
||||
it("three dead in a row stop on the third, and a step then passes over all three", async () => {
|
||||
const [D, E] = [vid("D", 0), vid("E", 0)];
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, B, C, D, E]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
fail();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||
fail();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("D"));
|
||||
fail();
|
||||
await settle();
|
||||
expect(lastStart()?.videoId).toBe("D");
|
||||
prev();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("E"));
|
||||
expect([opened("B"), opened("C"), opened("D")]).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it("a video opened dead with auto-advance off stays on its error", async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { PlayerDescription, PlayerTransportControls } from "./playerModalControls";
|
||||
import { unavailable } from "../../components/ui/unavailable";
|
||||
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../../lib/playerModes";
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -702,11 +703,15 @@ export default function PlayerModal({
|
||||
const goNext = () => step(1);
|
||||
const canPrev = !!queue && stepIndex(queue, index, -1, dead) >= 0;
|
||||
const canNext = !!queue && stepIndex(queue, index, 1, dead) >= 0;
|
||||
// Off with items still left that way: only ones YouTube refused are there, which the counter alone
|
||||
// does not tell. At the queue's ends the counter does.
|
||||
const prevReason = queue && index > 0 ? t("player.noPlayableBefore") : undefined;
|
||||
const nextReason = queue && index < queue.length - 1 ? t("player.noPlayableAfter") : undefined;
|
||||
// "Remove from this playlist", offered right where you are watching the thing you want gone. The
|
||||
// parent owns the removal (API + caches + the toast whose Undo puts it back); the player owns what happens NEXT, because it
|
||||
// is the only side that knows which item is playing — its queue is frozen at mount, so the parent
|
||||
// cannot tell. Step to the item that followed (clamped when the last one goes), and close when the
|
||||
// list runs out.
|
||||
// cannot tell. Step to the item that followed (the one before when the last one goes), passing over
|
||||
// the ones known unplayable, and close when the list runs out.
|
||||
//
|
||||
// Guarded against a double press by a REF, not by disabling the button: a trigger disabled for the
|
||||
// removal's lifetime was still disabled when focus came back to it (it once waited on a confirm) —
|
||||
@@ -723,19 +728,19 @@ export default function PlayerModal({
|
||||
// Decide from where the player is NOW, not where it was when the button was pressed: prev/next
|
||||
// and auto-advance keep working while the request runs, and a cursor from before the await
|
||||
// threw the viewer back onto a video they had already moved past.
|
||||
const { queue: q, index: i } = navRef.current;
|
||||
const { queue: q, index: i, dead: d } = navRef.current;
|
||||
if ((q?.[i] ?? video).id !== victim.id) {
|
||||
// They moved on — take the removed item out of the queue and leave playback where it is.
|
||||
setQueue((cur) => cur?.filter((v) => v.id !== victim.id));
|
||||
return;
|
||||
}
|
||||
const next = afterRemoval(q, i, victim.id);
|
||||
const next = afterRemoval(q, i, victim.id, d);
|
||||
if (!next) {
|
||||
onClose(); // that was the last one
|
||||
return;
|
||||
}
|
||||
setQueue(next.queue);
|
||||
stepTo(next.playingId, 1); // the item that followed: a dead one there carries on forward
|
||||
stepTo(next.playingId, next.way); // one that cannot play after all carries on the same way
|
||||
};
|
||||
|
||||
// Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
|
||||
@@ -830,6 +835,9 @@ export default function PlayerModal({
|
||||
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
|
||||
// be refused.
|
||||
const relatedOn = useMe().related_enabled;
|
||||
// A video reset from the related list starts over: the position this session stored for it would
|
||||
// otherwise resume a plain pick there, and the player's close would patch it back into the lists.
|
||||
const forgetPosition = (id: string) => void sessionPosRef.current.delete(id);
|
||||
const playRelated = (v: Video, startAt: number | null) => {
|
||||
setRelatedOpen(false);
|
||||
// The video already playing (its row stays in the list): a new player for the same id would
|
||||
@@ -1208,8 +1216,10 @@ export default function PlayerModal({
|
||||
>
|
||||
{/* Glyphs only, each named by its label and tooltip. The controls scroll when a
|
||||
narrow window cannot hold them; the way out (fullscreen, leave the big view)
|
||||
stays outside that part, in sight: on an iPad it had scrolled off the right. */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-x-3 overflow-x-auto">
|
||||
stays outside that part, in sight: on an iPad it had scrolled off the right.
|
||||
The bar's full height and a little side room: a scroller clips what overflows
|
||||
it, the focus ring drawn outside each control included. */}
|
||||
<div className="-mx-1 flex min-w-0 flex-1 items-center gap-x-3 self-stretch overflow-x-auto px-1">
|
||||
{hasQueue && (
|
||||
<>
|
||||
<PlayerTransportControls
|
||||
@@ -1218,6 +1228,8 @@ export default function PlayerModal({
|
||||
queueLength={queue!.length}
|
||||
canPrev={canPrev}
|
||||
canNext={canNext}
|
||||
prevReason={prevReason}
|
||||
nextReason={nextReason}
|
||||
autoMode={autoMode}
|
||||
loopMode={loopMode}
|
||||
onPrev={goPrev}
|
||||
@@ -1372,18 +1384,18 @@ export default function PlayerModal({
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={goPrev}
|
||||
disabled={!canPrev}
|
||||
{...unavailable(!canPrev, prevReason)}
|
||||
label={t("player.previous")}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
title={`${t("player.previous")} · Shift+←${!canPrev && prevReason ? ` · ${prevReason}` : ""}`}
|
||||
>
|
||||
<SkipBack />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={goNext}
|
||||
disabled={!canNext}
|
||||
{...unavailable(!canNext, nextReason)}
|
||||
label={t("player.next")}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
title={`${t("player.next")} · Shift+→${!canNext && nextReason ? ` · ${nextReason}` : ""}`}
|
||||
>
|
||||
<SkipForward />
|
||||
</IconButton>
|
||||
@@ -1419,6 +1431,8 @@ export default function PlayerModal({
|
||||
queueLength={queue!.length}
|
||||
canPrev={canPrev}
|
||||
canNext={canNext}
|
||||
prevReason={prevReason}
|
||||
nextReason={nextReason}
|
||||
autoMode={autoMode}
|
||||
loopMode={loopMode}
|
||||
onPrev={goPrev}
|
||||
@@ -1467,7 +1481,12 @@ export default function PlayerModal({
|
||||
maxWidth="max-w-sm"
|
||||
initialFocus="close"
|
||||
>
|
||||
<RelatedVideos videoId={active.id} onPlay={playRelated} onWrite={onWrite} />
|
||||
<RelatedVideos
|
||||
videoId={active.id}
|
||||
onPlay={playRelated}
|
||||
onWrite={onWrite}
|
||||
onReset={forgetPosition}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{(navigated || related) && (
|
||||
@@ -1723,6 +1742,7 @@ export default function PlayerModal({
|
||||
videoId={active.id}
|
||||
onPlay={playRelated}
|
||||
onWrite={onWrite}
|
||||
onReset={forgetPosition}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -54,12 +54,15 @@ export function RelatedVideos({
|
||||
videoId,
|
||||
onPlay,
|
||||
onWrite,
|
||||
onReset,
|
||||
}: {
|
||||
videoId: string;
|
||||
/** `startAt` null resumes the video where it was left; a number (Restart's 0) starts there. */
|
||||
onPlay: (v: Video, startAt: number | null) => void;
|
||||
/** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
|
||||
onWrite?: (write: Promise<unknown>) => void;
|
||||
/** A video's view was reset from its row: the host forgets where its session left it. */
|
||||
onReset?: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -107,6 +110,7 @@ export function RelatedVideos({
|
||||
};
|
||||
|
||||
const onResetState = (id: string) => {
|
||||
onReset?.(id);
|
||||
const seq = begin(id, { status: "new", position_seconds: 0 });
|
||||
const write = resetVideoState(qc, id);
|
||||
onWrite?.(write);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { VideoDetail } from "../../lib/api";
|
||||
import type { AutoMode, LoopMode } from "../../lib/playerModes";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { StateMessage } from "../../components/ui/QueryState";
|
||||
import { unavailable } from "../../components/ui/unavailable";
|
||||
import { ClampedText } from "../../components/ui/ClampedText";
|
||||
import { renderDescription } from "./descriptionLinks";
|
||||
|
||||
@@ -28,6 +29,8 @@ export function PlayerTransportControls({
|
||||
queueLength,
|
||||
canPrev,
|
||||
canNext,
|
||||
prevReason,
|
||||
nextReason,
|
||||
autoMode,
|
||||
loopMode,
|
||||
onPrev,
|
||||
@@ -41,6 +44,11 @@ export function PlayerTransportControls({
|
||||
/** Whether there is a playable item that way (one YouTube refused is passed over). */
|
||||
canPrev: boolean;
|
||||
canNext: boolean;
|
||||
/** Why Previous / Next are off when items are left that way (only ones YouTube refused): the
|
||||
* button stays focusable and its tooltip says so. Unset at the queue's ends, which explain
|
||||
* themselves. */
|
||||
prevReason?: string;
|
||||
nextReason?: string;
|
||||
autoMode: AutoMode;
|
||||
loopMode: LoopMode;
|
||||
onPrev: () => void;
|
||||
@@ -62,6 +70,7 @@ export function PlayerTransportControls({
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
reason?: string;
|
||||
lit?: boolean;
|
||||
};
|
||||
const step: Control[] = [
|
||||
@@ -72,6 +81,7 @@ export function PlayerTransportControls({
|
||||
title: `${t("player.previous")} · Shift+←`,
|
||||
onClick: onPrev,
|
||||
disabled: !canPrev,
|
||||
reason: canPrev ? undefined : prevReason,
|
||||
},
|
||||
{
|
||||
key: "next",
|
||||
@@ -80,6 +90,7 @@ export function PlayerTransportControls({
|
||||
title: `${t("player.next")} · Shift+→`,
|
||||
onClick: onNext,
|
||||
disabled: !canNext,
|
||||
reason: canNext ? undefined : nextReason,
|
||||
},
|
||||
];
|
||||
// Persistent playback settings — cycle on click, saved to your account.
|
||||
@@ -107,10 +118,10 @@ export function PlayerTransportControls({
|
||||
key={c.key}
|
||||
variant={docked ? "overlay" : undefined}
|
||||
onClick={c.onClick}
|
||||
disabled={c.disabled}
|
||||
{...unavailable(c.disabled, c.reason)}
|
||||
lit={c.lit}
|
||||
label={c.name}
|
||||
title={c.title}
|
||||
title={c.reason ? `${c.title} · ${c.reason}` : c.title}
|
||||
>
|
||||
{c.glyph}
|
||||
</IconButton>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"saveForLater": "Save for later",
|
||||
"unhide": "Unhide",
|
||||
"hide": "Hide",
|
||||
"resetState": "Reset view",
|
||||
"resetState": "Clear watch progress",
|
||||
"thisChannel": "This channel",
|
||||
"continueTitle": "Continue where you left off",
|
||||
"continue": "Continue",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"back": "Back",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"noPlayableBefore": "No video before this one can play here",
|
||||
"noPlayableAfter": "No video after this one can play here",
|
||||
"autoAdvance": {
|
||||
"label": "Auto",
|
||||
"hint": "When a video ends, what plays next. Click to cycle Off, Next, Previous, Random. Saved to your account.",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"back": "Vissza",
|
||||
"previous": "Előző",
|
||||
"next": "Következő",
|
||||
"noPlayableBefore": "Előtte nincs itt lejátszható videó",
|
||||
"noPlayableAfter": "Utána nincs itt lejátszható videó",
|
||||
"autoAdvance": {
|
||||
"label": "Automatikus",
|
||||
"hint": "A videó végén mi jöjjön. Kattintásra vált: Ki, Következő, Előző, Véletlen. A fiókodhoz mentve.",
|
||||
|
||||
@@ -10,6 +10,27 @@ const v = (id: string): Video => ({ id }) as Video;
|
||||
const ids = (q: Video[]) => q.map((x) => x.id);
|
||||
|
||||
describe("afterRemoval", () => {
|
||||
it("passes over a follower known dead, on the way it went", () => {
|
||||
const q = [v("a"), v("b"), v("c"), v("d")];
|
||||
const next = afterRemoval(q, 1, "b", new Set(["c"]));
|
||||
expect(next).toMatchObject({ playingId: "d", way: 1 });
|
||||
});
|
||||
|
||||
it("goes back over the dead ones when nothing playable followed", () => {
|
||||
const q = [v("a"), v("b"), v("c")];
|
||||
expect(afterRemoval(q, 2, "c", new Set(["b"]))).toMatchObject({ playingId: "a", way: -1 });
|
||||
// The last one going, nothing dead: the one before it, and that is a step back.
|
||||
expect(afterRemoval(q, 2, "c")).toMatchObject({ playingId: "b", way: -1 });
|
||||
});
|
||||
|
||||
it("with none left that can play, takes the follower all the same", () => {
|
||||
const q = [v("a"), v("b"), v("c")];
|
||||
expect(afterRemoval(q, 0, "a", new Set(["b", "c"]))).toMatchObject({
|
||||
playingId: "b",
|
||||
way: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("plays the item that followed the one you removed", () => {
|
||||
const q = [v("a"), v("b"), v("c")];
|
||||
const next = afterRemoval(q, 1, "b");
|
||||
|
||||
@@ -12,21 +12,30 @@ export interface AfterRemoval {
|
||||
queue: Video[];
|
||||
/** The item to play next. */
|
||||
playingId: string;
|
||||
/** Which way that item lies from the removed one: what it carries on along if it cannot play. */
|
||||
way: 1 | -1;
|
||||
}
|
||||
|
||||
/** `null` means nothing is left — the caller closes the player.
|
||||
*
|
||||
* The next item is the one that FOLLOWED the removed one (that is what "delete and keep going"
|
||||
* means when you are working down a list), clamped to the end when the last item goes. */
|
||||
* means when you are working down a list), the one before it when the last item goes. A video known
|
||||
* to be unplayable (`dead`, see `stepIndex`) is passed over, not loaded again: the nearest playable
|
||||
* one after, else before. None playable: the follower all the same, where the player shows why. */
|
||||
export function afterRemoval(
|
||||
queue: Video[] | undefined,
|
||||
index: number,
|
||||
victimId: string,
|
||||
dead: ReadonlySet<string> = new Set(),
|
||||
): AfterRemoval | null {
|
||||
const rest = (queue ?? []).filter((v) => v.id !== victimId);
|
||||
if (rest.length === 0) return null;
|
||||
const next = rest[Math.min(Math.max(index, 0), rest.length - 1)]!;
|
||||
return { queue: rest, playingId: next.id };
|
||||
// The removed item's slot: what followed it now stands there (past the end when it was the last).
|
||||
const at = Math.min(Math.max(index, 0), rest.length);
|
||||
const pick = (j: number, way: 1 | -1) => ({ queue: rest, playingId: rest[j]!.id, way });
|
||||
for (let j = at; j < rest.length; j++) if (!dead.has(rest[j]!.id)) return pick(j, 1);
|
||||
for (let j = at - 1; j >= 0; j--) if (!dead.has(rest[j]!.id)) return pick(j, -1);
|
||||
return at < rest.length ? pick(at, 1) : pick(rest.length - 1, -1);
|
||||
}
|
||||
|
||||
/** The nearest item from `index` going `dir` that is not known to be unplayable, or -1 when there is
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//
|
||||
// The About cell's card opened the instant a pointer crossed the cell, so scrolling the table over
|
||||
// a column of them popped card after card. It waits for the pointer to rest on the cell now, and a
|
||||
// scroll puts an open one away.
|
||||
// scroll puts an open one away — unless the keyboard's focus opened it, whose own scroll into view
|
||||
// would put it away the moment it opened.
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -66,4 +67,14 @@ describe("AboutCell — the hover card", () => {
|
||||
await wait(50);
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it("stays when the focus that opened it scrolls its cell into view", async () => {
|
||||
const cell = mount();
|
||||
act(() => cell.focus());
|
||||
await wait(10);
|
||||
expect(card()).not.toBeNull();
|
||||
act(() => void window.dispatchEvent(new Event("scroll")));
|
||||
await wait(50);
|
||||
expect(card()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,17 +25,23 @@ const ABOUT_REST_MS = 500;
|
||||
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
|
||||
const preview = (text ?? "").trim();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Opened by the keyboard's focus: the scroll that brings a focused cell into view is that focus's
|
||||
// own, and would put the card away the moment it opened — so only a pointer's card goes on a scroll.
|
||||
const [byFocus, setByFocus] = useState(false);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open: open && !!preview,
|
||||
onOpenChange: setOpen,
|
||||
onOpenChange: (next, _event, reason) => {
|
||||
setByFocus(next && reason === "focus");
|
||||
setOpen(next);
|
||||
},
|
||||
mode: "hover",
|
||||
offset: 6,
|
||||
// Opens once the pointer rests on the cell, not as it crosses one: scrolling the table under a
|
||||
// still pointer, or moving down the column, used to pop card after card. The close grace lets
|
||||
// the cursor reach the card; a scroll puts an open one away.
|
||||
hoverDelay: { open: ABOUT_REST_MS, close: 160 },
|
||||
dismissOnScroll: true,
|
||||
dismissOnScroll: !byFocus,
|
||||
// Cap the panel to the space between the trigger and the viewport edge (publishes --fit-max-h) so a
|
||||
// long About never runs its top off-screen — at high browser zoom the short viewport made a fixed
|
||||
// max-height overflow above the top edge, where the scroll container can't reach.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// "Reset view" on a half-watched video. The optimistic layer overrode only
|
||||
// "Clear watch progress" on a half-watched video. The optimistic layer overrode only
|
||||
// the STATUS, so the card kept its resume position — its progress bar and its Continue button — until
|
||||
// the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed
|
||||
// hands it: each video's position, and a button that fires the reset.
|
||||
|
||||
@@ -85,6 +85,8 @@ async function renderFeed(channelScoped = false) {
|
||||
const trigger = (name: string) =>
|
||||
screen.getAllByRole("button").find((b) => b.getAttribute("aria-label")?.startsWith(`${name}:`))!;
|
||||
const sortTrigger = () => trigger("feed.sortLabel");
|
||||
// The group's glyph, not the trigger's own chevron: the glyph sits in its aria-hidden wrapper.
|
||||
const glyph = (el: HTMLElement) => el.querySelector('[aria-hidden="true"] > svg');
|
||||
|
||||
describe("Feed toolbar", () => {
|
||||
it.each([
|
||||
@@ -94,7 +96,7 @@ describe("Feed toolbar", () => {
|
||||
await renderFeed();
|
||||
const pill = screen.getByTestId(id);
|
||||
expect(pill.textContent).not.toContain(word);
|
||||
expect(pill.querySelector("svg")).not.toBeNull();
|
||||
expect(glyph(pill)).not.toBeNull();
|
||||
expect(pill.getAttribute("title")).toBe(word);
|
||||
expect(pill.getAttribute("aria-label")).toMatch(new RegExp(`^${word}: `));
|
||||
});
|
||||
@@ -103,6 +105,7 @@ describe("Feed toolbar", () => {
|
||||
await renderFeed();
|
||||
const sort = sortTrigger();
|
||||
expect(sort.textContent).not.toContain("feed.sortLabel");
|
||||
expect(glyph(sort)).not.toBeNull();
|
||||
expect(sort.getAttribute("title")).toBe("feed.sortLabel");
|
||||
});
|
||||
|
||||
|
||||
@@ -1433,6 +1433,24 @@ describe("Files · a press on the row plays it", () => {
|
||||
expect(screen.queryByText("playing 1")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens nothing for the click on the row that ends its rename", async () => {
|
||||
mocks.files = [file(1)];
|
||||
mocks.folders = [YT];
|
||||
mount();
|
||||
const cell = sizeCellOf("Clip 1");
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 1")));
|
||||
fireEvent.click(menuItem("files.actions.rename"));
|
||||
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
|
||||
// A click elsewhere on the row: the press begins with the field focused, its mousedown blurs
|
||||
// the field (which saves the name), and the click lands on the row after that.
|
||||
fireEvent.pointerDown(cell);
|
||||
act(() => field.blur());
|
||||
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
|
||||
fireEvent.click(cell, { detail: 1 });
|
||||
await act(async () => {});
|
||||
expect(screen.queryByText("playing 1")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens nothing for the click that ends selecting the row's words", async () => {
|
||||
mocks.files = [file(1)];
|
||||
mocks.folders = [YT];
|
||||
|
||||
@@ -53,6 +53,8 @@ async function mount() {
|
||||
return screen.findByTestId("plex-show");
|
||||
}
|
||||
|
||||
// The group's glyph, not the trigger's own chevron: the glyph sits in its aria-hidden wrapper.
|
||||
const glyph = (el: HTMLElement) => el.querySelector('[aria-hidden="true"] > svg');
|
||||
const sortTrigger = () =>
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
@@ -63,7 +65,7 @@ describe("PlexBrowse toolbar", () => {
|
||||
const pill = await mount();
|
||||
// The key of a value ("plex.filter.showOpt.all") holds the group's key: test the visible text.
|
||||
expect(pill.textContent).toBe("plex.filter.showOpt.all");
|
||||
expect(pill.querySelector("svg")).not.toBeNull();
|
||||
expect(glyph(pill)).not.toBeNull();
|
||||
expect(pill.getAttribute("title")).toBe("plex.filter.show");
|
||||
expect(pill.getAttribute("aria-label")).toMatch(/^plex\.filter\.show: /);
|
||||
});
|
||||
@@ -72,6 +74,7 @@ describe("PlexBrowse toolbar", () => {
|
||||
const pill = await mount();
|
||||
const sort = sortTrigger();
|
||||
expect(sort.textContent).not.toContain("plex.filter.sort");
|
||||
expect(glyph(sort)).not.toBeNull();
|
||||
expect(sort.getAttribute("title")).toBe("plex.filter.sort");
|
||||
expect(pill.parentElement!.contains(sort)).toBe(true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user