fix: share, upload, crop, auto-skip, tag chips and See all hold what they claim

- Share: a link that expired is never copied (the dialog and one-click Share pick a live one,
  else make one), says it expired, and the copy-on-open claim clears on the next copy.
- A password-protected file link asks to open a file, not to watch a video.
- Upload: Cancel, the close button or the backdrop while naming a folder makes no folder; a failed
  refetch keeps the target; a folder being made says so.
- Crop: an infinite number is dropped, not a 500; an unprobed clip's size follows a shown-size crop;
  the edit_spec docs name frame_w/frame_h; the join and rotated paths are tested.
- Auto-skip: the wait gives way on a marker too short for it, so the skip still fires.
- Tag chips count the followed channels (subscribed_count) and a toggled chip keeps the focus.
- Continue watching's See all sorts by last watched when in progress was already on.
This commit is contained in:
2026-11-06 00:09:39 +01:00
parent c34f6f3297
commit 8383459f70
39 changed files with 760 additions and 82 deletions
+20 -4
View File
@@ -11,7 +11,9 @@ with the worker branching on the asset's `source_kind` to run ffmpeg instead of
(the `-ss` seeks first), so a short clip is cheap even from a long source
* crop (± trim) → always re-encode (a filter can't be stream-copied), frame-accurate
`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h}, accurate?: bool}`. `accurate` is the
`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h, frame_w?, frame_h?}, accurate?: bool}`. A
crop with `frame_w`/`frame_h` counts the picture as it is SHOWN, measured on a frame of that size
(`crop_filter`); one without them is in the file's stored pixels (its older form). `accurate` is the
per-edit user choice for a trim-only cut (ignored when a crop forces a re-encode anyway). `split`
is a frontend concept: the
UI fans a split out into N trim jobs, so there is one output file per job here.
@@ -65,8 +67,9 @@ def _crop_frame(crop: dict) -> dict | None:
spec from before it was sent, which `crop_filter` cuts in the file's own pixels)."""
try:
w, h = int(crop["frame_w"]), int(crop["frame_h"])
# silent-ok: no frame is a crop's older form, and it is still the crop that was asked for.
except (KeyError, TypeError, ValueError):
# silent-ok: no frame is a crop's older form, and it is still the crop that was asked for. An
# infinite number (JSON `1e400`) is OverflowError, and it is no more a frame than "x" is.
except (KeyError, TypeError, ValueError, OverflowError):
return None
return {"frame_w": w, "frame_h": h} if w > 0 and h > 0 else None
@@ -85,6 +88,19 @@ def crop_filter(crop: dict) -> str:
)
def crop_stored_size(crop: dict, src_w: int | None, src_h: int | None) -> tuple[int, int] | None:
"""The stored size `crop_filter` cuts out of a `src_w` x `src_h` file, evened as `EVEN_FRAME`
evens it — the clip's size when ffprobe cannot tell. A crop counted in the shown picture is a
fraction of the file's frame, not its own pixel count; None when the source size is unknown."""
if crop.get("frame_w") and crop.get("frame_h"):
if not src_w or not src_h:
return None
w, h = src_w * crop["w"] // crop["frame_w"], src_h * crop["h"] // crop["frame_h"]
else:
w, h = crop["w"], crop["h"]
return w // 2 * 2, h // 2 * 2
def normalize_edit_spec(spec: dict | None) -> dict:
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
@@ -97,7 +113,7 @@ def normalize_edit_spec(spec: dict | None) -> dict:
if crop:
try:
c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")}
except (KeyError, TypeError, ValueError) as exc:
except (KeyError, TypeError, ValueError, OverflowError) as exc:
# The clip still renders — uncropped. The user asked for a crop, got a whole frame,
# and nothing anywhere called it a failure; this line is the only trace there is.
log.warning(
+13 -6
View File
@@ -127,18 +127,25 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
# --- serialization -------------------------------------------------------------------------
def share_path(db: Session, link: DownloadLink, token: str) -> str:
"""The address a link is given out on: its path says what it opens. Only a word for the reader,
the token is the whole key: every prefix of `main.SHARE_PREFIXES` opens every link, so the links
already sent on /watch/ work for good. A file still being made reads as the video it mostly is."""
def opens(db: Session, link: DownloadLink) -> str:
"""What a link opens, in the words its address and its preview use: "folder", "video" or
"file". A file still being made reads as the video it mostly is."""
from app.downloads import mediakind
if link.folder_id is not None:
return f"/folder/{token}"
return "folder"
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
kind = asset.media_kind if asset else None
return f"/file/{token}" if kind and kind != mediakind.VIDEO else f"/watch/{token}"
return "file" if kind and kind != mediakind.VIDEO else "video"
def share_path(db: Session, link: DownloadLink, token: str) -> str:
"""The address a link is given out on: its path says what it opens (`opens`). Only a word for the
reader, the token is the whole key: every prefix of `main.SHARE_PREFIXES` opens every link, so
the links already sent on /watch/ work for good."""
prefix = {"folder": "folder", "file": "file", "video": "watch"}[opens(db, link)]
return f"/{prefix}/{token}"
def owner_view(db: Session, link: DownloadLink, token: str | None = None) -> dict:
+3 -1
View File
@@ -234,7 +234,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
source_asset_id: Mapped[int | None] = mapped_column(
ForeignKey("media_assets.id", ondelete="SET NULL")
)
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}} in pixels. For clip
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h,frame_w?,frame_h?}}, its
# crop in the SHOWN picture's pixels when it carries the frame it was measured on (`edit.crop_filter`),
# else in the file's stored ones. For clip
# jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop
# in fractions of the box it was drawn in (`downloads.clip.normalize`).
edit_spec: Mapped[dict | None] = mapped_column(JSON)
+3 -3
View File
@@ -77,9 +77,9 @@ def _watch_tags(token: str, db: Session) -> str | None:
if link is None or linksmod.is_expired(link):
return None
url = f"{settings.app_base}{linksmod.share_path(db, link, token)}"
folder = link.folder_id is not None
# What the card calls it, as its address does (`links.share_path`).
video = "/watch/" in url
# What the card calls it, as its address does.
kind = linksmod.opens(db, link)
folder, video = kind == "folder", kind == "video"
if link.password_hash:
# Password-gated: don't leak the title/thumbnail in the unfurl.
return "\n ".join(
+4 -4
View File
@@ -914,13 +914,13 @@ def _process_edit(job_id: int, asset_id: int) -> None:
asset.size_bytes = size
asset.container = out_ext
asset.duration_s = total_s or asset.duration_s
cut = editmod.crop_stored_size(edit_spec["crop"], src_w, src_h) if crop else None
if out_w and out_h:
asset.width, asset.height = out_w, out_h
elif crop:
asset.width = edit_spec["crop"]["w"]
asset.height = edit_spec["crop"]["h"]
elif cut:
asset.width, asset.height = cut
else:
asset.width, asset.height = src_w, src_h
asset.width, asset.height = (None, None) if crop else (src_w, src_h)
asset.error = None
asset.last_access_at = datetime.now(timezone.utc)
job = db.get(DownloadJob, job_id)
+16 -4
View File
@@ -41,11 +41,23 @@ def video(db: Session, video_id: str) -> Video | None:
# --- tags ------------------------------------------------------------------------------------
def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int]]:
"""The tags this user sees (the shared system ones and their own), each with how many channel
links it has, by category then name."""
def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int, int]]:
"""The tags this user sees (the shared system ones and their own), by category then name, each
with how many channel links it has and how many of the user's own links sit on a channel they
subscribe to — the rows the Channels page's tag filter shows. A link outlives an unsubscribe,
so the two differ."""
subscribed = select(Subscription.channel_id).where(owned_by(Subscription.user_id, user))
on_subscribed = dict(
db.execute(
select(ChannelTag.tag_id, func.count(ChannelTag.id))
.where(owned_by(ChannelTag.user_id, user), ChannelTag.channel_id.in_(subscribed))
.group_by(ChannelTag.tag_id)
)
.tuples()
.all()
)
return [
(tag, count)
(tag, count, on_subscribed.get(tag.id, 0))
for tag, count in db.execute(
select(Tag, func.count(ChannelTag.id))
.outerjoin(ChannelTag, ChannelTag.tag_id == Tag.id)
+9 -2
View File
@@ -176,8 +176,15 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak)."""
link = _link_or_404(db, token)
if link.password_hash:
# Which kind it opens, for the password prompt's title — never what is in it.
return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"}
# Which kind it opens, for the password prompt's words — never what is in it. `video` tells
# a video (watched) from any other file (opened); the address is only a hint, every
# prefix opens every link.
opens = linksmod.opens(db, link)
return {
"needs_password": True,
"kind": "folder" if opens == "folder" else "file",
"video": opens == "video",
}
meta = _opened(db, link, token)
db.commit()
return meta
+2 -1
View File
@@ -48,8 +48,9 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db))
"category": tag.category,
"system": tag.user_id is None,
"channel_count": count,
"subscribed_count": subscribed,
}
for tag, count in queries.tags_with_counts(db, user)
for tag, count, subscribed in queries.tags_with_counts(db, user)
]
+80 -4
View File
@@ -20,8 +20,10 @@ from app.downloads.models import DownloadJob, MediaAsset
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def _source(path: Path, size: str, box: str, sar: int) -> None:
"""Red, with the picture's middle half green: a crop of that middle holds no red."""
def _source(path: Path, size: str, box: str, sar: int, rotate: int = 0) -> None:
"""Red, with a green box: a crop of the box holds no red. `rotate` tags the file to be shown
turned by that many degrees counter-clockwise, as a phone records a portrait video."""
tag = ["-display_rotation:v:0", str(rotate)] if rotate else []
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
@@ -32,6 +34,13 @@ def _source(path: Path, size: str, box: str, sar: int) -> None:
],
check=True,
) # fmt: skip
if tag:
tagged = path.with_name("tagged-" + path.name)
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", *tag, "-i", str(path), "-c", "copy", str(tagged)],
check=True,
) # fmt: skip
tagged.replace(path)
def _red_and_green(path: Path) -> tuple[int, int]:
@@ -50,13 +59,14 @@ def _red_and_green(path: Path) -> tuple[int, int]:
return red, green
def _cut(db, make_user, tmp_path, src: Path, crop: dict) -> Path:
def _cut_asset(db, make_user, src: Path, spec: dict, then=lambda: None) -> MediaAsset:
user = make_user()
source = uploadmod.ingest_upload(
db, user.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
)
assert source.status == "done"
job = service.enqueue_edit(db, user.id, source, {"crop": crop})
then()
job = service.enqueue_edit(db, user.id, source, spec)
asset = db.get(MediaAsset, job.asset_id)
job.status, asset.status = "running", "downloading" # the worker's claim
db.commit()
@@ -66,6 +76,11 @@ def _cut(db, make_user, tmp_path, src: Path, crop: dict) -> Path:
db.expire_all()
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
assert (job.status, job.error, asset.status) == ("done", None, "ready")
return asset
def _cut(db, make_user, tmp_path, src: Path, crop: dict, **spec) -> Path:
asset = _cut_asset(db, make_user, src, {"crop": crop, **spec})
return storage.abs_path(str(tmp_path), asset.rel_path)
@@ -110,3 +125,64 @@ def test_a_crop_stored_before_it_carried_its_frame_is_cut_as_it_always_was():
def test_a_frame_that_cannot_be_read_is_not_divided_by(frame):
spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50, **frame}})
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
def test_a_crop_of_a_join_keeps_what_the_frame_showed(db, make_user, tmp_path, monkeypatch):
"""A cut-list joins through `filter_complex`, a crop path of its own."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
final = _cut(
db, make_user, tmp_path, src,
{"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240},
segments=[{"start_s": 0, "end_s": 0.5}, {"start_s": 1, "end_s": 1.5}], accurate=True,
) # fmt: skip
assert _red_and_green(final) == (0, 160 * 120)
def test_a_crop_of_a_rotated_video_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch):
"""The browser shows a rotated file upright, so the frame was drawn on the upright picture."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
# Stored 320 x 240, green on its left half; shown turned a quarter, 240 x 320.
_source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90)
# Turned counter-clockwise, the stored left half is the shown bottom half.
final = _cut(
db, make_user, tmp_path, src,
{"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320},
) # fmt: skip
assert _red_and_green(final) == (0, 240 * 160)
def test_a_clip_ffprobe_cannot_size_gets_the_size_its_crop_cut(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
def probe_fails():
monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5)
asset = _cut_asset(
db, make_user, src,
{"crop": {"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240}},
then=probe_fails,
) # fmt: skip
# Half the shown width of a 320-wide file: 160 stored, not the 320 the frame counted.
assert (asset.width, asset.height) == (160, 120)
@pytest.mark.parametrize("key", ["frame_w", "x"])
def test_an_infinite_number_in_a_crop_is_dropped_not_a_crash(key):
crop = {"x": 4, "y": 6, "w": 100, "h": 50, "frame_w": 640, "frame_h": 240}
spec = edit.normalize_edit_spec({"crop": {**crop, key: float("inf")}})
if key == "frame_w":
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
else:
assert "crop" not in spec
+2 -1
View File
@@ -150,6 +150,7 @@ def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client):
assert client.get(f"/api/public/watch/{token}").json() == {
"needs_password": True,
"kind": "folder",
"video": False,
}
bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file"
assert client.get(bare).status_code == 403
@@ -312,7 +313,7 @@ def test_a_password_file_link_says_it_opens_a_file(shelf, client):
.json()
)
meta = client.get(f"/api/public/watch/{made['token']}").json()
assert meta == {"needs_password": True, "kind": "file"}
assert meta == {"needs_password": True, "kind": "file", "video": True}
def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db):
+20 -2
View File
@@ -14,7 +14,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.discover import pins
from app.catalog.models import Channel, Video
from app.catalog.models import Channel, Subscription, Video
from app.reader import queries, service
from app.reader.models import ChannelTag, ComfortPin, Playlist, PlaylistItem, Tag, VideoState
@@ -60,12 +60,30 @@ def test_a_system_tag_is_nobodys_to_edit(db, make_user):
service.create_tag(db, other, "Theirs", "other", None) # another user's tag stays theirs
db.commit()
assert queries.own_tag(db, user, system.id) is None
assert [t.id for t, _ in queries.tags_with_counts(db, user)] == [system.id]
assert [t.id for t, *_ in queries.tags_with_counts(db, user)] == [system.id]
# --- tags ------------------------------------------------------------------------------------
def test_a_tag_counts_apart_the_links_on_channels_the_user_still_follows(db, make_user):
"""An unsubscribe keeps the channel's tag links; the Channels page's filter lists only followed
channels, so its count must not hold the others."""
user, other = make_user(), make_user()
tag = service.create_tag(db, user, "Music", "other", None)
for cid in ("UCa", "UCb", "UCgone"):
db.add(Channel(id=cid, title=cid))
db.flush()
for cid in ("UCa", "UCb"):
db.add(Subscription(user_id=user.id, channel_id=cid))
db.add(Subscription(user_id=other.id, channel_id="UCgone"))
for cid in ("UCa", "UCb", "UCgone"):
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=user.id, source="user"))
db.commit()
[(_, count, subscribed)] = [r for r in queries.tags_with_counts(db, user) if r[0].id == tag.id]
assert (count, subscribed) == (3, 2)
def test_a_tag_is_made_once_per_name_and_an_unknown_category_is_other(db, make_user):
user = make_user()
tag = service.create_tag(db, user, "Later", "nonsense", "#fff")
+11
View File
@@ -135,3 +135,14 @@ def test_a_card_of_a_video_stays_a_video(shared, db):
assert f"/watch/{made['token']}" in tags and "video.other" in tags
locked = shared("other.mp4", _VIDEO, password="pw")
assert "Password-protected video" in og._watch_tags(locked["token"], db)
@pytest.mark.parametrize(
("name", "data", "video"),
[("clip.mp4", _VIDEO, True), ("song.m4a", _AUDIO, False), ("backup.tar.gz", None, False)],
)
def test_a_password_link_says_whether_it_opens_a_video(shared, client, name, data, video):
"""The lock screen asks to watch a video and to open anything else, whichever path it is on."""
token = shared(name, data, password="pw")["token"]
meta = client.get(f"/api/public/watch/{token}").json()
assert meta == {"needs_password": True, "kind": "file", "video": video}
+2
View File
@@ -863,4 +863,6 @@ of a full-width panel and nothing on the right.
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found |
| 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go |
| 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in |
| 2026-11-04 | Tag chip counts (owner D5a) | A Channels tag chip shows how many followed channels it holds: a muted number after the name, no badge | The number in brackets after the name | The owner's pick; the number is a detail of the name, so it takes the name's own text, muted, rather than a `CountBadge` |
| 2026-11-05 | Picked tag chips lead the row (owner c) | On the Channels tag row the picked chips come first, in the tags' order, then the rest | Keep the order and fold the unpicked first; mark the "+N" when it holds a pick | The row folds from its end, so a picked tag late in the list sat only inside "+N" with nothing on the row saying a filter was on |
| 2026-11-05 | A toggled tag chip keeps the focus (owner a) | A chip that moves when toggled takes the keyboard focus with it: to its new place, or to "+N" when it went into the closed fold | Keep a picked chip in the "+N" popover until it closes | The row always shows the filters as they are; the focus, not the order, was what got lost |
+1
View File
@@ -231,6 +231,7 @@ export default function Modal({
size="sm"
variant="ghost"
onClick={onClose}
data-dismiss=""
>
<X className="w-4 h-4" />
</IconButton>
@@ -45,6 +45,7 @@ const tag = (id: number, name: string): Tag => ({
category: "other",
system: false,
channel_count: 0,
subscribed_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
@@ -33,6 +33,7 @@
"added": "File uploaded",
"addedConverting": "File uploaded — converting it so it plays on any phone",
"targetLabel": "Upload to",
"makingFolder": "Making “{{name}}”…",
"targetFailed": "The folders could not be loaded",
"failed": "Couldn't upload the file"
},
@@ -113,6 +114,7 @@
"revokeConfirm": "This link will stop working immediately.",
"views_one": "{{count}} view",
"views_other": "{{count}} views",
"expiredOn": "expired {{date}}",
"expiresOn": "expires {{date}}",
"hasPassword": "password",
"downloadable": "downloadable",
@@ -33,6 +33,7 @@
"added": "Fájl feltöltve",
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen",
"targetLabel": "Feltöltés ide",
"makingFolder": "„{{name}}” létrehozása…",
"targetFailed": "A mappákat nem sikerült betölteni",
"failed": "Nem sikerült feltölteni a fájlt"
},
@@ -113,6 +114,7 @@
"revokeConfirm": "A link azonnal használhatatlanná válik.",
"views_one": "{{count}} megtekintés",
"views_other": "{{count}} megtekintés",
"expiredOn": "lejárt: {{date}}",
"expiresOn": "lejár: {{date}}",
"hasPassword": "jelszó",
"downloadable": "letölthető",
+4
View File
@@ -7,7 +7,11 @@ export interface Tag {
color: string | null;
category: string;
system: boolean;
/** Every channel link it has, a channel the user no longer follows included (an unsubscribe keeps
* the link) — what deleting the tag removes. */
channel_count: number;
/** The user's own links on channels they follow: the rows the Channels page's filter shows. */
subscribed_count: number;
}
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern.
+16
View File
@@ -94,6 +94,22 @@ describe("quickShareLink", () => {
expect(createDownloadLink).not.toHaveBeenCalled();
});
// An expired link stays listed for its owner, but its recipient would land on "link gone".
it("passes over an expired link, and mints one when every link has expired", async () => {
const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString();
const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString();
downloadLinks.mockResolvedValue([
{ id: 5, url: "/watch/gone", expires_at: lastWeek },
{ id: 3, url: "/watch/live", expires_at: nextWeek },
]);
expect(await quickShareLink(qc(), { kind: "file", id: 1 })).toBe(`${origin}/watch/live`);
expect(createDownloadLink).not.toHaveBeenCalled();
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/gone", expires_at: lastWeek }]);
createDownloadLink.mockResolvedValue({ id: 9, url: "/watch/fresh" });
expect(await quickShareLink(qc(), { kind: "file", id: 2 })).toBe(`${origin}/watch/fresh`);
});
it("mints a new link when none it has can be shown, keeping the old ones listed", async () => {
const hidden = { id: 4, url: null };
downloadLinks.mockResolvedValue([hidden]);
+12 -3
View File
@@ -1,6 +1,6 @@
import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type NewShareLink } from "./api";
import { api, type NewShareLink, type ShareLink } from "./api";
import { copyText } from "./clipboard";
import { msg, notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
@@ -38,6 +38,16 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
}
/** Has a link stopped opening? The list still holds an expired link (its owner may want to see or
* revoke it), but it is no link to hand out. */
export const linkExpired = (link: ShareLink, now = Date.now()) =>
link.expires_at !== null && Date.parse(link.expires_at) <= now;
/** The link to hand out of a list (newest first): the newest that still opens and whose address can
* be shown — an expired one sends the recipient to "link gone", one without an address is no use
* to copy. None: make one. */
export const liveLink = (links: ShareLink[]) => links.find((l) => l.url && !linkExpired(l));
// One-click share: the row's Share button turns "share this" into a single action.
// Ensure the file or folder has a public link (minting one on demand — omitting `allow_download` so
// the backend default makes it downloadable), copy it to the clipboard, and toast with an "open in
@@ -51,8 +61,7 @@ export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promi
queryKey: linksKey(target),
queryFn: () => fetchLinks(target),
});
// The newest link whose address can still be shown; one that cannot is no use to copy.
path = existing.find((l) => l.url)?.url;
path = liveLink(existing)?.url;
if (!path) {
const made = await makeLink(target, {}); // omit allow_download → backend default (true)
qc.setQueryData(linksKey(target), [made, ...existing]);
@@ -0,0 +1,16 @@
// @vitest-environment jsdom
//
// An auto-skip's countdown starts below zero: the wait before it, while the Skip button already
// shows. The bar it draws must read empty through the wait, not a negative width.
import { describe, expect, it } from "vitest";
import { skipCountdown } from "./plexPlayerHooks";
describe("skipCountdown", () => {
it("stays empty through the wait, runs over the delay, and stops at full", () => {
expect(skipCountdown(-3, 5)).toBe(0);
expect(skipCountdown(-0.1, 5)).toBe(0);
expect(skipCountdown(2.5, 5)).toBe(0.5);
expect(skipCountdown(7, 5)).toBe(1);
});
});
+18 -3
View File
@@ -434,8 +434,18 @@ export function usePlayerVolume(opts: {
return { setVol, nudgeVolume, toggleMute, onWheel };
}
// How long a marker plays before an auto-skip's countdown starts (seconds).
// How long a marker plays before an auto-skip's countdown starts (seconds), at most: on a marker
// too short for both, the wait gives way, so the countdown still ends inside it (`useAutoSkip`).
const AUTO_SKIP_WAIT_S = 3;
// What the countdown keeps in hand before the marker's end: it ticks on wall time, the playhead on
// the media's, and the marker stops counting a second before its end.
const AUTO_SKIP_SLACK_S = 0.5;
/** How far an auto-skip's countdown has run (0→1) after `elapsed` playing seconds of it. Through
* the wait `elapsed` is below zero, and the bar it draws stays empty: a negative width is invalid
* CSS, which the browser drops and keeps whatever it drew before. */
export const skipCountdown = (elapsed: number, delay: number) =>
Math.max(0, Math.min(1, elapsed / delay));
// Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — to the marker end, or
@@ -484,6 +494,7 @@ export function useAutoSkip(opts: {
(m) => abs >= m.start_s && abs < m.end_s - 1,
);
const activeMarkerRef = useLatest(activeMarker);
const absRef = useLatest(abs);
// A boolean, not the list, goes into the countdown's deps: a refetch hands back an equal-but-new
// list and must not restart a countdown under way (see below); doSkip reads the list from a ref.
const activeAuto = activeMarker != null && markerSkip(activeMarker, markers ?? []).auto;
@@ -543,14 +554,17 @@ export function useAutoSkip(opts: {
if (autoSkipDelay <= 0) return doSkip(m); // immediate
// Below zero is the wait: time to see where the marker has landed and press the button, which
// shows from the start, before anything counts down to leaving. A delay of 0 asked for none.
let elapsed = -AUTO_SKIP_WAIT_S;
// It is cut to what the marker has left over the countdown (a short intro, or one resumed near
// its end): a wait the countdown cannot finish after is an auto-skip that never fires.
const left = m.end_s - 1 - absRef.current - autoSkipDelay - AUTO_SKIP_SLACK_S;
let elapsed = -Math.max(0, Math.min(AUTO_SKIP_WAIT_S, left));
setSkipProgress(0);
const iv = window.setInterval(() => {
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
// deliberately-paused video. It resumes from where it stopped when playback resumes.
if (videoRef.current?.paused) return;
elapsed += 0.1;
const p = Math.max(0, Math.min(1, elapsed / autoSkipDelay));
const p = skipCountdown(elapsed, autoSkipDelay);
setSkipProgress(p);
if (p >= 1) {
window.clearInterval(iv);
@@ -573,6 +587,7 @@ export function useAutoSkip(opts: {
doSkip,
videoRef,
activeMarkerRef,
absRef,
activeAuto,
]);
+1
View File
@@ -25,6 +25,7 @@ const tag = (id: number, name: string, system: boolean): Tag => ({
color: null,
category: "topic",
channel_count: 1,
subscribed_count: 1,
});
function wrapper({ children }: { children: ReactNode }) {
@@ -11,7 +11,8 @@
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
// measurement can only ever produce one answer.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -114,4 +115,76 @@ describe("TagFilterChips counts", () => {
expect(name("alpha")?.textContent).toBe("alpha42");
expect(name("beta")?.textContent).toBe("beta0");
});
it("draws nothing after the name of a chip that has no count", () => {
fit.visible = 3;
render(<TagFilterChips chips={[{ ...chip("alpha"), count: 42 }, chip("gamma")]} />);
const gamma = screen.getAllByRole("button").find((b) => b.textContent?.startsWith("gamma"));
expect(gamma?.textContent).toBe("gamma");
expect(gamma?.querySelector("span")).toBeNull();
});
});
// The channel manager puts the picked chips first, so a toggle moves its chip: along the row, out
// of the "+N" fold, or into it. The keyboard's focus goes with the chip, not to <body>.
describe("TagFilterChips focus on a toggle that moves the chip", () => {
function Row({ start }: { start: string[] }) {
const [picked, setPicked] = useState(start);
const all = ["alpha", "beta", "gamma"].map((k) => ({
...chip(k),
active: picked.includes(k),
onToggle: () => setPicked((p) => (p.includes(k) ? p.filter((x) => x !== k) : [...p, k])),
}));
return (
<TagFilterChips chips={[...all.filter((c) => c.active), ...all.filter((c) => !c.active)]} />
);
}
const press = (el: HTMLElement) => {
el.focus();
fireEvent.click(el);
};
const named = (label: string) =>
screen.getAllByRole("button", { hidden: true }).find((b) => b.textContent === label)!;
it("along the row: the chip keeps the focus where it now stands", () => {
fit.visible = 3;
render(<Row start={[]} />);
press(named("gamma"));
expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([
"gamma",
"alpha",
"beta",
]);
expect(document.activeElement).toBe(named("gamma"));
});
it("out of the fold: picked in the popover, it is focused on the row", () => {
fit.visible = 1;
render(<Row start={[]} />);
fireEvent.click(moreBtn()!);
const inFold = named("beta");
press(inFold);
const onRow = named("beta");
expect(onRow).not.toBe(inFold);
expect(document.activeElement).toBe(onRow);
});
it("into a closed fold: the focus goes to the +N that now holds it", () => {
fit.visible = 1;
render(<Row start={["gamma"]} />);
press(named("gamma"));
expect(named("alpha").closest('[role="menu"]')).toBeNull(); // alpha leads the row now
expect(document.activeElement).toBe(moreBtn());
});
it("a mouse press that leaves the focus elsewhere takes nothing", () => {
fit.visible = 3;
render(<Row start={[]} />);
const outside = document.body.appendChild(document.createElement("input"));
outside.focus();
fireEvent.click(named("gamma"));
act(() => {});
expect(document.activeElement).toBe(outside);
outside.remove();
});
});
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
@@ -39,9 +39,18 @@ function ChipText({ chip }: { chip: FilterChip }) {
);
}
function TagChip({ chip }: { chip: FilterChip }) {
function TagChip({ chip, onToggled }: { chip: FilterChip; onToggled: (key: string) => void }) {
return (
<Chip onClick={chip.onToggle} pressed={chip.active}>
<Chip
data-chip-key={chip.key}
onClick={(e) => {
// Before the toggle: a pressed chip may move (the caller's order can put picks first), and
// the focus must go where it went.
if (e.currentTarget === document.activeElement) onToggled(chip.key);
chip.onToggle();
}}
pressed={chip.active}
>
<ChipText chip={chip} />
</Chip>
);
@@ -104,6 +113,32 @@ export default function TagFilterChips({
const { setReference } = pop.refs;
const moreLabel = t("channels.tags.moreCount", { count: hidden });
// A toggle may move its chip — between the row and the "+N" fold (two parents: the button is
// remounted), or along the row (a keyed move that drops the focus to <body>). The focus follows
// it: onto the chip where it now stands, or onto "+N" when it went into a closed fold. Held for
// the frame, since the fit re-decides in layout effects after the toggle's render.
const follow = useRef<string | null>(null);
const foldRef = useRef<HTMLDivElement | null>(null);
const moreRef = useRef<HTMLButtonElement | null>(null);
const onToggled = (chipKey: string) => {
follow.current = chipKey;
requestAnimationFrame(() => (follow.current = null));
};
useLayoutEffect(() => {
const chipKey = follow.current;
if (!chipKey) return;
const find = (box: HTMLElement | null) =>
[...(box?.querySelectorAll<HTMLElement>("[data-chip-key]") ?? [])].find(
(b) => b.dataset.chipKey === chipKey,
);
const el = find(rowRef.current) ?? find(foldRef.current) ?? moreRef.current;
if (el && document.activeElement !== el) el.focus();
});
const setMore = (el: HTMLButtonElement | null) => {
moreRef.current = el;
setReference(el);
};
return (
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
@@ -132,10 +167,10 @@ export default function TagFilterChips({
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => (
<TagChip key={c.key} chip={c} />
<TagChip key={c.key} chip={c} onToggled={onToggled} />
))}
{hidden > 0 && (
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
<Chip ref={setMore} aria-label={moreLabel} {...pop.getReferenceProps()}>
+{hidden}
</Chip>
)}
@@ -146,9 +181,11 @@ export default function TagFilterChips({
ariaLabel={moreLabel}
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
>
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} />
))}
<div ref={foldRef} className="contents">
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} onToggled={onToggled} />
))}
</div>
</PopoverSurface>
</div>
);
@@ -188,7 +188,9 @@ describe("buildChannelColumns — Added", () => {
});
describe("tagFilterChips", () => {
it("counts a tag's channels, and only a tag's", () => {
// An unsubscribe keeps a channel's tag links: the chip counts the followed ones, the rows the
// filter shows, not every link the tag has.
it("counts a tag's followed channels, and only a tag's", () => {
const tag = {
id: 3,
name: "Tech",
@@ -196,6 +198,7 @@ describe("tagFilterChips", () => {
category: "topic",
system: false,
channel_count: 17,
subscribed_count: 15,
};
const chips = tagFilterChips({
userTags: [tag],
@@ -206,7 +209,7 @@ describe("tagFilterChips", () => {
toggleNoTag: () => {},
t: ((k: string) => k) as never,
});
expect(chips.find((c) => c.label === "Tech")?.count).toBe(17);
expect(chips.find((c) => c.label === "Tech")?.count).toBe(15);
expect(chips.find((c) => c.key === "t99")?.count).toBeUndefined();
expect(chips[chips.length - 1]?.count).toBeUndefined();
});
@@ -72,7 +72,7 @@ export function tagFilterChips(o: {
...o.userTags.map((tg) => ({
key: `t${tg.id}`,
label: tg.name,
count: tg.channel_count,
count: tg.subscribed_count,
active: o.picked.includes(String(tg.id)),
onToggle: () => o.toggle(tg.id),
})),
@@ -36,6 +36,7 @@ const tag = (id: number, name: string): Tag => ({
category: "other",
system: false,
channel_count: 0,
subscribed_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
@@ -110,24 +110,58 @@ describe("ShareDialog · opened to share", () => {
</QueryClientProvider>,
);
};
const row = (id: number, url: string | null) => ({
const row = (id: number, url: string | null, expires_at: string | null = null) => ({
id,
token: "t",
url,
allow_download: true,
has_password: false,
expires_at: null,
expires_at,
view_count: 0,
created_at: null,
});
const origin = window.location.origin;
const status = () => screen.getByRole("status").textContent;
const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString();
const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString();
it("copies the newest link it can show, and says so", async () => {
h.links = [row(1, null), row(2, "/file/b"), row(3, "/file/c")];
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
expect(h.copyText).toHaveBeenCalledTimes(1);
expect((await screen.findByRole("status")).textContent).toBe("downloads.share.linkCopied");
await waitFor(() => expect(status()).toBe("downloads.share.linkCopied"));
});
// The list keeps an expired link for its owner to see and revoke; the recipient of one lands on
// "link gone".
it("copies the newest link that still opens, and says an expired one has expired", async () => {
h.links = [row(1, "/file/old", lastWeek), row(2, "/file/b", nextWeek)];
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
expect(h.copyText).toHaveBeenCalledTimes(1);
expect(screen.getByText("downloads.share.expiredOn", { exact: false })).toBeTruthy();
expect(screen.getByText("downloads.share.expiresOn", { exact: false })).toBeTruthy();
});
it("makes a link when every one it has expired, and copies that", async () => {
h.links = [row(1, "/file/old", lastWeek)];
h.createDownloadLink.mockResolvedValue(row(4, "/file/new"));
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`));
expect(h.copyText).toHaveBeenCalledTimes(1);
});
// The clipboard holds what was copied last: the link copied on opening stops saying so.
it("stops claiming the opening copy once another link is copied", async () => {
h.links = [row(2, "/file/b"), row(3, "/file/c")];
open();
await waitFor(() => expect(status()).toBe("downloads.share.linkCopied"));
expect(screen.getAllByText("downloads.share.linkCopied")).toHaveLength(2); // the row + the region
fireEvent.click(screen.getAllByRole("button", { name: "downloads.share.copy" })[1]!);
await waitFor(() => expect(h.copyText).toHaveBeenLastCalledWith(`${origin}/file/c`));
expect(status()).toBe("");
expect(screen.queryByText("downloads.share.linkCopied")).toBeNull();
});
it("copies the link it made when there was none", async () => {
@@ -143,7 +177,8 @@ describe("ShareDialog · opened to share", () => {
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalled());
await screen.findByRole("button", { name: "downloads.share.copy" });
expect(screen.queryByRole("status")).toBeNull();
expect(status()).toBe("");
expect(screen.queryByText("downloads.share.linkCopied")).toBeNull();
});
it("offers the link in a new tab, beside Copy", async () => {
+25 -11
View File
@@ -12,7 +12,9 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../.
import {
fetchLinks,
invalidateLinks,
linkExpired,
linksKey,
liveLink,
makeLink,
type LinkTarget,
} from "../../lib/downloads";
@@ -80,11 +82,14 @@ function UserShare({ job }: { job: DownloadJob }) {
function LinkRow({
link,
copiedOnOpen,
onCopied,
onChanged,
}: {
link: ShareLink;
/** The dialog put this link on the clipboard when it opened. */
/** The dialog put this link on the clipboard when it opened, and nothing was copied since. */
copiedOnOpen: boolean;
/** Its Copy was pressed: the clipboard holds this link now, not the one the dialog copied. */
onCopied: () => void;
onChanged: () => void;
}) {
const { t, i18n } = useTranslation();
@@ -95,7 +100,9 @@ function LinkRow({
const fullUrl = link.url && window.location.origin + link.url;
const copy = () => {
if (fullUrl) void copyValue(fullUrl);
if (!fullUrl) return;
onCopied();
void copyValue(fullUrl);
};
const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
@@ -124,7 +131,9 @@ function LinkRow({
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
if (link.expires_at)
badges.push(
t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }),
t(linkExpired(link) ? "downloads.share.expiredOn" : "downloads.share.expiresOn", {
date: formatDate(link.expires_at, i18n.language),
}),
);
return (
@@ -198,11 +207,9 @@ function LinkRow({
<Trash2 className="w-4 h-4 text-danger" />
</IconButton>
</div>
{copiedOnOpen && (
<p role="status" className="text-xs text-success">
{t("downloads.share.linkCopied")}
</p>
)}
{/* Seen here, heard from the dialog's own live region (`LinkShare`): one mounted already
filled may never be announced. */}
{copiedOnOpen && <p className="text-xs text-success">{t("downloads.share.linkCopied")}</p>}
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
<span className="inline-flex items-center gap-1">
<Eye className="w-3 h-3" /> {t("downloads.share.views", { count: link.view_count })}
@@ -314,7 +321,7 @@ function LinkShare({ target }: { target: LinkTarget }) {
});
// Open with a link already generated: sharing is almost always the goal, so mint a default
// (no password, no expiry, downloadable) link the moment the dialog finds none — as if the
// (no password, no expiry, downloadable) link the moment the dialog finds none that opens — as if the
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// extra links, revoke) stay available on the created link and via the "New link" button.
const autoTried = useRef(false);
@@ -327,13 +334,19 @@ function LinkShare({ target }: { target: LinkTarget }) {
useEffect(() => {
if (!links.data || autoTried.current) return;
autoTried.current = true;
if (links.data.length === 0)
// The link to hand out is one that still opens (`liveLink`, as the one-click Share picks it):
// with only expired ones, or none whose address can be shown, a new one is made as for none.
const live = liveLink(links.data);
if (!live)
createLink(undefined, { onSuccess: (made) => void copiedLink(made).then(setCopiedOnOpen) });
else void copiedLink(links.data.find((l) => l.url)).then(setCopiedOnOpen);
else void copiedLink(live).then(setCopiedOnOpen);
}, [links.data, createLink]);
return (
<div>
<p role="status" className="sr-only">
{copiedOnOpen !== null ? t("downloads.share.linkCopied") : ""}
</p>
<div className="flex items-center gap-2 text-sm font-medium mb-1">
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
</div>
@@ -360,6 +373,7 @@ function LinkShare({ target }: { target: LinkTarget }) {
key={l.id}
link={l}
copiedOnOpen={copiedOnOpen === l.id}
onCopied={() => setCopiedOnOpen(null)}
onChanged={invalidate}
/>
))}
@@ -186,6 +186,74 @@ describe("UploadDialog · where the file goes", () => {
expect(await sentTo()).toBe(7);
});
// The name field saves on blur, and a press on Cancel or ✕ blurs it before the dialog closes.
it.each([
["Cancel", () => screen.getByText("downloads.actions.cancel")],
["the close button", () => screen.getByRole("button", { name: "common.close" })],
])("dismissing with %s while naming makes no folder", async (_n, dismiss) => {
const onClose = vi.fn();
wrap(onClose);
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "Trips" } });
const control = dismiss();
fireEvent.pointerDown(control);
fireEvent.blur(field);
fireEvent.click(control);
expect(onClose).toHaveBeenCalled();
await new Promise((r) => setTimeout(r, 20)); // a mutation calls its function a tick later
expect(h.createFolder).not.toHaveBeenCalled();
});
it("leaving the name for anything else in the dialog makes the folder", async () => {
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
wrap();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "Trips" } });
fireEvent.pointerDown(screen.getByText("downloads.upload.targetLabel"));
fireEvent.blur(field);
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
});
it("says a new folder is being made while Upload waits for it", async () => {
h.createFolder.mockReturnValue(new Promise(() => {}));
wrap();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "Trips" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(await screen.findByText("downloads.upload.makingFolder")).toBeTruthy();
});
// A failed refetch keeps the list it had: the target the file goes to stays shown.
it("keeps the target when a later read of the folders fails", async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FilesActionsContext.Provider
value={{
setSearch: vi.fn(),
setView: vi.fn(),
openFile: h.openFile,
consumeFocus: vi.fn(),
}}
>
<UploadDialog folderId={5} initialFile={null} onClose={vi.fn()} />
</FilesActionsContext.Provider>
</QueryClientProvider>,
);
expect((await target()).textContent).toContain("peat");
h.fileFolders.mockRejectedValue(new Error("down"));
await qc.refetchQueries({ queryKey: ["file-folders"] }).catch(() => {});
await waitFor(() => expect(qc.getQueryState(["file-folders"])?.status).toBe("error"));
expect((await target()).textContent).toContain("peat");
expect(screen.queryByText("downloads.upload.targetFailed")).toBeNull();
});
it("an empty name makes no folder and leaves the target", async () => {
wrap(vi.fn(), 5);
fireEvent.click(await target());
+34 -9
View File
@@ -88,6 +88,20 @@ export default function UploadDialog({
refocusTarget.current = false;
targetBox.current?.querySelector("button")?.focus();
}, [naming]);
// A press on Cancel, the ✕ or the backdrop blurs the name field before the dialog closes, and the
// field saves on blur: that press is "never mind", not "make it". The press is seen before the
// blur it causes, in every browser (which control takes the focus, if any, differs: Safari
// focuses no button).
const dismissing = useRef(false);
useEffect(() => {
const onPress = (e: PointerEvent) => {
const el = e.target instanceof Element ? e.target : null;
dismissing.current =
!!el && (!!el.closest("[data-dismiss]") || el.matches('[role="dialog"]'));
};
document.addEventListener("pointerdown", onPress, true);
return () => document.removeEventListener("pointerdown", onPress, true);
}, []);
const makeFolder = useMutation({
mutationFn: (folder: string) => api.createFolder(folder),
onSuccess: (made) => {
@@ -113,8 +127,10 @@ export default function UploadDialog({
const pickTarget = ([value]: string[]) => {
// After the menu has closed: it hands the focus back as it goes, which took it off a field already
// there, and a name field that loses the focus is done, with no name.
if (value === NEW_FOLDER) window.setTimeout(() => setNaming(true));
else if (value === UPLOADS) setTarget(null);
if (value === NEW_FOLDER) {
dismissing.current = false; // a press before the naming began (Cancel aborting an upload) is not this one
window.setTimeout(() => setNaming(true));
} else if (value === UPLOADS) setTarget(null);
else setTarget(Number(value));
};
// A done file is in Files, opened on it; one the worker still converts is in the tray until it is.
@@ -234,11 +250,11 @@ export default function UploadDialog({
maxLength={DISPLAY_NAME_MAX}
/>
<p id="upload-target-label" className="block text-sm font-medium mt-4 mb-1">
{t("downloads.upload.targetLabel")}
</p>
{foldersQ.isError ? (
// The file still goes where the dialog was opened on: only the list could not be read.
<p className="block text-sm font-medium mt-4 mb-1">{t("downloads.upload.targetLabel")}</p>
{foldersQ.isError && !foldersQ.data ? (
// The file still goes where the dialog was opened on: only the list could not be read. A
// failed REFETCH keeps the list it had (react-query sets isError beside the data): the
// target the file goes to stays shown, as the rail does it.
<StateMessage tone="error" compact onRetry={() => void foldersQ.refetch()}>
{t("downloads.upload.targetFailed")}
</StateMessage>
@@ -249,12 +265,20 @@ export default function UploadDialog({
onDone={(folder, byKey) => {
refocusTarget.current = byKey;
setNaming(false);
if (!byKey && dismissing.current) return;
if (folder?.trim()) makeFolder.mutate(folder.trim());
}}
/>
) : (
<div ref={targetBox} aria-labelledby="upload-target-label">
{foldersQ.data && !busy && (
// The select's own height held while the list loads, so it does not push the form down.
<div ref={targetBox} className="min-h-8 flex items-center">
{makeFolder.isPending && (
// Upload waits for it (`targetSettling`): this says why.
<span className="text-sm text-muted">
{t("downloads.upload.makingFolder", { name: makeFolder.variables })}
</span>
)}
{foldersQ.data && !busy && !makeFolder.isPending && (
<FilterSelect
hideLabel
label={t("downloads.upload.targetLabel")}
@@ -300,6 +324,7 @@ export default function UploadDialog({
variant="ghost"
size="sm"
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
data-dismiss=""
>
{t("downloads.actions.cancel")}
</Button>
@@ -31,14 +31,18 @@ afterEach(() => {
vi.restoreAllMocks();
});
// Wide pixels: stored 960 across, shown 1920 — the browser reports the shown size, the asset the
// stored one, and the two must not be mistaken for each other.
const job = {
id: 7,
display_name: "Talk",
asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" },
asset: { duration_s: 100, width: 960, height: 1080, title: "Talk" },
} as unknown as DownloadJob;
describe("VideoEditor crop", () => {
it("sends the size the crop was measured on with it", async () => {
vi.spyOn(HTMLVideoElement.prototype, "videoWidth", "get").mockReturnValue(1920);
vi.spyOn(HTMLVideoElement.prototype, "videoHeight", "get").mockReturnValue(1080);
const enqueue = vi
.spyOn(api, "enqueueEdit")
.mockResolvedValue({ id: 8 } as unknown as DownloadJob);
@@ -0,0 +1,148 @@
// @vitest-environment jsdom
//
// Discover's See all opens the list its rail is the start of, sorted the way the rail is: Recently
// added newest first, Continue watching in progress by last watched. The rails only call back; what
// each opens is PlexBrowse's, against the Plex store's show and sort, which outlive the Discover /
// Browse toggle — so a sort picked earlier must not survive the press.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./plexSavedViews", async (importOriginal) => ({
...(await importOriginal<typeof import("./plexSavedViews")>()),
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }),
}));
// The rails themselves are PlexDiscover's own test: here only their two See all presses.
vi.mock("./PlexDiscover", () => ({
default: (p: { onSeeAllRecent: () => void; onSeeAllContinue: () => void }) => (
<div>
<button onClick={p.onSeeAllRecent}>recent</button>
<button onClick={p.onSeeAllContinue}>continue</button>
</div>
),
}));
import PlexBrowse from "./PlexBrowse";
import { api, EMPTY_PLEX_FILTERS } from "../../lib/api";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
import {
PlexActionsContext,
PlexStateContext,
type PlexActions,
type PlexState,
} from "./plexContext";
import type { SortState } from "../../lib/columnSort";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
beforeEach(() => window.history.replaceState({}, "", "/"));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const TITLE: SortState = { key: "title", dir: "asc" };
const slot = document.body.appendChild(document.createElement("div"));
// The store's show and sort, real enough for PlexBrowse's own effect to act on them.
function mount(start: { show: string; sort: SortState }) {
vi.spyOn(api, "plexLibrary").mockResolvedValue({ items: [], total: 0, offset: 0 } as never);
const now = { state: null as PlexState | null, actions: null as PlexActions | null };
function Store() {
const [show, setShow] = useState(start.show);
const [sort, setSort] = useState<SortState>(start.sort);
const state: PlexState = {
scope: "both",
show,
sort,
filters: EMPTY_PLEX_FILTERS,
q: "",
field: "all",
playlistOpen: null,
jumpTo: null,
} as PlexState;
const actions = {
setShow,
setSort,
setScope: () => {},
setFilters: () => {},
setQ: () => {},
setField: () => {},
setPlaylistOpen: () => {},
setJumpTo: () => {},
} as PlexActions;
now.state = state;
now.actions = actions;
return (
<PlexStateContext.Provider value={state}>
<PlexActionsContext.Provider value={actions}>
<PlexBrowse />
</PlexActionsContext.Provider>
</PlexStateContext.Provider>
);
}
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<Store />
</PageToolbarSlot.Provider>
</QueryClientProvider>,
);
return now;
}
describe("PlexBrowse · Discover's See all", () => {
it("Recently added opens every title, newest first, over a sort picked before", async () => {
const store = mount({ show: "in_progress", sort: TITLE });
fireEvent.click(await screen.findByText("recent"));
expect(store.state!.show).toBe("all");
expect(store.state!.sort).toEqual({ key: "added", dir: "desc" });
});
// Entering in-progress keeps the sort from before it, to give back on the way out: leaving for
// Recently added must not hand it back over newest first.
it("Recently added from in progress does not give back the sort from before it", async () => {
const store = mount({ show: "all", sort: TITLE });
act(() => store.actions!.setShow("in_progress"));
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
fireEvent.click(await screen.findByText("recent"));
expect(store.state!.show).toBe("all");
expect(store.state!.sort).toEqual({ key: "added", dir: "desc" });
});
it("Continue watching opens in progress, last watched first", async () => {
const store = mount({ show: "all", sort: TITLE });
fireEvent.click(await screen.findByText("continue"));
expect(store.state!.show).toBe("in_progress");
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
});
// Already in progress, with another sort picked there: no entering edge brings the sort.
it("Continue watching sorts by last watched when in progress was already on", async () => {
const store = mount({ show: "all", sort: TITLE });
act(() => store.actions!.setShow("in_progress"));
act(() => store.actions!.setSort(TITLE));
fireEvent.click(await screen.findByText("continue"));
expect(store.state!.show).toBe("in_progress");
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
// …and the sort from before in-progress still comes back on the way out.
act(() => store.actions!.setShow("all"));
expect(store.state!.sort).toEqual(TITLE);
});
});
+4 -1
View File
@@ -592,7 +592,10 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
sub.open({ kind: "grid" });
}}
onSeeAllContinue={() => {
setShow("in_progress");
// Already in progress (with a sort picked since), no edge brings the sort: set it here,
// keeping the sort from before in-progress for the way out.
if (show === "in_progress") setSort(LASTWATCHED_SORT);
else setShow("in_progress");
sub.open({ kind: "grid" });
}}
/>
@@ -1169,6 +1169,29 @@ describe("PlexPlayer — clip mode", () => {
enterMarker(true);
advance(10_000);
expect(left).not.toHaveBeenCalled();
video()._paused = false;
advance(3500); // the whole wait is still ahead, then the 1 s countdown
expect(left).not.toHaveBeenCalled();
advance(1000);
expect(left).toHaveBeenCalledWith("a", "watched");
});
// An 8 s intro with the default 5 s countdown: 3 s of wait on top would run past its end, and the
// countdown stops there with nothing skipped.
it("shortens the wait on a marker too short for it, so the skip still fires", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 5 });
const short = {
...withCredits,
markers: [{ type: "credits", start_s: 2909, end_s: 2917, final: true }],
} as unknown as PlexItemDetail;
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
mount({ details: { a: short, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
enterMarker(false); // at 2910: 6 s before the marker stops counting
advance(5000);
expect(left).not.toHaveBeenCalled(); // a wait is still kept: 0.5 s of it here
advance(700);
expect(left).toHaveBeenCalledWith("a", "watched");
});
});
+13
View File
@@ -281,6 +281,19 @@ describe("WatchPage honest failures", () => {
return page;
}
it.each([
[{ kind: "file", video: true }, "This video is password-protected", "Watch"],
[{ kind: "file", video: false }, "This file is password-protected", "Open"],
[{ kind: "folder", video: false }, "This folder is password-protected", "Open"],
[{}, "This video is password-protected", "Watch"],
])("a password link that opens %o asks in its words", async (kind, title, button) => {
window.history.replaceState({}, "", "/file/tok-lock");
mockMeta({ needs_password: true, ...kind });
const page = render(<WatchPage />);
await page.findByText(title);
expect(page.getByRole("button", { name: button })).toBeTruthy();
});
it("too many password tries says so, and keeps the form", async () => {
window.history.replaceState({}, "", "/watch/tok-429");
const page = await tryPassword(async () => res(429));
+11 -6
View File
@@ -64,7 +64,7 @@ type Meta = FileMeta | FolderMeta;
// What the link answers before it is opened: a password link says only which kind it is.
type Answer =
| (Meta & { needs_password?: false })
| { needs_password: true; kind?: "file" | "folder" };
| { needs_password: true; kind?: "file" | "folder"; video?: boolean };
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
function displayUrl(url: string): string {
@@ -97,8 +97,9 @@ export default function WatchPage() {
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
>(token ? "loading" : "gone");
const [meta, setMeta] = useState<Meta | null>(null);
// A password link's kind, for its title, until it is unlocked.
const [locked, setLocked] = useState<"file" | "folder">("file");
// What a password link opens, for its prompt's words, until it is unlocked. A server from before
// it said `video` only had video links' words, so a file without it reads as one.
const [locked, setLocked] = useState<"video" | "file" | "folder">("video");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const errorId = useId();
@@ -124,7 +125,7 @@ export default function WatchPage() {
.then((m) => {
if (typeof m === "string") setStatus(m);
else if (m.needs_password) {
setLocked(m.kind ?? "file");
setLocked(m.kind === "folder" ? "folder" : m.video === false ? "file" : "video");
setStatus("password");
} else {
setMeta(m);
@@ -246,7 +247,11 @@ export default function WatchPage() {
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
<div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-accent" />{" "}
{locked === "folder" ? T.passwordTitleFolder : T.passwordTitle}
{locked === "folder"
? T.passwordTitleFolder
: locked === "file"
? T.passwordTitleFile
: T.passwordTitle}
</div>
<Input
className="w-full"
@@ -272,7 +277,7 @@ export default function WatchPage() {
disabled={!password || unlocking}
className="mt-4"
>
{T.watch}
{locked === "video" ? T.watch : T.open}
</Button>
</div>
</div>
+6
View File
@@ -27,6 +27,8 @@ const STR = {
crashHint: "Reload the page to try again.",
reload: "Reload",
passwordTitleFolder: "This folder is password-protected",
passwordTitleFile: "This file is password-protected",
open: "Open",
folderEmpty: "This folder is empty.",
play: "Play",
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
@@ -55,6 +57,8 @@ const STR = {
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
reload: "Újratöltés",
passwordTitleFolder: "Ez a mappa jelszóval védett",
passwordTitleFile: "Ez a fájl jelszóval védett",
open: "Megnyitás",
folderEmpty: "Ez a mappa üres.",
play: "Lejátszás",
files: (n: number) => `${n} fájl`,
@@ -83,6 +87,8 @@ const STR = {
crashHint: "Lade die Seite neu und versuche es erneut.",
reload: "Neu laden",
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
passwordTitleFile: "Diese Datei ist passwortgeschützt",
open: "Öffnen",
folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen",
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),