fix: review round 2 — Clear watch progress clears only the resume point, big view focus by the way it arrived, dialog name, reason tooltip, backfill locks
This commit is contained in:
@@ -89,14 +89,17 @@ def _retitle(db: Session, a: MediaAsset, adapter: adapters.SourceAdapter, url: s
|
|||||||
named = normalize_title(adapter.title(info))
|
named = normalize_title(adapter.title(info))
|
||||||
if not named or adapter.is_placeholder_title(named):
|
if not named or adapter.is_placeholder_title(named):
|
||||||
return False
|
return False
|
||||||
# One conditional write, not a read and a compare: a name its owner gives while this runs (the
|
# One conditional write, not a read and a compare: a name its owner gives while the page is read
|
||||||
# poster step can take seconds before the commit) is not overwritten by what was read before it.
|
# is not overwritten by what was read before it. The asset first, as every other writer of the
|
||||||
|
# two locks them (`service.lock_own_job`): the session does not flush by itself.
|
||||||
|
placeholder = a.title
|
||||||
|
a.title = named
|
||||||
|
db.flush()
|
||||||
db.execute(
|
db.execute(
|
||||||
update(DownloadJob)
|
update(DownloadJob)
|
||||||
.where(DownloadJob.asset_id == a.id, DownloadJob.display_name == a.title)
|
.where(DownloadJob.asset_id == a.id, DownloadJob.display_name == placeholder)
|
||||||
.values(display_name=named[:DISPLAY_TEXT_MAX])
|
.values(display_name=named[:DISPLAY_TEXT_MAX])
|
||||||
)
|
)
|
||||||
a.title = named
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +130,9 @@ def run() -> dict:
|
|||||||
if adapter.wants_metadata_backfill():
|
if adapter.wants_metadata_backfill():
|
||||||
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
|
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
|
||||||
if _retitle(db, a, adapter, url):
|
if _retitle(db, a, adapter, url):
|
||||||
|
# Before the poster step: it can cut a frame for 30 s, and these locks held
|
||||||
|
# across it block a rename and deadlock a delete.
|
||||||
|
db.commit()
|
||||||
retitled += 1
|
retitled += 1
|
||||||
log.info("asset %s retitled", a.id) # the title names what they downloaded
|
log.info("asset %s retitled", a.id) # the title names what they downloaded
|
||||||
if a.poster_path is None:
|
if a.poster_path is None:
|
||||||
|
|||||||
@@ -255,17 +255,6 @@ def mark(db: Session, user: User, video_id: str, status: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def clear_state(db: Session, user: User, video_id: str) -> None:
|
|
||||||
"""The video back to pristine for this user: status, resume point and `watched_at` all go.
|
|
||||||
Playlist membership lives elsewhere and stays."""
|
|
||||||
lock_takeout(db, user) # as `unmark`
|
|
||||||
row = queries.video_state(db, user, video_id)
|
|
||||||
if row is not None:
|
|
||||||
db.delete(row)
|
|
||||||
_remember_unmark(db, user, video_id)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
|
def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
|
||||||
"""The player's resume point. 0 clears it: a row holding nothing else goes, one with a status
|
"""The player's resume point. 0 clears it: a row holding nothing else goes, one with a status
|
||||||
(watched, hidden) keeps it with the position zeroed. The status itself is never changed here.
|
(watched, hidden) keeps it with the position zeroed. The status itself is never changed here.
|
||||||
|
|||||||
@@ -204,22 +204,21 @@ def set_video_state(
|
|||||||
return {"ok": True, "video_id": video_id, "status": status}
|
return {"ok": True, "video_id": video_id, "status": status}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/videos/{video_id}/state")
|
@router.delete("/videos/{video_id}/progress")
|
||||||
def clear_video_state(
|
def clear_video_progress(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Reset a video to pristine for this user — drop the whole VideoState row (status,
|
"""Forget where the user left a video: its resume point goes, its status (watched, hidden) stays.
|
||||||
watch position and watched_at), as if it had never been opened. Saved/playlist membership
|
Saved/playlist membership lives elsewhere and is untouched."""
|
||||||
lives elsewhere and is untouched."""
|
|
||||||
try:
|
try:
|
||||||
service.clear_state(db, user, video_id)
|
service.set_progress(db, user, video_id, 0)
|
||||||
db.commit()
|
db.commit()
|
||||||
# silent-ok: the row went under the delete, which is the outcome the delete wanted.
|
# silent-ok: the row went under the write, which is the outcome the write wanted.
|
||||||
except StaleDataError:
|
except StaleDataError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
|
return {"ok": True, "video_id": video_id, "position_seconds": 0}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/{video_id}/progress")
|
@router.post("/videos/{video_id}/progress")
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ 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.
|
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 select, update
|
||||||
|
|
||||||
from sqlalchemy import update
|
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
@@ -149,8 +147,8 @@ def test_the_backfill_retitles_a_placeholder_download_and_the_names_that_repeate
|
|||||||
|
|
||||||
|
|
||||||
def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch):
|
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
|
# The owner renames the download while the backfill reads the page: the owner's name wins, it is
|
||||||
# sits between them): the owner's name wins, it is not overwritten by what was read before it.
|
# not overwritten by what was read before it.
|
||||||
_fake_ydl(monkeypatch, _PUBLIC)
|
_fake_ydl(monkeypatch, _PUBLIC)
|
||||||
owner = make_user()
|
owner = make_user()
|
||||||
asset = MediaAsset(
|
asset = MediaAsset(
|
||||||
@@ -178,7 +176,9 @@ def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch):
|
|||||||
db.add(job)
|
db.add(job)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
def rename():
|
read_page = backfill.public_title_info
|
||||||
|
|
||||||
|
def renamed_while_reading(*args):
|
||||||
with SessionLocal() as other:
|
with SessionLocal() as other:
|
||||||
other.execute(
|
other.execute(
|
||||||
update(DownloadJob)
|
update(DownloadJob)
|
||||||
@@ -186,27 +186,64 @@ def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch):
|
|||||||
.values(display_name="Office jokes")
|
.values(display_name="Office jokes")
|
||||||
)
|
)
|
||||||
other.commit()
|
other.commit()
|
||||||
|
return read_page(*args)
|
||||||
|
|
||||||
def poster_while_renamed(a, root):
|
monkeypatch.setattr(backfill, "public_title_info", renamed_while_reading)
|
||||||
# A rename that must wait for the backfill's write waits here, and lands after its commit.
|
monkeypatch.setattr(backfill.editmod, "ensure_poster", lambda a, root: None)
|
||||||
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
|
assert backfill.run()["retitled"] == 1
|
||||||
for t in renamers:
|
|
||||||
t.join(timeout=10)
|
|
||||||
assert not t.is_alive()
|
|
||||||
|
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
assert db.get(DownloadJob, job.id).display_name == "Office jokes"
|
assert db.get(DownloadJob, job.id).display_name == "Office jokes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_poster_step_holds_no_row_lock(db, make_user, monkeypatch):
|
||||||
|
# It can cut a frame for 30 s. A delete locks the asset and then the job: with the job held here
|
||||||
|
# and the asset still to write, that is a deadlock, and a rename waits out the whole cut.
|
||||||
|
_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()
|
||||||
|
asset_id, job_id = asset.id, job.id
|
||||||
|
|
||||||
|
def poster_finds_rows_free(a, root):
|
||||||
|
with SessionLocal() as other:
|
||||||
|
other.execute(
|
||||||
|
select(MediaAsset).where(MediaAsset.id == asset_id).with_for_update(nowait=True)
|
||||||
|
)
|
||||||
|
other.execute(
|
||||||
|
select(DownloadJob).where(DownloadJob.id == job_id).with_for_update(nowait=True)
|
||||||
|
)
|
||||||
|
other.rollback()
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(backfill.editmod, "ensure_poster", poster_finds_rows_free)
|
||||||
|
|
||||||
|
assert backfill.run()["retitled"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_a_download_titled_facebook_is_named_from_the_signed_out_page(
|
def test_a_download_titled_facebook_is_named_from_the_signed_out_page(
|
||||||
db, make_user, monkeypatch, tmp_path
|
db, make_user, monkeypatch, tmp_path
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ def test_clearing_a_video_leaves_another_users_state(db, make_user, as_user):
|
|||||||
_videos(db, "v1")
|
_videos(db, "v1")
|
||||||
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
|
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
|
||||||
db.commit()
|
db.commit()
|
||||||
assert as_user(me).delete("/api/videos/v1/state").status_code == 200
|
assert as_user(me).delete("/api/videos/v1/progress").status_code == 200
|
||||||
assert _video_state(db, other) == ("hidden", 90)
|
assert _video_state(db, other) == ("hidden", 90)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -190,9 +190,6 @@ def test_a_cleared_resume_point_drops_a_bare_row_and_zeroes_one_with_a_status(db
|
|||||||
db.commit()
|
db.commit()
|
||||||
assert _state(db, user, "v1") is None
|
assert _state(db, user, "v1") is None
|
||||||
assert (_state(db, user, "v2").status, _state(db, user, "v2").position_seconds) == ("hidden", 0)
|
assert (_state(db, user, "v2").status, _state(db, user, "v2").position_seconds) == ("hidden", 0)
|
||||||
service.clear_state(db, user, "v2")
|
|
||||||
db.commit()
|
|
||||||
assert _state(db, user, "v2") is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- playlists -------------------------------------------------------------------------------
|
# --- playlists -------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -101,8 +101,10 @@ def test_a_video_state_cleared_through_the_route_is_gone(db, make_user, as_user)
|
|||||||
_video(db)
|
_video(db)
|
||||||
db.add(VideoState(user_id=user.id, video_id="v1", status="hidden", position_seconds=90))
|
db.add(VideoState(user_id=user.id, video_id="v1", status="hidden", position_seconds=90))
|
||||||
db.commit()
|
db.commit()
|
||||||
assert as_user(user).delete("/api/videos/v1/state").status_code == 200
|
assert as_user(user).delete("/api/videos/v1/progress").status_code == 200
|
||||||
assert _state(db, user) is None
|
db.expire_all()
|
||||||
|
row = _state(db, user)
|
||||||
|
assert (row.status, row.position_seconds) == ("hidden", 0)
|
||||||
|
|
||||||
|
|
||||||
# --- saved views ------------------------------------------------------------------------------
|
# --- saved views ------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -292,17 +292,16 @@ def test_a_known_video_is_marked_watched_at_its_takeout_time_and_the_rest_wait_o
|
|||||||
assert (states["older000000"].status, states["older000000"].position_seconds) == ("watched", 40)
|
assert (states["older000000"].status, states["older000000"].position_seconds) == ("watched", 40)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("undo", ["unmark", "clear_state"])
|
def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user):
|
||||||
def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user, undo):
|
"""Un-marking a video leaves no state row, and the import inserted a watched one over the user's
|
||||||
"""Un-marking (or resetting) a video leaves no state row, and the import inserted a watched one
|
own choice. A view on YouTube after the un-mark is a new view, and counts."""
|
||||||
over the user's own choice. A view on YouTube after the un-mark is a new view, and counts."""
|
user = make_user("unmark@example.com")
|
||||||
user = make_user(f"{undo}@example.com")
|
|
||||||
for vid in ("undone00000", "again000000"):
|
for vid in ("undone00000", "again000000"):
|
||||||
_video(db, vid)
|
_video(db, vid)
|
||||||
db.commit()
|
db.commit()
|
||||||
for vid in ("undone00000", "again000000"):
|
for vid in ("undone00000", "again000000"):
|
||||||
service.mark(db, user, vid, "watched")
|
service.mark(db, user, vid, "watched")
|
||||||
getattr(service, undo)(db, user, vid)
|
service.unmark(db, user, vid)
|
||||||
db.commit()
|
db.commit()
|
||||||
assert _states(db, user) == {}
|
assert _states(db, user) == {}
|
||||||
later = datetime.now(timezone.utc) + timedelta(minutes=5)
|
later = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||||
@@ -603,7 +602,6 @@ def test_staging_and_settling_take_the_users_one_lock(db, make_user):
|
|||||||
),
|
),
|
||||||
# Between the import's read of the un-marks and its marking, one would be overwritten.
|
# Between the import's read of the un-marks and its marking, one would be overwritten.
|
||||||
"unmark": lambda s, u: service.unmark(s, u, "aaaaaaaaaaa"),
|
"unmark": lambda s, u: service.unmark(s, u, "aaaaaaaaaaa"),
|
||||||
"reset": lambda s, u: service.clear_state(s, u, "aaaaaaaaaaa"),
|
|
||||||
}
|
}
|
||||||
with SessionLocal() as holder:
|
with SessionLocal() as holder:
|
||||||
service.lock_takeout(holder, holder.get(User, user.id))
|
service.lock_takeout(holder, holder.get(User, user.id))
|
||||||
|
|||||||
+2
-1
@@ -256,7 +256,7 @@ Also in force:
|
|||||||
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
|
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
|
||||||
refresh arrow (`RefreshCw`), and so is doing a download again; a reset or revert is the
|
refresh arrow (`RefreshCw`), and so is doing a download again; a reset or revert is the
|
||||||
counter-clockwise arrow (`RotateCcw`), and so is playing from the beginning, on the card and in its
|
counter-clockwise arrow (`RotateCcw`), and so is playing from the beginning, on the card and in its
|
||||||
menu alike; clearing a video's progress and status is the eraser (`Eraser`). A failed download's Retry
|
menu alike; clearing a video's watch progress is the eraser (`Eraser`). A failed download's Retry
|
||||||
had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous /
|
had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous /
|
||||||
Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous,
|
Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous,
|
||||||
`Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the
|
`Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the
|
||||||
@@ -833,3 +833,4 @@ of a full-width panel and nothing on the right.
|
|||||||
| 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 | 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 |
|
| 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 |
|
||||||
|
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own |
|
||||||
|
|||||||
@@ -11,18 +11,22 @@ type Side = "top" | "bottom" | "left" | "right";
|
|||||||
* is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room
|
* is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room
|
||||||
* and slides back inside the viewport near an edge.
|
* and slides back inside the viewport near an edge.
|
||||||
* `always` bypasses the hints toggle (for a label that IS the primary affordance, e.g. the slim nav
|
* `always` bypasses the hints toggle (for a label that IS the primary affordance, e.g. the slim nav
|
||||||
* rail's flyout names); `openDelay` waits before showing (a rest-on-the-icon feel). */
|
* rail's flyout names); `openDelay` waits before showing (a rest-on-the-icon feel); `stable` keeps
|
||||||
|
* the wrapper with no hint to show, so a hint that comes and goes does not remount the element and
|
||||||
|
* cost it the focus. */
|
||||||
export default function Tooltip({
|
export default function Tooltip({
|
||||||
hint,
|
hint,
|
||||||
side = "top",
|
side = "top",
|
||||||
always = false,
|
always = false,
|
||||||
openDelay = 0,
|
openDelay = 0,
|
||||||
|
stable = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
hint: string;
|
hint: string;
|
||||||
side?: Side;
|
side?: Side;
|
||||||
always?: boolean;
|
always?: boolean;
|
||||||
openDelay?: number;
|
openDelay?: number;
|
||||||
|
stable?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const hintsOn = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
const hintsOn = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
||||||
@@ -42,7 +46,7 @@ export default function Tooltip({
|
|||||||
});
|
});
|
||||||
const { setReference } = pop.refs;
|
const { setReference } = pop.refs;
|
||||||
|
|
||||||
if (!enabled || !hint) return <>{children}</>;
|
if ((!enabled || !hint) && !stable) return <>{children}</>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
/** An unavailable control's props. With a reason it stays focusable (`aria-disabled`, which
|
/** 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
|
* `FocusSafeButton` keeps inert to a click, and the reason read out with its name; the caller shows
|
||||||
* with a reason stays focusable"); without one it is plainly off. */
|
* it in a tooltip — DESIGN.md "Unavailable with a reason stays focusable"); without one it is
|
||||||
|
* plainly off. */
|
||||||
export function unavailable(disabled: boolean | undefined, reason: string | undefined) {
|
export function unavailable(disabled: boolean | undefined, reason: string | undefined) {
|
||||||
if (!disabled) return {};
|
if (!disabled) return {};
|
||||||
return reason ? { "aria-disabled": true as const } : { disabled: true };
|
return reason
|
||||||
|
? { "aria-disabled": true as const, "aria-description": reason }
|
||||||
|
: { disabled: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1050,14 +1050,14 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
expect(lastStart()?.start ?? 0).toBe(0);
|
expect(lastStart()?.start ?? 0).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A reset from the related row is where it starts now: the position this session stored for it
|
// A clear 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.
|
// (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 () => {
|
it("wide: a related video cleared from its row starts at 0 on a plain pick", async () => {
|
||||||
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
||||||
video_id: id,
|
video_id: id,
|
||||||
position_seconds: id === "rel2" ? 500 : 77,
|
position_seconds: id === "rel2" ? 500 : 77,
|
||||||
}));
|
}));
|
||||||
const reset = vi.spyOn(api, "clearState").mockResolvedValue(undefined as never);
|
const reset = vi.spyOn(api, "clearProgress").mockResolvedValue(undefined as never);
|
||||||
clock.current = 77;
|
clock.current = 77;
|
||||||
await playRel2(() => Promise.resolve());
|
await playRel2(() => Promise.resolve());
|
||||||
clock.current = 500;
|
clock.current = 500;
|
||||||
@@ -1070,7 +1070,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen
|
screen
|
||||||
.queryAllByRole("menuitem", { hidden: true })
|
.queryAllByRole("menuitem", { hidden: true })
|
||||||
.find((b) => b.textContent === "card.resetState")!,
|
.find((b) => b.textContent === "card.clearProgress")!,
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(reset).toHaveBeenCalledWith("rel2"));
|
await waitFor(() => expect(reset).toHaveBeenCalledWith("rel2"));
|
||||||
await settle();
|
await settle();
|
||||||
@@ -1079,6 +1079,44 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
expect(lastStart()?.start ?? 0).toBe(0);
|
expect(lastStart()?.start ?? 0).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The queue was read when the player opened, and a video in it can be in the related list too: a
|
||||||
|
// step to it after the clear starts at 0, not at that older position, which its first checkpoint
|
||||||
|
// would then write back over the clear.
|
||||||
|
it("wide: a queue item cleared from its related row starts at 0 on a step to it", async () => {
|
||||||
|
wideWindow(true);
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
vi.spyOn(api, "related").mockResolvedValue({
|
||||||
|
items: [related("rel2", 120)],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
});
|
||||||
|
const clear = vi.spyOn(api, "clearProgress").mockResolvedValue(undefined as never);
|
||||||
|
const A = vid("A", 40);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal
|
||||||
|
video={A}
|
||||||
|
queue={[A, vid("rel2", 120)]}
|
||||||
|
startIndex={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
onState={() => Promise.resolve()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
act(() => playerEvents.onReady?.());
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
|
||||||
|
const links = await screen.findAllByRole("link", { name: "Video rel2" });
|
||||||
|
fireEvent.contextMenu(links[0]!.closest("[data-video-id]")!);
|
||||||
|
fireEvent.click(
|
||||||
|
screen
|
||||||
|
.queryAllByRole("menuitem", { hidden: true })
|
||||||
|
.find((b) => b.textContent === "card.clearProgress")!,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(clear).toHaveBeenCalledWith("rel2"));
|
||||||
|
await settle();
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||||
|
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
|
// 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.
|
// per video), so Restart there is a seek.
|
||||||
it("wide: Restart on the related video already playing seeks it to 0", async () => {
|
it("wide: Restart on the related video already playing seeks it to 0", async () => {
|
||||||
@@ -1702,13 +1740,14 @@ describe("PlayerModal — the docked bar's fullscreen", () => {
|
|||||||
// The big view covers the card, and its controls stayed in the Tab order under it: past the bar's
|
// The big view covers the card, and its controls stayed in the Tab order under it: past the bar's
|
||||||
// last button the focus went on, unseen, to the card's Previous, Download and Remove.
|
// last button the focus went on, unseen, to the card's Previous, Download and Remove.
|
||||||
describe("PlayerModal — the card under the big view", () => {
|
describe("PlayerModal — the card under the big view", () => {
|
||||||
const open = () => {
|
const open = (others: Video[] = []) => {
|
||||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
vi.spyOn(api, "saveProgress").mockResolvedValue({ video_id: "A", position_seconds: 0 });
|
||||||
const A = vid("A", 0);
|
const A = vid("A", 0);
|
||||||
renderModal(
|
renderModal(
|
||||||
<PlayerModal
|
<PlayerModal
|
||||||
video={A}
|
video={A}
|
||||||
queue={[A]}
|
queue={[A, ...others]}
|
||||||
startIndex={0}
|
startIndex={0}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
onState={() => Promise.resolve()}
|
onState={() => Promise.resolve()}
|
||||||
@@ -1737,16 +1776,68 @@ describe("PlayerModal — the card under the big view", () => {
|
|||||||
|
|
||||||
it("does not leave a mouse on the way out, where a Space would leave the big view", () => {
|
it("does not leave a mouse on the way out, where a Space would leave the big view", () => {
|
||||||
const enter = open();
|
const enter = open();
|
||||||
act(() => enter.focus());
|
|
||||||
const matches = vi.spyOn(enter, "matches").mockReturnValue(false);
|
const matches = vi.spyOn(enter, "matches").mockReturnValue(false);
|
||||||
fireEvent.click(enter);
|
act(() => enter.focus());
|
||||||
matches.mockRestore();
|
matches.mockRestore();
|
||||||
|
fireEvent.click(enter);
|
||||||
const card = screen.getByRole("dialog").querySelector<HTMLElement>("[tabindex='-1']");
|
const card = screen.getByRole("dialog").querySelector<HTMLElement>("[tabindex='-1']");
|
||||||
expect(document.activeElement).toBe(card);
|
expect(document.activeElement).toBe(card);
|
||||||
fireEvent.click(screen.getByLabelText("player.exitMaximize"));
|
fireEvent.click(screen.getByLabelText("player.exitMaximize"));
|
||||||
expect(document.activeElement).toBe(card);
|
expect(document.activeElement).toBe(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chrome turns a clicked button :focus-visible the moment a key is pressed, so the F that opens the
|
||||||
|
// big view cannot be what tells a mouse's focus from a key's.
|
||||||
|
it("keeps a click's focus a mouse's when F opens the view", () => {
|
||||||
|
open();
|
||||||
|
const clicked = screen.getByLabelText("player.markWatched");
|
||||||
|
const matches = vi.spyOn(clicked, "matches").mockReturnValue(false);
|
||||||
|
act(() => clicked.focus());
|
||||||
|
matches.mockRestore(); // from here it answers true, as Chrome does after the key
|
||||||
|
fireEvent.keyDown(window, { key: "f" });
|
||||||
|
const card = screen.getByRole("dialog").querySelector<HTMLElement>("[tabindex='-1']");
|
||||||
|
expect(document.activeElement).toBe(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A step to the queue's end in the big view turned the remembered Next off: it refuses the focus.
|
||||||
|
it("gives the card the focus when the control it came from has gone off", async () => {
|
||||||
|
open([vid("B", 0)]);
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
const next = screen.getByRole("button", { name: "player.next" });
|
||||||
|
act(() => next.focus());
|
||||||
|
fireEvent.keyDown(window, { key: "f" });
|
||||||
|
expect(document.activeElement).toBe(screen.getByLabelText("player.exitMaximize"));
|
||||||
|
fireEvent.keyDown(window, { key: "ArrowRight", shiftKey: true });
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
|
expect((next as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
const card = screen.getByRole("dialog").querySelector<HTMLElement>("[tabindex='-1']");
|
||||||
|
expect(document.activeElement).toBe(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The headings sit in the part that goes inert, and an inert element names nothing (measured in
|
||||||
|
// Chromium's accessibility tree; jsdom's would still give the name).
|
||||||
|
it("names the dialog from outside the part that goes inert", () => {
|
||||||
|
const enter = open();
|
||||||
|
const id = screen.getByRole("dialog").getAttribute("aria-labelledby")!;
|
||||||
|
fireEvent.click(enter);
|
||||||
|
const names = document.querySelectorAll(`[id="${id}"]`);
|
||||||
|
expect(names).toHaveLength(1);
|
||||||
|
expect(names[0]!.closest("[inert]")).toBeNull();
|
||||||
|
expect(names[0]!.textContent).toBe("Video A");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A divider squeezed to nothing where the bar's row overflows and scrolls.
|
||||||
|
it("keeps the bar's dividers when its row scrolls", () => {
|
||||||
|
fireEvent.click(open([vid("B", 0)]));
|
||||||
|
const scroller = screen
|
||||||
|
.getByRole("button", { name: "player.exitMaximize" })
|
||||||
|
.parentElement!.querySelector(".overflow-x-auto")!;
|
||||||
|
const dividers = scroller.querySelectorAll("span.w-px");
|
||||||
|
expect(dividers.length).toBeGreaterThan(1);
|
||||||
|
for (const d of dividers) expect(d.className).toContain("shrink-0");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not strand the focus on the page when the bar goes with it", () => {
|
it("does not strand the focus on the page when the bar goes with it", () => {
|
||||||
const enter = open();
|
const enter = open();
|
||||||
fireEvent.click(enter);
|
fireEvent.click(enter);
|
||||||
@@ -1864,6 +1955,19 @@ describe("PlayerModal — clip mode", () => {
|
|||||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves the focus in the panel when the view ends, whatever it came from", async () => {
|
||||||
|
open();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
act(() => screen.getByLabelText("player.markWatched").focus());
|
||||||
|
key("f");
|
||||||
|
key("c");
|
||||||
|
const panel = screen.getByTestId("clip-panel");
|
||||||
|
const inPanel = panel.querySelector<HTMLElement>("button:not([disabled])")!;
|
||||||
|
act(() => inPanel.focus());
|
||||||
|
key("f");
|
||||||
|
expect(document.activeElement).toBe(inPanel);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives the focus back to the scissors the user can see: in the maximised view, the bar's", async () => {
|
it("gives the focus back to the scissors the user can see: in the maximised view, the bar's", async () => {
|
||||||
open();
|
open();
|
||||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
@@ -1999,8 +2103,10 @@ describe("PlayerModal — stepping over a video that cannot play", () => {
|
|||||||
for (const b of prevs) {
|
for (const b of prevs) {
|
||||||
expect((b as HTMLButtonElement).disabled).toBe(false);
|
expect((b as HTMLButtonElement).disabled).toBe(false);
|
||||||
expect(b.getAttribute("aria-disabled")).toBe("true");
|
expect(b.getAttribute("aria-disabled")).toBe("true");
|
||||||
expect(b.getAttribute("title")).toContain("player.noPlayableBefore");
|
|
||||||
}
|
}
|
||||||
|
// Where a key's focus can read it: a browser's own title shows for a mouse alone.
|
||||||
|
act(() => prevs[0]!.focus());
|
||||||
|
expect(await screen.findByText(/player\.noPlayableBefore/)).toBeTruthy();
|
||||||
prev();
|
prev();
|
||||||
await settle();
|
await settle();
|
||||||
expect(lastStart()?.videoId).toBe("B");
|
expect(lastStart()?.videoId).toBe("B");
|
||||||
@@ -2009,6 +2115,24 @@ describe("PlayerModal — stepping over a video that cannot play", () => {
|
|||||||
expect(b.getAttribute("title")).not.toContain("player.noPlayableAfter");
|
expect(b.getAttribute("title")).not.toContain("player.noPlayableAfter");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Next that the queue's end turned off keeps the focus; a step back gives it a reason, and the
|
||||||
|
// button that holds the focus must be the same button afterwards.
|
||||||
|
it("keeps the focus on a control when its reason arrives", async () => {
|
||||||
|
await open(0);
|
||||||
|
next();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
const button = screen.getByRole("button", { name: "player.next" });
|
||||||
|
act(() => button.focus());
|
||||||
|
next();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||||
|
fail();
|
||||||
|
await settle();
|
||||||
|
prev();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
expect(button.getAttribute("aria-disabled")).toBe("true");
|
||||||
|
expect(document.activeElement).toBe(button);
|
||||||
|
});
|
||||||
|
|
||||||
it("Previous at the queue's start is plainly off: the counter says why", async () => {
|
it("Previous at the queue's start is plainly off: the counter says why", async () => {
|
||||||
await open(0);
|
await open(0);
|
||||||
for (const b of screen.getAllByRole("button", { name: "player.previous" })) {
|
for (const b of screen.getAllByRole("button", { name: "player.previous" })) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useNavigationActions } from "../navigation/navigationContext";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { usePref } from "../../lib/usePref";
|
import { usePref } from "../../lib/usePref";
|
||||||
import { PlayerDescription, PlayerTransportControls } from "./playerModalControls";
|
import { PlayerDescription, PlayerTransportControls, ReasonTooltip } from "./playerModalControls";
|
||||||
import { unavailable } from "../../components/ui/unavailable";
|
import { unavailable } from "../../components/ui/unavailable";
|
||||||
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../../lib/playerModes";
|
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../../lib/playerModes";
|
||||||
import {
|
import {
|
||||||
@@ -544,17 +544,20 @@ export default function PlayerModal({
|
|||||||
// The big view covers the card, so the card's part with the controls goes inert under it (see the
|
// The big view covers the card, so the card's part with the controls goes inert under it (see the
|
||||||
// markup) and the focus cannot stay there. A key's focus moves to the way out and comes back to
|
// markup) and the focus cannot stay there. A key's focus moves to the way out and comes back to
|
||||||
// the same control when the view ends. A mouse's goes to the card: on the way out, the next Space
|
// the same control when the view ends. A mouse's goes to the card: on the way out, the next Space
|
||||||
// would leave the big view instead of pausing. A focus the view's end took with it (the bar's
|
// would leave the big view instead of pausing. Which it was is read as the focus arrives: Chrome
|
||||||
// controls go with the bar) goes back to the card. After the clip panel's move above, which keeps
|
// calls a clicked button :focus-visible once any key is pressed, the F that opens the view too.
|
||||||
// its own focus.
|
// When the view ends, a focus that has a place of its own stays there (the clip panel's, kept by
|
||||||
|
// its move above); one with none goes back to the card when the control refuses it (a Next that a
|
||||||
|
// step to the queue's end turned off). The bar's controls go with the bar.
|
||||||
const cardPanelRef = useRef<HTMLDivElement>(null);
|
const cardPanelRef = useRef<HTMLDivElement>(null);
|
||||||
const bigExitRef = useRef<HTMLButtonElement>(null);
|
const bigExitRef = useRef<HTMLButtonElement>(null);
|
||||||
const focusBeforeBig = useRef<HTMLElement | null>(null);
|
const focusBeforeBig = useRef<HTMLElement | null>(null);
|
||||||
|
const panelFocusByKey = useRef(false);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (bigView) {
|
if (bigView) {
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
if (!(active instanceof HTMLElement) || !cardPanelRef.current?.contains(active)) return;
|
if (!(active instanceof HTMLElement) || !cardPanelRef.current?.contains(active)) return;
|
||||||
const byKey = active.matches(":focus-visible");
|
const byKey = panelFocusByKey.current;
|
||||||
focusBeforeBig.current = byKey ? active : null;
|
focusBeforeBig.current = byKey ? active : null;
|
||||||
if (byKey && bigExitRef.current) bigExitRef.current.focus();
|
if (byKey && bigExitRef.current) bigExitRef.current.focus();
|
||||||
else focusModal();
|
else focusModal();
|
||||||
@@ -562,8 +565,9 @@ export default function PlayerModal({
|
|||||||
}
|
}
|
||||||
const back = focusBeforeBig.current;
|
const back = focusBeforeBig.current;
|
||||||
focusBeforeBig.current = null;
|
focusBeforeBig.current = null;
|
||||||
if (back?.isConnected) back.focus();
|
const active = document.activeElement;
|
||||||
else reclaimFocus();
|
if (active === document.body || active === cardRef.current) back?.focus();
|
||||||
|
if (document.activeElement !== back) reclaimFocus();
|
||||||
}, [bigView, focusModal, reclaimFocus]);
|
}, [bigView, focusModal, reclaimFocus]);
|
||||||
/** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The
|
/** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The
|
||||||
* panel lives under the video in the card, and in place of the docked bar when maximised; the
|
* panel lives under the video in the card, and in place of the docked bar when maximised; the
|
||||||
@@ -859,9 +863,9 @@ export default function PlayerModal({
|
|||||||
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
|
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
|
||||||
// be refused.
|
// be refused.
|
||||||
const relatedOn = useMe().related_enabled;
|
const relatedOn = useMe().related_enabled;
|
||||||
// A video reset from the related list starts over: the position this session stored for it would
|
// A video cleared 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.
|
// 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 clearPosition = (id: string) => void sessionPosRef.current.set(id, 0);
|
||||||
const playRelated = (v: Video, startAt: number | null) => {
|
const playRelated = (v: Video, startAt: number | null) => {
|
||||||
setRelatedOpen(false);
|
setRelatedOpen(false);
|
||||||
// The video already playing (its row stays in the list): a new player for the same id would
|
// The video already playing (its row stays in the list): a new player for the same id would
|
||||||
@@ -1124,6 +1128,11 @@ export default function PlayerModal({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
>
|
>
|
||||||
|
{/* What names the dialog: the headings go inert with the card's panel in the big view, and an
|
||||||
|
inert element names nothing. Hidden, so it is read as the name and only as that. */}
|
||||||
|
<span id={titleId} hidden>
|
||||||
|
{heading}
|
||||||
|
</span>
|
||||||
{/* The card. Previous / next live in the panel (its transport row, or the shut panel's strip) and
|
{/* The card. Previous / next live in the panel (its transport row, or the shut panel's strip) and
|
||||||
on Shift+← / →: the full-height strips that once flanked the card cost the video 7rem of width
|
on Shift+← / →: the full-height strips that once flanked the card cost the video 7rem of width
|
||||||
for a second copy of the same two buttons (owner). */}
|
for a second copy of the same two buttons (owner). */}
|
||||||
@@ -1261,7 +1270,7 @@ export default function PlayerModal({
|
|||||||
onCycleAuto={cycleAuto}
|
onCycleAuto={cycleAuto}
|
||||||
onCycleLoop={cycleLoop}
|
onCycleLoop={cycleLoop}
|
||||||
/>
|
/>
|
||||||
<span className="h-4 w-px bg-white/20" />
|
<span className="h-4 w-px shrink-0 bg-white/20" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
||||||
@@ -1383,6 +1392,9 @@ export default function PlayerModal({
|
|||||||
<div
|
<div
|
||||||
ref={cardPanelRef}
|
ref={cardPanelRef}
|
||||||
inert={bigView}
|
inert={bigView}
|
||||||
|
onFocus={(e) => {
|
||||||
|
panelFocusByKey.current = e.target.matches(":focus-visible");
|
||||||
|
}}
|
||||||
className={wide ? "relative shrink-0 border-l border-border" : undefined}
|
className={wide ? "relative shrink-0 border-l border-border" : undefined}
|
||||||
style={wide ? { width: panelWidth } : undefined}
|
style={wide ? { width: panelWidth } : undefined}
|
||||||
>
|
>
|
||||||
@@ -1391,10 +1403,7 @@ export default function PlayerModal({
|
|||||||
data-testid="player-strip"
|
data-testid="player-strip"
|
||||||
className="absolute inset-0 flex flex-col items-center gap-1 py-2"
|
className="absolute inset-0 flex flex-col items-center gap-1 py-2"
|
||||||
>
|
>
|
||||||
{/* The dialog is named by this heading's id: the panel's own one is not rendered. */}
|
<h2 className="sr-only">{heading}</h2>
|
||||||
<h2 id={titleId} className="sr-only">
|
|
||||||
{heading}
|
|
||||||
</h2>
|
|
||||||
{closeButton}
|
{closeButton}
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={panelToggleRef}
|
ref={panelToggleRef}
|
||||||
@@ -1408,24 +1417,28 @@ export default function PlayerModal({
|
|||||||
{hasQueue && (
|
{hasQueue && (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
<IconButton
|
<ReasonTooltip reason={!canPrev ? prevReason : undefined} side="left">
|
||||||
variant="ghost"
|
<IconButton
|
||||||
onClick={goPrev}
|
variant="ghost"
|
||||||
{...unavailable(!canPrev, prevReason)}
|
onClick={goPrev}
|
||||||
label={t("player.previous")}
|
{...unavailable(!canPrev, prevReason)}
|
||||||
title={`${t("player.previous")} · Shift+←${!canPrev && prevReason ? ` · ${prevReason}` : ""}`}
|
label={t("player.previous")}
|
||||||
>
|
title={`${t("player.previous")} · Shift+←`}
|
||||||
<SkipBack />
|
>
|
||||||
</IconButton>
|
<SkipBack />
|
||||||
<IconButton
|
</IconButton>
|
||||||
variant="ghost"
|
</ReasonTooltip>
|
||||||
onClick={goNext}
|
<ReasonTooltip reason={!canNext ? nextReason : undefined} side="left">
|
||||||
{...unavailable(!canNext, nextReason)}
|
<IconButton
|
||||||
label={t("player.next")}
|
variant="ghost"
|
||||||
title={`${t("player.next")} · Shift+→${!canNext && nextReason ? ` · ${nextReason}` : ""}`}
|
onClick={goNext}
|
||||||
>
|
{...unavailable(!canNext, nextReason)}
|
||||||
<SkipForward />
|
label={t("player.next")}
|
||||||
</IconButton>
|
title={`${t("player.next")} · Shift+→`}
|
||||||
|
>
|
||||||
|
<SkipForward />
|
||||||
|
</IconButton>
|
||||||
|
</ReasonTooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1474,9 +1487,7 @@ export default function PlayerModal({
|
|||||||
{/* Title row — the title and its ⓘ (the description) on the left; stacked, Close on the
|
{/* Title row — the title and its ⓘ (the description) on the left; stacked, Close on the
|
||||||
right (wide, it heads the panel). */}
|
right (wide, it heads the panel). */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">{heading}</h2>
|
||||||
{heading}
|
|
||||||
</h2>
|
|
||||||
{!wide && (
|
{!wide && (
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={setDescAnchor}
|
ref={setDescAnchor}
|
||||||
@@ -1512,7 +1523,7 @@ export default function PlayerModal({
|
|||||||
videoId={active.id}
|
videoId={active.id}
|
||||||
onPlay={playRelated}
|
onPlay={playRelated}
|
||||||
onWrite={onWrite}
|
onWrite={onWrite}
|
||||||
onReset={forgetPosition}
|
onCleared={clearPosition}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
@@ -1769,7 +1780,7 @@ export default function PlayerModal({
|
|||||||
videoId={active.id}
|
videoId={active.id}
|
||||||
onPlay={playRelated}
|
onPlay={playRelated}
|
||||||
onWrite={onWrite}
|
onWrite={onWrite}
|
||||||
onReset={forgetPosition}
|
onCleared={clearPosition}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -163,12 +163,12 @@ describe("RelatedVideos", () => {
|
|||||||
await waitFor(() => expect(titles()).toEqual(["Video A"]));
|
await waitFor(() => expect(titles()).toEqual(["Video A"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets a started video's view from its menu", async () => {
|
it("clears a started video's progress from its menu", async () => {
|
||||||
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })]));
|
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })]));
|
||||||
const reset = vi.spyOn(api, "clearState").mockResolvedValue(undefined as never);
|
const reset = vi.spyOn(api, "clearProgress").mockResolvedValue(undefined as never);
|
||||||
setup();
|
setup();
|
||||||
fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!);
|
fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!);
|
||||||
fireEvent.click(menuItem("card.resetState"));
|
fireEvent.click(menuItem("card.clearProgress"));
|
||||||
await waitFor(() => expect(reset).toHaveBeenCalledWith("A"));
|
await waitFor(() => expect(reset).toHaveBeenCalledWith("A"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import Tooltip from "../../components/ui/Tooltip";
|
|||||||
import VideoCard from "../video/VideoCard";
|
import VideoCard from "../video/VideoCard";
|
||||||
import { VideoMenu } from "../video/VideoMenu";
|
import { VideoMenu } from "../video/VideoMenu";
|
||||||
import { announceVideoState } from "../video/stateNotice";
|
import { announceVideoState } from "../video/stateNotice";
|
||||||
import { resetVideoState, writeVideoState, writeWatchLater } from "../video/videoWrites";
|
import { clearVideoProgress, writeVideoState, writeWatchLater } from "../video/videoWrites";
|
||||||
import { msg } from "../../lib/notifications";
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
|
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
|
||||||
@@ -54,15 +54,15 @@ export function RelatedVideos({
|
|||||||
videoId,
|
videoId,
|
||||||
onPlay,
|
onPlay,
|
||||||
onWrite,
|
onWrite,
|
||||||
onReset,
|
onCleared,
|
||||||
}: {
|
}: {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
/** `startAt` null resumes the video where it was left; a number (Restart's 0) starts there. */
|
/** `startAt` null resumes the video where it was left; a number (Restart's 0) starts there. */
|
||||||
onPlay: (v: Video, startAt: number | null) => void;
|
onPlay: (v: Video, startAt: number | null) => void;
|
||||||
/** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
|
/** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
|
||||||
onWrite?: (write: Promise<unknown>) => void;
|
onWrite?: (write: Promise<unknown>) => void;
|
||||||
/** A video's view was reset from its row: the host forgets where its session left it. */
|
/** A video's progress was cleared from its row: the host forgets where its session left it. */
|
||||||
onReset?: (id: string) => void;
|
onCleared?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -109,10 +109,10 @@ export function RelatedVideos({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onResetState = (id: string) => {
|
const onClearProgress = (id: string) => {
|
||||||
onReset?.(id);
|
onCleared?.(id);
|
||||||
const seq = begin(id, { status: "new", position_seconds: 0 });
|
const seq = begin(id, { position_seconds: 0 });
|
||||||
const write = resetVideoState(qc, id);
|
const write = clearVideoProgress(qc, id);
|
||||||
onWrite?.(write);
|
onWrite?.(write);
|
||||||
write
|
write
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -120,7 +120,7 @@ export function RelatedVideos({
|
|||||||
void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
notifyActionError(e, msg("errors.writeFailed.videoReset"));
|
notifyActionError(e, msg("errors.writeFailed.videoProgress"));
|
||||||
settle(seq);
|
settle(seq);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -250,7 +250,7 @@ export function RelatedVideos({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onResetState, onOpen: open }} />
|
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onClearProgress, onOpen: open }} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ListEnd, ListStart, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } f
|
|||||||
import type { VideoDetail } from "../../lib/api";
|
import type { VideoDetail } from "../../lib/api";
|
||||||
import type { AutoMode, LoopMode } from "../../lib/playerModes";
|
import type { AutoMode, LoopMode } from "../../lib/playerModes";
|
||||||
import { IconButton } from "../../components/ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
|
import Tooltip from "../../components/ui/Tooltip";
|
||||||
import { StateMessage } from "../../components/ui/QueryState";
|
import { StateMessage } from "../../components/ui/QueryState";
|
||||||
import { unavailable } from "../../components/ui/unavailable";
|
import { unavailable } from "../../components/ui/unavailable";
|
||||||
import { ClampedText } from "../../components/ui/ClampedText";
|
import { ClampedText } from "../../components/ui/ClampedText";
|
||||||
@@ -13,6 +14,25 @@ import { renderDescription } from "./descriptionLinks";
|
|||||||
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
|
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
|
||||||
// The auto-advance / loop modes it renders are defined in `lib/playerModes`.
|
// The auto-advance / loop modes it renders are defined in `lib/playerModes`.
|
||||||
|
|
||||||
|
/** Why a control is off, where a key's focus reads it too: a browser's own tooltip (`title`, left on
|
||||||
|
* the button for what it does) shows for a mouse alone. `stable`: the reason comes and goes under a
|
||||||
|
* control that may hold the focus. */
|
||||||
|
export function ReasonTooltip({
|
||||||
|
reason,
|
||||||
|
side,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
reason: string | undefined;
|
||||||
|
side?: "left";
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip hint={reason ?? ""} always={!!reason} side={side} stable>
|
||||||
|
{children}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop modes.
|
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop modes.
|
||||||
* Rendered in two places from ONE list of controls: the modal's queue bar (`variant:"modal"`, normal
|
* Rendered in two places from ONE list of controls: the modal's queue bar (`variant:"modal"`, normal
|
||||||
* glass theme) and the big-view docked control bar (`variant:"docked"`, white-on-dark). Glyphs only
|
* glass theme) and the big-view docked control bar (`variant:"docked"`, white-on-dark). Glyphs only
|
||||||
@@ -60,7 +80,7 @@ export function PlayerTransportControls({
|
|||||||
const docked = variant === "docked";
|
const docked = variant === "docked";
|
||||||
// Never broken or squeezed: in a row that scrolls, a squeezed "1 / 55" stood on three lines.
|
// Never broken or squeezed: in a row that scrolls, a squeezed "1 / 55" stood on three lines.
|
||||||
const counter = `shrink-0 whitespace-nowrap tabular-nums ${docked ? "text-white/70" : "text-muted"}`;
|
const counter = `shrink-0 whitespace-nowrap tabular-nums ${docked ? "text-white/70" : "text-muted"}`;
|
||||||
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
const divider = `h-4 w-px shrink-0 ${docked ? "bg-white/20" : "bg-border"}`;
|
||||||
const autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`;
|
const autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`;
|
||||||
const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`;
|
const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`;
|
||||||
type Control = {
|
type Control = {
|
||||||
@@ -114,17 +134,18 @@ export function PlayerTransportControls({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const render = (c: Control) => (
|
const render = (c: Control) => (
|
||||||
<IconButton
|
<ReasonTooltip key={c.key} reason={c.reason}>
|
||||||
key={c.key}
|
<IconButton
|
||||||
variant={docked ? "overlay" : undefined}
|
variant={docked ? "overlay" : undefined}
|
||||||
onClick={c.onClick}
|
onClick={c.onClick}
|
||||||
{...unavailable(c.disabled, c.reason)}
|
{...unavailable(c.disabled, c.reason)}
|
||||||
lit={c.lit}
|
lit={c.lit}
|
||||||
label={c.name}
|
label={c.name}
|
||||||
title={c.reason ? `${c.title} · ${c.reason}` : c.title}
|
title={c.title}
|
||||||
>
|
>
|
||||||
{c.glyph}
|
{c.glyph}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</ReasonTooltip>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// The card shows its state and the everyday toggles (watched, save, add to playlist) and a
|
// The card shows its state and the everyday toggles (watched, save, add to playlist) and a
|
||||||
// "…" into the list's ONE menu; download, details, hide and reset live only in that menu. Every view
|
// "…" into the list's ONE menu; download, details, hide and clear progress live only in that menu. Every view
|
||||||
// carries the same set, and a view that shows the "…" twice (the card: in the hover row on the
|
// carries the same set, and a view that shows the "…" twice (the card: in the hover row on the
|
||||||
// picture, and beside the title for touch — only one is displayed per device) points both at the
|
// picture, and beside the title for touch — only one is displayed per device) points both at the
|
||||||
// same menu.
|
// same menu.
|
||||||
@@ -77,7 +77,7 @@ describe.each(Object.keys(FEED_VIEW_SPEC) as FeedView[])("the %s view's actions"
|
|||||||
for (const want of ["card.markWatched", "card.saveForLater", "playlists.addToPlaylist"])
|
for (const want of ["card.markWatched", "card.saveForLater", "playlists.addToPlaylist"])
|
||||||
expect(all).toContain(want);
|
expect(all).toContain(want);
|
||||||
expect(all).toContain("card.menu.more");
|
expect(all).toContain("card.menu.more");
|
||||||
for (const gone of ["downloads.button.label", "card.hide", "card.resetState", "card.info"])
|
for (const gone of ["downloads.button.label", "card.hide", "card.clearProgress", "card.info"])
|
||||||
expect(all).not.toContain(gone);
|
expect(all).not.toContain(gone);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import RemoteImage from "../../components/ui/RemoteImage";
|
|||||||
type More = { props: Record<string, unknown>; open: boolean };
|
type More = { props: Record<string, unknown>; open: boolean };
|
||||||
|
|
||||||
// The card keeps its state and the everyday toggles — watched, save, add to playlist — and
|
// The card keeps its state and the everyday toggles — watched, save, add to playlist — and
|
||||||
// a "…" for everything else (download, details, copy, hide, reset, pin), which is the list's ONE
|
// a "…" for everything else (download, details, copy, hide, clear progress, pin), which is the list's ONE
|
||||||
// menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles
|
// menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles
|
||||||
// are gone (the "…" and the long-press are the way in) and only the "…" stays.
|
// are gone (the "…" and the long-press are the way in) and only the "…" stays.
|
||||||
/** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */
|
/** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// A video's ONE action list. The right-click and the card's "…" open the same menu, which
|
// A video's ONE action list. The right-click and the card's "…" open the same menu, which
|
||||||
// holds everything the card no longer shows (download, details, copy, hide, reset, pin) beside the
|
// holds everything the card no longer shows (download, details, copy, hide, clear progress, pin) beside the
|
||||||
// everyday toggles, and each row fires the same handler the card's own buttons do.
|
// everyday toggles, and each row fires the same handler the card's own buttons do.
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
@@ -102,18 +102,18 @@ function setup(v: Video) {
|
|||||||
const handlers = {
|
const handlers = {
|
||||||
onState: vi.fn(),
|
onState: vi.fn(),
|
||||||
onToggleSave: vi.fn(),
|
onToggleSave: vi.fn(),
|
||||||
onResetState: vi.fn(),
|
onClearProgress: vi.fn(),
|
||||||
onOpen: vi.fn(),
|
onOpen: vi.fn(),
|
||||||
};
|
};
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient();
|
||||||
render(
|
const { unmount } = render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<FloatingTree>
|
<FloatingTree>
|
||||||
<Feed v={v} handlers={handlers} />
|
<Feed v={v} handlers={handlers} />
|
||||||
</FloatingTree>
|
</FloatingTree>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
return { handlers };
|
return { handlers, unmount };
|
||||||
}
|
}
|
||||||
const rightClick = () => fireEvent.contextMenu(screen.getByText("card"));
|
const rightClick = () => fireEvent.contextMenu(screen.getByText("card"));
|
||||||
const more = () => screen.getByRole("button", { name: "more" });
|
const more = () => screen.getByRole("button", { name: "more" });
|
||||||
@@ -137,16 +137,29 @@ describe("a video's menu", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers Continue and the restart on a half-watched one, the way back and the reset", async () => {
|
it("offers Continue and the restart on a half-watched one, the way back and the clearing", async () => {
|
||||||
const { handlers } = setup(video({ position_seconds: 30, saved: true }));
|
const { handlers } = setup(video({ position_seconds: 30, saved: true }));
|
||||||
rightClick();
|
rightClick();
|
||||||
expect(labels()).toContain("card.restartTitle");
|
expect(labels()).toContain("card.restartTitle");
|
||||||
expect(labels()).toContain("card.menu.unsave");
|
expect(labels()).toContain("card.menu.unsave");
|
||||||
expect(labels()).toContain("card.resetState");
|
expect(labels()).toContain("card.clearProgress");
|
||||||
fireEvent.click(item("card.restartTitle"));
|
fireEvent.click(item("card.restartTitle"));
|
||||||
await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenLastCalledWith(expect.anything(), 0));
|
await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenLastCalledWith(expect.anything(), 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// It clears a resume point and nothing else: with none, there is nothing for it to do.
|
||||||
|
it("offers clearing only where there is a resume point, whatever the status", () => {
|
||||||
|
for (const status of ["hidden", "watched"] as const) {
|
||||||
|
const { unmount } = setup(video({ status }));
|
||||||
|
rightClick();
|
||||||
|
expect(labels()).not.toContain("card.clearProgress");
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
setup(video({ status: "hidden", position_seconds: 30 }));
|
||||||
|
rightClick();
|
||||||
|
expect(labels()).toContain("card.clearProgress");
|
||||||
|
});
|
||||||
|
|
||||||
it("each toggle fires the card's own handler", async () => {
|
it("each toggle fires the card's own handler", async () => {
|
||||||
const { handlers } = setup(video({ status: "watched" }));
|
const { handlers } = setup(video({ status: "watched" }));
|
||||||
rightClick();
|
rightClick();
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function downloadRow(state: string | undefined) {
|
|||||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
);
|
);
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn(), onResetState: vi.fn() }, true),
|
() =>
|
||||||
|
useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn(), onClearProgress: vi.fn() }, true),
|
||||||
{ wrapper },
|
{ wrapper },
|
||||||
);
|
);
|
||||||
return () => result.current.items(video, null).find((i) => i.id === "download")!;
|
return () => result.current.items(video, null).find((i) => i.id === "download")!;
|
||||||
@@ -74,7 +75,7 @@ describe("the video menu's channel-filter row", () => {
|
|||||||
{
|
{
|
||||||
onState: vi.fn(),
|
onState: vi.fn(),
|
||||||
onToggleSave: vi.fn(),
|
onToggleSave: vi.fn(),
|
||||||
onResetState: vi.fn(),
|
onClearProgress: vi.fn(),
|
||||||
channelFilter: ids ? { ids, set } : undefined,
|
channelFilter: ids ? { ids, set } : undefined,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { api, type Video, type VideoStatus } from "../../lib/api";
|
import { api, type Video, type VideoStatus } from "../../lib/api";
|
||||||
import { isResettable } from "../../lib/api/feed";
|
import { hasResumePoint } from "../../lib/api/feed";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { pinVideo, useComfortPin } from "../comfort/comfortPin";
|
import { pinVideo, useComfortPin } from "../comfort/comfortPin";
|
||||||
import { useCopyItems } from "../../components/ui/copyItems";
|
import { useCopyItems } from "../../components/ui/copyItems";
|
||||||
@@ -33,13 +33,13 @@ import { raw } from "../../lib/notifications";
|
|||||||
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
|
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
|
||||||
// and the Home shelves list the same actions in the same order. The card itself keeps only its
|
// and the Home shelves list the same actions in the same order. The card itself keeps only its
|
||||||
// state and the everyday toggles (watched, save, add to playlist) on hover; everything else —
|
// state and the everyday toggles (watched, save, add to playlist) on hover; everything else —
|
||||||
// download, details, copy, hide, reset, pin — lives here, and only here.
|
// download, details, copy, hide, clear progress, pin — lives here, and only here.
|
||||||
|
|
||||||
export interface VideoActionHandlers {
|
export interface VideoActionHandlers {
|
||||||
onState: (id: string, status: VideoStatus) => void;
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
/** Back to fresh: its state row and resume point both go. */
|
/** Back to fresh: its state row and resume point both go. */
|
||||||
onResetState: (id: string) => void;
|
onClearProgress: (id: string) => void;
|
||||||
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
|
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
/** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel
|
/** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel
|
||||||
@@ -71,7 +71,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
|||||||
anchor: Element | null,
|
anchor: Element | null,
|
||||||
{ pin: withPin = true }: { pin?: boolean } = {},
|
{ pin: withPin = true }: { pin?: boolean } = {},
|
||||||
): ActionMenuItem[] => {
|
): ActionMenuItem[] => {
|
||||||
const { onState, onToggleSave, onResetState, onOpen, channelFilter } = handlers;
|
const { onState, onToggleSave, onClearProgress, onOpen, channelFilter } = handlers;
|
||||||
const watched = v.status === "watched";
|
const watched = v.status === "watched";
|
||||||
const hidden = v.status === "hidden";
|
const hidden = v.status === "hidden";
|
||||||
const inProgress = v.position_seconds > 0 && !watched;
|
const inProgress = v.position_seconds > 0 && !watched;
|
||||||
@@ -189,13 +189,13 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
|||||||
separatorBefore: true,
|
separatorBefore: true,
|
||||||
onSelect: () => onState(v.id, hidden ? "new" : "hidden"),
|
onSelect: () => onState(v.id, hidden ? "new" : "hidden"),
|
||||||
},
|
},
|
||||||
...(isResettable(v)
|
...(hasResumePoint(v)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "reset",
|
id: "clearProgress",
|
||||||
label: t("card.resetState"),
|
label: t("card.clearProgress"),
|
||||||
icon: <Eraser className={icon} />,
|
icon: <Eraser className={icon} />,
|
||||||
onSelect: () => onResetState(v.id),
|
onSelect: () => onClearProgress(v.id),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const ALLOWED = new Set([
|
|||||||
// the built-in Watch later list's items ARE the mark. videoWrites makes those writes and the mark
|
// the built-in Watch later list's items ARE the mark. videoWrites makes those writes and the mark
|
||||||
// together; a direct api call skips the mark.
|
// together; a direct api call skips the mark.
|
||||||
const WRITES =
|
const WRITES =
|
||||||
/\bapi\s*\.\s*(setState|clearState|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|moveToPlaylist)\b/;
|
/\bapi\s*\.\s*(setState|clearProgress|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|moveToPlaylist)\b/;
|
||||||
|
|
||||||
describe("a video's state, Watch-later and playlist-item writes", () => {
|
describe("a video's state, Watch-later and playlist-item writes", () => {
|
||||||
it("go through features/video/videoWrites, never the api directly", () => {
|
it("go through features/video/videoWrites, never the api directly", () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
addPlaylistItem,
|
addPlaylistItem,
|
||||||
movePlaylistItem,
|
movePlaylistItem,
|
||||||
removePlaylistItem,
|
removePlaylistItem,
|
||||||
resetVideoState,
|
clearVideoProgress,
|
||||||
writeVideoState,
|
writeVideoState,
|
||||||
writeWatchLater,
|
writeWatchLater,
|
||||||
} from "./videoWrites";
|
} from "./videoWrites";
|
||||||
@@ -41,11 +41,11 @@ describe("videoWrites", () => {
|
|||||||
expect(marks(qc)).toEqual({ X: { status: "hidden" } });
|
expect(marks(qc)).toEqual({ X: { status: "hidden" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a reset as new, from the start", async () => {
|
it("marks a cleared video as back at the start, its status as it was", async () => {
|
||||||
vi.spyOn(api, "clearState").mockResolvedValue({ ok: true } as never);
|
vi.spyOn(api, "clearProgress").mockResolvedValue({ ok: true } as never);
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient();
|
||||||
await resetVideoState(qc, "X");
|
await clearVideoProgress(qc, "X");
|
||||||
expect(marks(qc)).toEqual({ X: { status: "new", position_seconds: 0 } });
|
expect(marks(qc)).toEqual({ X: { position_seconds: 0 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a Watch-later save and its removal", async () => {
|
it("marks a Watch-later save and its removal", async () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { markVideo } from "../../lib/progressCache";
|
|||||||
// once it has the change — into the lists that are never refetched (`markVideo`: every related
|
// once it has the change — into the lists that are never refetched (`markVideo`: every related
|
||||||
// list, every live search). These are the ONE way to make those writes, and
|
// list, every live search). These are the ONE way to make those writes, and
|
||||||
// `videoWrites.contract.test.ts` holds it: four writes that called the api directly (the
|
// `videoWrites.contract.test.ts` holds it: four writes that called the api directly (the
|
||||||
// Notification Center's undo, the feed's reset, the feed's and Home's Watch-later toggles) left a
|
// Notification Center's undo, the feed's clear, the feed's and Home's Watch-later toggles) left a
|
||||||
// related list hiding a video the user had un-hidden, or showing it unsaved after a save. A
|
// related list hiding a video the user had un-hidden, or showing it unsaved after a save. A
|
||||||
// playlist item is one of them because the built-in Watch later list IS the mark (`saved`): ticked
|
// playlist item is one of them because the built-in Watch later list IS the mark (`saved`): ticked
|
||||||
// in Add to playlist or taken out on the Playlists page, it left the bookmark as it was. Each
|
// in Add to playlist or taken out on the Playlists page, it left the bookmark as it was. Each
|
||||||
@@ -20,10 +20,10 @@ export function writeVideoState(qc: QueryClient, id: string, status: VideoStatus
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Back to pristine: the state row and its resume position both go. */
|
/** Forget where the user left it: the resume point goes, the status stays. */
|
||||||
export function resetVideoState(qc: QueryClient, id: string) {
|
export function clearVideoProgress(qc: QueryClient, id: string) {
|
||||||
return api.clearState(id).then((r) => {
|
return api.clearProgress(id).then((r) => {
|
||||||
markVideo(qc, id, { status: "new", position_seconds: 0 });
|
markVideo(qc, id, { position_seconds: 0 });
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"saveForLater": "Save for later",
|
"saveForLater": "Save for later",
|
||||||
"unhide": "Unhide",
|
"unhide": "Unhide",
|
||||||
"hide": "Hide",
|
"hide": "Hide",
|
||||||
"resetState": "Clear watch progress",
|
"clearProgress": "Clear watch progress",
|
||||||
"thisChannel": "This channel",
|
"thisChannel": "This channel",
|
||||||
"continueTitle": "Continue where you left off",
|
"continueTitle": "Continue where you left off",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
"prefs": "Couldn't save your settings",
|
"prefs": "Couldn't save your settings",
|
||||||
"deleteAccount": "Couldn't delete your account",
|
"deleteAccount": "Couldn't delete your account",
|
||||||
"videoState": "Couldn't change the video's state",
|
"videoState": "Couldn't change the video's state",
|
||||||
"videoReset": "Couldn't reset the video",
|
"videoProgress": "Couldn't clear the video's watch progress",
|
||||||
"watchLaterAdd": "Couldn't add it to Watch later",
|
"watchLaterAdd": "Couldn't add it to Watch later",
|
||||||
"watchLaterRemove": "Couldn't remove it from Watch later",
|
"watchLaterRemove": "Couldn't remove it from Watch later",
|
||||||
"clearSearch": "Couldn't clear the search results",
|
"clearSearch": "Couldn't clear the search results",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"saveForLater": "Mentés későbbre",
|
"saveForLater": "Mentés későbbre",
|
||||||
"unhide": "Megjelenítés",
|
"unhide": "Megjelenítés",
|
||||||
"hide": "Elrejtés",
|
"hide": "Elrejtés",
|
||||||
"resetState": "Megtekintés visszaállítása",
|
"clearProgress": "Megtekintés visszaállítása",
|
||||||
"thisChannel": "Ez a csatorna",
|
"thisChannel": "Ez a csatorna",
|
||||||
"continueTitle": "Folytatás ott, ahol abbahagytad",
|
"continueTitle": "Folytatás ott, ahol abbahagytad",
|
||||||
"continue": "Folytatás",
|
"continue": "Folytatás",
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
"prefs": "Nem sikerült menteni a beállításaidat",
|
"prefs": "Nem sikerült menteni a beállításaidat",
|
||||||
"deleteAccount": "Nem sikerült törölni a fiókodat",
|
"deleteAccount": "Nem sikerült törölni a fiókodat",
|
||||||
"videoState": "Nem sikerült módosítani a videó állapotát",
|
"videoState": "Nem sikerült módosítani a videó állapotát",
|
||||||
"videoReset": "Nem sikerült alaphelyzetbe állítani a videót",
|
"videoProgress": "Nem sikerült visszaállítani a videó megtekintését",
|
||||||
"watchLaterAdd": "Nem sikerült hozzáadni a Megnézem később listához",
|
"watchLaterAdd": "Nem sikerült hozzáadni a Megnézem később listához",
|
||||||
"watchLaterRemove": "Nem sikerült eltávolítani a Megnézem később listából",
|
"watchLaterRemove": "Nem sikerült eltávolítani a Megnézem később listából",
|
||||||
"clearSearch": "Nem sikerült törölni a keresési találatokat",
|
"clearSearch": "Nem sikerült törölni a keresési találatokat",
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export interface Video {
|
|||||||
watch_url: string;
|
watch_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a video has anything its reset would clear: pristine = never opened, the default status
|
/** Whether a video has a resume point, the one thing "Clear watch progress" clears. Its status is no
|
||||||
* and no resume position. The card's reset button and the card menu's reset both ask this. */
|
* part of it: a hidden video stays hidden, a watched one watched. */
|
||||||
export function isResettable(v: Pick<Video, "status" | "position_seconds">): boolean {
|
export function hasResumePoint(v: Pick<Video, "position_seconds">): boolean {
|
||||||
return v.status !== "new" || v.position_seconds > 0;
|
return v.position_seconds > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A video's related videos, in the locale YouTube fills the list in: on the video's topic, or with
|
/** A video's related videos, in the locale YouTube fills the list in: on the video's topic, or with
|
||||||
@@ -290,8 +290,8 @@ export const feedApi = {
|
|||||||
{ method: "POST", body: JSON.stringify({ status }) },
|
{ method: "POST", body: JSON.stringify({ status }) },
|
||||||
{ idempotent: true },
|
{ idempotent: true },
|
||||||
),
|
),
|
||||||
clearState: (id: string) =>
|
clearProgress: (id: string) =>
|
||||||
req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }),
|
req(`/api/videos/${id}/progress`, { method: "DELETE" }, { idempotent: true }),
|
||||||
// Returns the position the server actually STORED — which is not always what we sent: a
|
// Returns the position the server actually STORED — which is not always what we sent: a
|
||||||
// trivially-early or near-finished checkpoint is cleared to 0 (see set_video_progress). Callers
|
// trivially-early or near-finished checkpoint is cleared to 0 (see set_video_progress). Callers
|
||||||
// that mirror the position into a cache must write this answer, never their own reading, so the
|
// that mirror the position into a cache must write this answer, never their own reading, so the
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
// Redownload. Each row is a `label: t("…")` followed by its `icon`.
|
// Redownload. Each row is a `label: t("…")` followed by its `icon`.
|
||||||
const ROW_GLYPHS: Record<string, string> = {
|
const ROW_GLYPHS: Record<string, string> = {
|
||||||
"card.restartTitle": "RotateCcw",
|
"card.restartTitle": "RotateCcw",
|
||||||
"card.resetState": "Eraser",
|
"card.clearProgress": "Eraser",
|
||||||
"files.actions.redownload": "RefreshCw",
|
"files.actions.redownload": "RefreshCw",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+31
-19
@@ -18,16 +18,16 @@ vi.mock("../../features/wizard/wizardContext", () => ({
|
|||||||
vi.mock("./VirtualFeed", () => ({
|
vi.mock("./VirtualFeed", () => ({
|
||||||
default: ({
|
default: ({
|
||||||
items,
|
items,
|
||||||
onResetState,
|
onClearProgress,
|
||||||
}: {
|
}: {
|
||||||
items: { id: string; position_seconds: number }[];
|
items: { id: string; position_seconds: number }[];
|
||||||
onResetState: (id: string) => void;
|
onClearProgress: (id: string) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<ol aria-label="results">
|
<ol aria-label="results">
|
||||||
{items.map((v) => (
|
{items.map((v) => (
|
||||||
<li key={v.id}>
|
<li key={v.id}>
|
||||||
{v.id}@{v.position_seconds}
|
{v.id}@{v.position_seconds}
|
||||||
<button onClick={() => onResetState(v.id)}>reset {v.id}</button>
|
<button onClick={() => onClearProgress(v.id)}>clear {v.id}</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -81,16 +81,16 @@ const halfWatched = {
|
|||||||
watch_url: "https://www.youtube.com/watch?v=v1",
|
watch_url: "https://www.youtube.com/watch?v=v1",
|
||||||
} as unknown as Video;
|
} as unknown as Video;
|
||||||
|
|
||||||
function mount(clear: () => Promise<unknown>) {
|
function mount(clear: () => Promise<unknown>, video = halfWatched, filters = FILTERS) {
|
||||||
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
|
vi.spyOn(api, "feed").mockResolvedValue({ items: [video], next_cursor: null } as never);
|
||||||
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||||
vi.spyOn(api, "clearState").mockImplementation(clear as never);
|
vi.spyOn(api, "clearProgress").mockImplementation(clear as never);
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<Feed
|
<Feed
|
||||||
filters={FILTERS}
|
filters={filters}
|
||||||
setFilters={() => {}}
|
setFilters={() => {}}
|
||||||
view="cards"
|
view="cards"
|
||||||
setView={() => {}}
|
setView={() => {}}
|
||||||
@@ -104,29 +104,41 @@ function mount(clear: () => Promise<unknown>) {
|
|||||||
return qc;
|
return qc;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Feed — resetting a half-watched video", () => {
|
describe("Feed — clearing a half-watched video's progress", () => {
|
||||||
it("clears its resume position at once, not only its status", async () => {
|
it("leaves its status alone: a hidden video stays in the Hidden view", async () => {
|
||||||
|
mount(
|
||||||
|
() => Promise.resolve({ ok: true }),
|
||||||
|
{ ...halfWatched, status: "hidden" },
|
||||||
|
{ ...FILTERS, show: "hidden" },
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByText("clear v1"));
|
||||||
|
await screen.findByText("v1@0");
|
||||||
|
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||||
|
expect(screen.getByText("v1@0")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears its resume position at once", async () => {
|
||||||
// Never settles: what the card shows before the server answers is the optimistic layer alone.
|
// Never settles: what the card shows before the server answers is the optimistic layer alone.
|
||||||
mount(() => new Promise(() => {}));
|
mount(() => new Promise(() => {}));
|
||||||
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
||||||
fireEvent.click(screen.getByText("reset v1"));
|
fireEvent.click(screen.getByText("clear v1"));
|
||||||
await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the position a replay after it saved, so a reopen resumes there", async () => {
|
it("shows the position a replay after it saved, so a reopen resumes there", async () => {
|
||||||
// Reset, played to 40 s and closed: the player writes what the server stored into the cache.
|
// Cleared, played to 40 s and closed: the player writes what the server stored into the cache.
|
||||||
// A reset mask over the cache kept showing 0, a reopen resumed at 0, and its first checkpoint
|
// A mask over the cache kept showing 0, a reopen resumed at 0, and its first checkpoint
|
||||||
// wrote over the 40.
|
// wrote over the 40.
|
||||||
const qc = mount(() => Promise.resolve({ ok: true }));
|
const qc = mount(() => Promise.resolve({ ok: true }));
|
||||||
fireEvent.click(await screen.findByText("reset v1"));
|
fireEvent.click(await screen.findByText("clear v1"));
|
||||||
await screen.findByText("v1@0");
|
await screen.findByText("v1@0");
|
||||||
act(() => patchCachedProgress(qc, new Map([["v1", 40]])));
|
act(() => patchCachedProgress(qc, new Map([["v1", 40]])));
|
||||||
await waitFor(() => expect(screen.getByText("v1@40")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("v1@40")).toBeTruthy());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the position at 0 through a refetch that set out before the reset", async () => {
|
it("keeps the position at 0 through a refetch that set out before the clear", async () => {
|
||||||
// That refetch read the row the reset deletes: its answer put the old position back under a
|
// That refetch read the row before the clear: its answer put the old position back under a
|
||||||
// card the reset had made new, and a reopen resumed there.
|
// card the clear had reset, and a reopen resumed there.
|
||||||
let answer: (v: unknown) => void = () => {};
|
let answer: (v: unknown) => void = () => {};
|
||||||
const qc = mount(() => new Promise(() => {}));
|
const qc = mount(() => new Promise(() => {}));
|
||||||
await screen.findByText("v1@95");
|
await screen.findByText("v1@95");
|
||||||
@@ -134,16 +146,16 @@ describe("Feed — resetting a half-watched video", () => {
|
|||||||
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||||
await waitFor(() => expect(api.feed).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(api.feed).toHaveBeenCalledTimes(2));
|
||||||
await act(() => new Promise((r) => setTimeout(r, 20)));
|
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||||
fireEvent.click(screen.getByText("reset v1"));
|
fireEvent.click(screen.getByText("clear v1"));
|
||||||
await screen.findByText("v1@0");
|
await screen.findByText("v1@0");
|
||||||
await act(async () => answer({ items: [halfWatched], next_cursor: null }));
|
await act(async () => answer({ items: [halfWatched], next_cursor: null }));
|
||||||
await act(() => new Promise((r) => setTimeout(r, 20)));
|
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||||
expect(screen.getByText("v1@0")).toBeTruthy();
|
expect(screen.getByText("v1@0")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("puts the position back when the server refuses the reset", async () => {
|
it("puts the position back when the server refuses the clear", async () => {
|
||||||
mount(() => Promise.reject(new Error("down")));
|
mount(() => Promise.reject(new Error("down")));
|
||||||
fireEvent.click(await screen.findByText("reset v1"));
|
fireEvent.click(await screen.findByText("clear v1"));
|
||||||
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
|
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
|
||||||
import {
|
import {
|
||||||
resetVideoState,
|
clearVideoProgress,
|
||||||
writeVideoState,
|
writeVideoState,
|
||||||
writeWatchLater,
|
writeWatchLater,
|
||||||
} from "../../features/video/videoWrites";
|
} from "../../features/video/videoWrites";
|
||||||
@@ -210,9 +210,9 @@ export default function Feed({
|
|||||||
const writeSeq = useRef(0);
|
const writeSeq = useRef(0);
|
||||||
const answered = useRef(new Set<number>());
|
const answered = useRef(new Set<number>());
|
||||||
const answeredAtRefetch = useRef<ReadonlySet<number>>(new Set());
|
const answeredAtRefetch = useRef<ReadonlySet<number>>(new Set());
|
||||||
// A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out
|
// A cleared position's 0 lives in the cached lists (see onClearProgress), where a refetch that set
|
||||||
// before the server took the reset puts the old position back: written again on its landing.
|
// out before the server took the clear puts the old position back: written again on its landing.
|
||||||
const resets = useRef(new Map<string, number>());
|
const clears = useRef(new Map<string, number>());
|
||||||
// The open player: which video and where to start (null = resume from saved position). `alone`:
|
// The open player: which video and where to start (null = resume from saved position). `alone`:
|
||||||
// opened from an address naming a video this list has not loaded, so it plays without the queue.
|
// opened from an address naming a video this list has not loaded, so it plays without the queue.
|
||||||
const [activeVideo, setActiveVideo] = useState<{
|
const [activeVideo, setActiveVideo] = useState<{
|
||||||
@@ -237,8 +237,8 @@ export default function Feed({
|
|||||||
});
|
});
|
||||||
const openVideo = useCallback((video: Video, startAt: number | null = null) => {
|
const openVideo = useCallback((video: Video, startAt: number | null = null) => {
|
||||||
playerSession.current.open = true;
|
playerSession.current.open = true;
|
||||||
// Its player writes the next position: a reset no longer speaks for it.
|
// Its player writes the next position: a clear no longer speaks for it.
|
||||||
resets.current.delete(video.id);
|
clears.current.delete(video.id);
|
||||||
setActiveVideo({ video, startAt });
|
setActiveVideo({ video, startAt });
|
||||||
}, []);
|
}, []);
|
||||||
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
|
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
|
||||||
@@ -356,8 +356,8 @@ export default function Feed({
|
|||||||
setSavedOverrides((o) => unconfirmed(o, known));
|
setSavedOverrides((o) => unconfirmed(o, known));
|
||||||
for (const seq of known) answered.current.delete(seq);
|
for (const seq of known) answered.current.delete(seq);
|
||||||
const zero = new Map<string, number>();
|
const zero = new Map<string, number>();
|
||||||
for (const [id, seq] of resets.current) {
|
for (const [id, seq] of clears.current) {
|
||||||
if (known.has(seq)) resets.current.delete(id);
|
if (known.has(seq)) clears.current.delete(id);
|
||||||
else zero.set(id, 0);
|
else zero.set(id, 0);
|
||||||
}
|
}
|
||||||
patchCachedProgress(qc, zero);
|
patchCachedProgress(qc, zero);
|
||||||
@@ -429,30 +429,26 @@ export default function Feed({
|
|||||||
[qc, trackSessionWrite],
|
[qc, trackSessionWrite],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onResetState = useCallback(
|
const onClearProgress = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
|
// The resume point alone: the status stays, so no status override.
|
||||||
// un-watch toggle can't clear an in-progress position, this can.
|
|
||||||
const seq = ++writeSeq.current;
|
const seq = ++writeSeq.current;
|
||||||
setOverrides((o) => ({ ...o, [id]: { value: "new", seq } }));
|
|
||||||
// The position goes into the cached lists, where the player's own closing patch writes the
|
// The position goes into the cached lists, where the player's own closing patch writes the
|
||||||
// next one: as a mask over them it outlived a replay, so a reopen resumed at 0 and its first
|
// next one: as a mask over them it outlived a replay, so a reopen resumed at 0 and its first
|
||||||
// checkpoint wrote over the progress just made.
|
// checkpoint wrote over the progress just made.
|
||||||
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
|
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
|
||||||
patchCachedProgress(qc, new Map([[id, 0]]));
|
patchCachedProgress(qc, new Map([[id, 0]]));
|
||||||
resets.current.set(id, seq);
|
clears.current.set(id, seq);
|
||||||
resetVideoState(qc, id)
|
clearVideoProgress(qc, id)
|
||||||
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
// Stale-mark only (see setState above): the patch above already cleared the card.
|
||||||
.then(() => {
|
.then(() => {
|
||||||
answered.current.add(seq);
|
answered.current.add(seq);
|
||||||
setOverrides((o) => withAnswer(o, id, seq));
|
|
||||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
notifyActionError(e, msg("errors.writeFailed.videoReset"));
|
notifyActionError(e, msg("errors.writeFailed.videoProgress"));
|
||||||
setOverrides((o) => withoutWrite(o, id, seq));
|
if (clears.current.get(id) !== seq) return;
|
||||||
if (resets.current.get(id) !== seq) return;
|
clears.current.delete(id);
|
||||||
resets.current.delete(id);
|
|
||||||
if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]]));
|
if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]]));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -666,7 +662,7 @@ export default function Feed({
|
|||||||
view={view}
|
view={view}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onResetState={onResetState}
|
onClearProgress={onClearProgress}
|
||||||
onOpen={openVideo}
|
onOpen={openVideo}
|
||||||
hasNextPage={false}
|
hasNextPage={false}
|
||||||
isFetching={false}
|
isFetching={false}
|
||||||
@@ -1004,7 +1000,7 @@ export default function Feed({
|
|||||||
view={view}
|
view={view}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onResetState={onResetState}
|
onClearProgress={onClearProgress}
|
||||||
onOpen={openVideo}
|
onOpen={openVideo}
|
||||||
// A channel page's view is the channel already: no row there to narrow it to one.
|
// A channel page's view is the channel already: no row there to narrow it to one.
|
||||||
channelFilter={
|
channelFilter={
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export interface VirtualFeedProps {
|
|||||||
view: FeedView;
|
view: FeedView;
|
||||||
onState: (id: string, status: VideoStatus) => void;
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onResetState: (id: string) => void;
|
onClearProgress: (id: string) => void;
|
||||||
onOpen: (v: Video, startAt?: number | null) => void;
|
onOpen: (v: Video, startAt?: number | null) => void;
|
||||||
/** The card menu's channel-filter row (see `useVideoActions`); omitted where it has no place. */
|
/** The card menu's channel-filter row (see `useVideoActions`); omitted where it has no place. */
|
||||||
channelFilter?: VideoActionHandlers["channelFilter"];
|
channelFilter?: VideoActionHandlers["channelFilter"];
|
||||||
@@ -44,7 +44,7 @@ export default function VirtualFeed({
|
|||||||
view,
|
view,
|
||||||
onState,
|
onState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
onResetState,
|
onClearProgress,
|
||||||
onOpen,
|
onOpen,
|
||||||
channelFilter,
|
channelFilter,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
@@ -92,7 +92,7 @@ export default function VirtualFeed({
|
|||||||
/>
|
/>
|
||||||
<VideoMenu
|
<VideoMenu
|
||||||
menu={menu}
|
menu={menu}
|
||||||
handlers={{ onState, onToggleSave, onResetState, onOpen, channelFilter }}
|
handlers={{ onState, onToggleSave, onClearProgress, onOpen, channelFilter }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
const discover = vi.fn();
|
const discover = vi.fn();
|
||||||
const discoverPick = vi.fn();
|
const discoverPick = vi.fn();
|
||||||
const videoRow = vi.fn();
|
const videoRow = vi.fn();
|
||||||
const clearState = vi.fn((_id: string) => Promise.resolve());
|
const clearProgress = vi.fn((_id: string) => Promise.resolve());
|
||||||
vi.mock("../../lib/api", () => ({
|
vi.mock("../../lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
discover: (...a: unknown[]) => discover(...a),
|
discover: (...a: unknown[]) => discover(...a),
|
||||||
videoRow: (...a: unknown[]) => videoRow(...a),
|
videoRow: (...a: unknown[]) => videoRow(...a),
|
||||||
discoverPick: (...a: unknown[]) => discoverPick(...a),
|
discoverPick: (...a: unknown[]) => discoverPick(...a),
|
||||||
setState: () => Promise.resolve(),
|
setState: () => Promise.resolve(),
|
||||||
clearState: (id: string) => clearState(id),
|
clearProgress: (id: string) => clearProgress(id),
|
||||||
watchLaterAdd: () => Promise.resolve(),
|
watchLaterAdd: () => Promise.resolve(),
|
||||||
watchLaterRemove: () => Promise.resolve(),
|
watchLaterRemove: () => Promise.resolve(),
|
||||||
downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it
|
downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it
|
||||||
@@ -816,11 +816,11 @@ describe("Home", () => {
|
|||||||
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
|
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
|
||||||
const reset = screen
|
const reset = screen
|
||||||
.getAllByRole("menuitem", { hidden: true })
|
.getAllByRole("menuitem", { hidden: true })
|
||||||
.find((b) => b.textContent === "card.resetState");
|
.find((b) => b.textContent === "card.clearProgress");
|
||||||
expect(reset).toBeTruthy();
|
expect(reset).toBeTruthy();
|
||||||
const asked = discover.mock.calls.length;
|
const asked = discover.mock.calls.length;
|
||||||
fireEvent.click(reset!);
|
fireEvent.click(reset!);
|
||||||
await waitFor(() => expect(clearState).toHaveBeenCalledWith("v1"));
|
await waitFor(() => expect(clearProgress).toHaveBeenCalledWith("v1"));
|
||||||
await waitFor(() => expect(discover.mock.calls.length).toBeGreaterThan(asked));
|
await waitFor(() => expect(discover.mock.calls.length).toBeGreaterThan(asked));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
|||||||
import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard";
|
import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard";
|
||||||
import { useVideoActions } from "../../features/video/useVideoActions";
|
import { useVideoActions } from "../../features/video/useVideoActions";
|
||||||
import {
|
import {
|
||||||
resetVideoState,
|
clearVideoProgress,
|
||||||
writeVideoState,
|
writeVideoState,
|
||||||
writeWatchLater,
|
writeWatchLater,
|
||||||
} from "../../features/video/videoWrites";
|
} from "../../features/video/videoWrites";
|
||||||
@@ -161,14 +161,14 @@ function useHomeCardMenu({
|
|||||||
// A video tile's own action list, the same one the feed's menu shows; the rail keeps its
|
// A video tile's own action list, the same one the feed's menu shows; the rail keeps its
|
||||||
// Play row (it resolves `next`) and its shelf rows around it.
|
// Play row (it resolves `next`) and its shelf rows around it.
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
// Back to fresh: what the rails answer changes with it (a reset video leaves Continue watching),
|
// What the rails answer changes with it (a video with no resume point leaves Continue watching),
|
||||||
// so Home asks again, as its marks do.
|
// so Home asks again, as its marks do.
|
||||||
const onResetState = (id: string) => {
|
const onClearProgress = (id: string) => {
|
||||||
void resetVideoState(qc, id)
|
void clearVideoProgress(qc, id)
|
||||||
.then(() => qc.invalidateQueries({ queryKey: qk.discover() }))
|
.then(() => qc.invalidateQueries({ queryKey: qk.discover() }))
|
||||||
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoReset")));
|
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoProgress")));
|
||||||
};
|
};
|
||||||
const videoActions = useVideoActions({ onState, onToggleSave, onResetState }, menu.open);
|
const videoActions = useVideoActions({ onState, onToggleSave, onClearProgress }, menu.open);
|
||||||
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
|
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
|
||||||
const plexActions = usePlexCardActions(
|
const plexActions = usePlexCardActions(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -570,14 +570,14 @@ async function rowMenu(title: string, item: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Playlists — a row's watch state", () => {
|
describe("Playlists — a row's watch state", () => {
|
||||||
it("resets a started video's view from its menu", async () => {
|
it("clears a started video's progress from its menu", async () => {
|
||||||
vi.spyOn(api, "clearState").mockResolvedValue(undefined as never);
|
vi.spyOn(api, "clearProgress").mockResolvedValue(undefined as never);
|
||||||
mount({ 1: [vid("A", 0), vid("B", 300)] });
|
mount({ 1: [vid("A", 0), vid("B", 300)] });
|
||||||
await rowMenu("Video B", "card.resetState");
|
await rowMenu("Video B", "card.clearProgress");
|
||||||
await waitFor(() => expect(api.clearState).toHaveBeenCalledWith("B"));
|
await waitFor(() => expect(api.clearProgress).toHaveBeenCalledWith("B"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers no reset on a video never started", async () => {
|
it("offers no clearing on a video never started", async () => {
|
||||||
mount({ 1: [vid("A", 0)] });
|
mount({ 1: [vid("A", 0)] });
|
||||||
const row = (await screen.findByRole("button", { name: "card.play — Video A" })).closest("tr")!;
|
const row = (await screen.findByRole("button", { name: "card.play — Video A" })).closest("tr")!;
|
||||||
fireEvent.contextMenu(row);
|
fireEvent.contextMenu(row);
|
||||||
@@ -587,7 +587,7 @@ describe("Playlists — a row's watch state", () => {
|
|||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getAllByRole("menuitem", { hidden: true })
|
.getAllByRole("menuitem", { hidden: true })
|
||||||
.some((b) => b.textContent === "card.resetState"),
|
.some((b) => b.textContent === "card.clearProgress"),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { useLatest, useStableCallback } from "../../lib/useLatest";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { isResettable } from "../../lib/api/feed";
|
import { hasResumePoint } from "../../lib/api/feed";
|
||||||
import {
|
import {
|
||||||
addPlaylistItem,
|
addPlaylistItem,
|
||||||
movePlaylistItem,
|
movePlaylistItem,
|
||||||
removePlaylistItem,
|
removePlaylistItem,
|
||||||
resetVideoState,
|
clearVideoProgress,
|
||||||
writeVideoState,
|
writeVideoState,
|
||||||
} from "../../features/video/videoWrites";
|
} from "../../features/video/videoWrites";
|
||||||
import {
|
import {
|
||||||
@@ -685,14 +685,14 @@ function PlaylistDetail() {
|
|||||||
return write;
|
return write;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back to fresh (the video's state row and resume point both go), from the row's menu.
|
// The video's resume point goes (its status stays), from the row's menu.
|
||||||
function resetRow(id: string) {
|
function clearRowProgress(id: string) {
|
||||||
resetVideoState(qc, id)
|
clearVideoProgress(qc, id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoReset")));
|
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoProgress")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure
|
// Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure
|
||||||
@@ -744,13 +744,13 @@ function PlaylistDetail() {
|
|||||||
onSelect: () => openPlayer(ctxRow.id),
|
onSelect: () => openPlayer(ctxRow.id),
|
||||||
},
|
},
|
||||||
...comfortPin.item(pinVideo(ctxRow.id), raw(ctxRow.title ?? "")),
|
...comfortPin.item(pinVideo(ctxRow.id), raw(ctxRow.title ?? "")),
|
||||||
...(isResettable(ctxRow)
|
...(hasResumePoint(ctxRow)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "reset",
|
id: "clearProgress",
|
||||||
label: t("card.resetState"),
|
label: t("card.clearProgress"),
|
||||||
icon: <Eraser className="w-4 h-4" />,
|
icon: <Eraser className="w-4 h-4" />,
|
||||||
onSelect: () => resetRow(ctxRow.id),
|
onSelect: () => clearRowProgress(ctxRow.id),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
Reference in New Issue
Block a user