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:
@@ -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
|
(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
|
* 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`
|
per-edit user choice for a trim-only cut (ignored when a crop forces a re-encode anyway). `split`
|
||||||
is a frontend concept: the
|
is a frontend concept: the
|
||||||
UI fans a split out into N trim jobs, so there is one output file per job here.
|
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)."""
|
spec from before it was sent, which `crop_filter` cuts in the file's own pixels)."""
|
||||||
try:
|
try:
|
||||||
w, h = int(crop["frame_w"]), int(crop["frame_h"])
|
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.
|
# silent-ok: no frame is a crop's older form, and it is still the crop that was asked for. An
|
||||||
except (KeyError, TypeError, ValueError):
|
# infinite number (JSON `1e400`) is OverflowError, and it is no more a frame than "x" is.
|
||||||
|
except (KeyError, TypeError, ValueError, OverflowError):
|
||||||
return None
|
return None
|
||||||
return {"frame_w": w, "frame_h": h} if w > 0 and h > 0 else 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:
|
def normalize_edit_spec(spec: dict | None) -> dict:
|
||||||
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
|
"""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:
|
if crop:
|
||||||
try:
|
try:
|
||||||
c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")}
|
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,
|
# 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.
|
# and nothing anywhere called it a failure; this line is the only trace there is.
|
||||||
log.warning(
|
log.warning(
|
||||||
|
|||||||
@@ -127,18 +127,25 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
|
|||||||
# --- serialization -------------------------------------------------------------------------
|
# --- serialization -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def share_path(db: Session, link: DownloadLink, token: str) -> str:
|
def opens(db: Session, link: DownloadLink) -> str:
|
||||||
"""The address a link is given out on: its path says what it opens. Only a word for the reader,
|
"""What a link opens, in the words its address and its preview use: "folder", "video" or
|
||||||
the token is the whole key: every prefix of `main.SHARE_PREFIXES` opens every link, so the links
|
"file". A file still being made reads as the video it mostly is."""
|
||||||
already sent on /watch/ work for good. A file still being made reads as the video it mostly is."""
|
|
||||||
from app.downloads import mediakind
|
from app.downloads import mediakind
|
||||||
|
|
||||||
if link.folder_id is not None:
|
if link.folder_id is not None:
|
||||||
return f"/folder/{token}"
|
return "folder"
|
||||||
job = db.get(DownloadJob, link.job_id)
|
job = db.get(DownloadJob, link.job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||||
kind = asset.media_kind if asset 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:
|
def owner_view(db: Session, link: DownloadLink, token: str | None = None) -> dict:
|
||||||
|
|||||||
@@ -234,7 +234,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
source_asset_id: Mapped[int | None] = mapped_column(
|
source_asset_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("media_assets.id", ondelete="SET NULL")
|
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
|
# 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`).
|
# in fractions of the box it was drawn in (`downloads.clip.normalize`).
|
||||||
edit_spec: Mapped[dict | None] = mapped_column(JSON)
|
edit_spec: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
|||||||
if link is None or linksmod.is_expired(link):
|
if link is None or linksmod.is_expired(link):
|
||||||
return None
|
return None
|
||||||
url = f"{settings.app_base}{linksmod.share_path(db, link, token)}"
|
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.
|
||||||
# What the card calls it, as its address does (`links.share_path`).
|
kind = linksmod.opens(db, link)
|
||||||
video = "/watch/" in url
|
folder, video = kind == "folder", kind == "video"
|
||||||
if link.password_hash:
|
if link.password_hash:
|
||||||
# Password-gated: don't leak the title/thumbnail in the unfurl.
|
# Password-gated: don't leak the title/thumbnail in the unfurl.
|
||||||
return "\n ".join(
|
return "\n ".join(
|
||||||
|
|||||||
@@ -914,13 +914,13 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
asset.size_bytes = size
|
asset.size_bytes = size
|
||||||
asset.container = out_ext
|
asset.container = out_ext
|
||||||
asset.duration_s = total_s or asset.duration_s
|
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:
|
if out_w and out_h:
|
||||||
asset.width, asset.height = out_w, out_h
|
asset.width, asset.height = out_w, out_h
|
||||||
elif crop:
|
elif cut:
|
||||||
asset.width = edit_spec["crop"]["w"]
|
asset.width, asset.height = cut
|
||||||
asset.height = edit_spec["crop"]["h"]
|
|
||||||
else:
|
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.error = None
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
|
|||||||
@@ -41,11 +41,23 @@ def video(db: Session, video_id: str) -> Video | None:
|
|||||||
# --- tags ------------------------------------------------------------------------------------
|
# --- tags ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int]]:
|
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), each with how many channel
|
"""The tags this user sees (the shared system ones and their own), by category then name, each
|
||||||
links it has, by category then name."""
|
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 [
|
return [
|
||||||
(tag, count)
|
(tag, count, on_subscribed.get(tag.id, 0))
|
||||||
for tag, count in db.execute(
|
for tag, count in db.execute(
|
||||||
select(Tag, func.count(ChannelTag.id))
|
select(Tag, func.count(ChannelTag.id))
|
||||||
.outerjoin(ChannelTag, ChannelTag.tag_id == Tag.id)
|
.outerjoin(ChannelTag, ChannelTag.tag_id == Tag.id)
|
||||||
|
|||||||
@@ -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)."""
|
returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak)."""
|
||||||
link = _link_or_404(db, token)
|
link = _link_or_404(db, token)
|
||||||
if link.password_hash:
|
if link.password_hash:
|
||||||
# Which kind it opens, for the password prompt's title — never what is in it.
|
# Which kind it opens, for the password prompt's words — never what is in it. `video` tells
|
||||||
return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"}
|
# 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)
|
meta = _opened(db, link, token)
|
||||||
db.commit()
|
db.commit()
|
||||||
return meta
|
return meta
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db))
|
|||||||
"category": tag.category,
|
"category": tag.category,
|
||||||
"system": tag.user_id is None,
|
"system": tag.user_id is None,
|
||||||
"channel_count": count,
|
"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)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ from app.downloads.models import DownloadJob, MediaAsset
|
|||||||
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||||
|
|
||||||
|
|
||||||
def _source(path: Path, size: str, box: str, sar: int) -> None:
|
def _source(path: Path, size: str, box: str, sar: int, rotate: int = 0) -> None:
|
||||||
"""Red, with the picture's middle half green: a crop of that middle holds no red."""
|
"""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(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
"ffmpeg", "-y", "-loglevel", "error",
|
"ffmpeg", "-y", "-loglevel", "error",
|
||||||
@@ -32,6 +34,13 @@ def _source(path: Path, size: str, box: str, sar: int) -> None:
|
|||||||
],
|
],
|
||||||
check=True,
|
check=True,
|
||||||
) # fmt: skip
|
) # 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]:
|
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
|
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()
|
user = make_user()
|
||||||
source = uploadmod.ingest_upload(
|
source = uploadmod.ingest_upload(
|
||||||
db, user.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
|
db, user.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
|
||||||
)
|
)
|
||||||
assert source.status == "done"
|
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)
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
job.status, asset.status = "running", "downloading" # the worker's claim
|
job.status, asset.status = "running", "downloading" # the worker's claim
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -66,6 +76,11 @@ def _cut(db, make_user, tmp_path, src: Path, crop: dict) -> Path:
|
|||||||
db.expire_all()
|
db.expire_all()
|
||||||
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
|
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
|
||||||
assert (job.status, job.error, asset.status) == ("done", None, "ready")
|
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)
|
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):
|
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}})
|
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}
|
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
|
||||||
|
|||||||
@@ -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() == {
|
assert client.get(f"/api/public/watch/{token}").json() == {
|
||||||
"needs_password": True,
|
"needs_password": True,
|
||||||
"kind": "folder",
|
"kind": "folder",
|
||||||
|
"video": False,
|
||||||
}
|
}
|
||||||
bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file"
|
bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file"
|
||||||
assert client.get(bare).status_code == 403
|
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()
|
.json()
|
||||||
)
|
)
|
||||||
meta = client.get(f"/api/public/watch/{made['token']}").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):
|
def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db):
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from app.discover import pins
|
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 import queries, service
|
||||||
from app.reader.models import ChannelTag, ComfortPin, Playlist, PlaylistItem, Tag, VideoState
|
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
|
service.create_tag(db, other, "Theirs", "other", None) # another user's tag stays theirs
|
||||||
db.commit()
|
db.commit()
|
||||||
assert queries.own_tag(db, user, system.id) is None
|
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 ------------------------------------------------------------------------------------
|
# --- 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):
|
def test_a_tag_is_made_once_per_name_and_an_unknown_category_is_other(db, make_user):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
tag = service.create_tag(db, user, "Later", "nonsense", "#fff")
|
tag = service.create_tag(db, user, "Later", "nonsense", "#fff")
|
||||||
|
|||||||
@@ -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
|
assert f"/watch/{made['token']}" in tags and "video.other" in tags
|
||||||
locked = shared("other.mp4", _VIDEO, password="pw")
|
locked = shared("other.mp4", _VIDEO, password="pw")
|
||||||
assert "Password-protected video" in og._watch_tags(locked["token"], db)
|
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}
|
||||||
|
|||||||
@@ -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 | 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 | 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-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 | 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 |
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ export default function Modal({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
data-dismiss=""
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const tag = (id: number, name: string): Tag => ({
|
|||||||
category: "other",
|
category: "other",
|
||||||
system: false,
|
system: false,
|
||||||
channel_count: 0,
|
channel_count: 0,
|
||||||
|
subscribed_count: 0,
|
||||||
});
|
});
|
||||||
const docu = tag(1, "docu");
|
const docu = tag(1, "docu");
|
||||||
const history = tag(2, "history");
|
const history = tag(2, "history");
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"added": "File uploaded",
|
"added": "File uploaded",
|
||||||
"addedConverting": "File uploaded — converting it so it plays on any phone",
|
"addedConverting": "File uploaded — converting it so it plays on any phone",
|
||||||
"targetLabel": "Upload to",
|
"targetLabel": "Upload to",
|
||||||
|
"makingFolder": "Making “{{name}}”…",
|
||||||
"targetFailed": "The folders could not be loaded",
|
"targetFailed": "The folders could not be loaded",
|
||||||
"failed": "Couldn't upload the file"
|
"failed": "Couldn't upload the file"
|
||||||
},
|
},
|
||||||
@@ -113,6 +114,7 @@
|
|||||||
"revokeConfirm": "This link will stop working immediately.",
|
"revokeConfirm": "This link will stop working immediately.",
|
||||||
"views_one": "{{count}} view",
|
"views_one": "{{count}} view",
|
||||||
"views_other": "{{count}} views",
|
"views_other": "{{count}} views",
|
||||||
|
"expiredOn": "expired {{date}}",
|
||||||
"expiresOn": "expires {{date}}",
|
"expiresOn": "expires {{date}}",
|
||||||
"hasPassword": "password",
|
"hasPassword": "password",
|
||||||
"downloadable": "downloadable",
|
"downloadable": "downloadable",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"added": "Fájl feltöltve",
|
"added": "Fájl feltöltve",
|
||||||
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen",
|
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen",
|
||||||
"targetLabel": "Feltöltés ide",
|
"targetLabel": "Feltöltés ide",
|
||||||
|
"makingFolder": "„{{name}}” létrehozása…",
|
||||||
"targetFailed": "A mappákat nem sikerült betölteni",
|
"targetFailed": "A mappákat nem sikerült betölteni",
|
||||||
"failed": "Nem sikerült feltölteni a fájlt"
|
"failed": "Nem sikerült feltölteni a fájlt"
|
||||||
},
|
},
|
||||||
@@ -113,6 +114,7 @@
|
|||||||
"revokeConfirm": "A link azonnal használhatatlanná válik.",
|
"revokeConfirm": "A link azonnal használhatatlanná válik.",
|
||||||
"views_one": "{{count}} megtekintés",
|
"views_one": "{{count}} megtekintés",
|
||||||
"views_other": "{{count}} megtekintés",
|
"views_other": "{{count}} megtekintés",
|
||||||
|
"expiredOn": "lejárt: {{date}}",
|
||||||
"expiresOn": "lejár: {{date}}",
|
"expiresOn": "lejár: {{date}}",
|
||||||
"hasPassword": "jelszó",
|
"hasPassword": "jelszó",
|
||||||
"downloadable": "letölthető",
|
"downloadable": "letölthető",
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ export interface Tag {
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
system: boolean;
|
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;
|
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.
|
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern.
|
||||||
|
|||||||
@@ -94,6 +94,22 @@ describe("quickShareLink", () => {
|
|||||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
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 () => {
|
it("mints a new link when none it has can be shown, keeping the old ones listed", async () => {
|
||||||
const hidden = { id: 4, url: null };
|
const hidden = { id: 4, url: null };
|
||||||
downloadLinks.mockResolvedValue([hidden]);
|
downloadLinks.mockResolvedValue([hidden]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
import { api, type NewShareLink } from "./api";
|
import { api, type NewShareLink, type ShareLink } from "./api";
|
||||||
import { copyText } from "./clipboard";
|
import { copyText } from "./clipboard";
|
||||||
import { msg, notify } from "./notifications";
|
import { msg, notify } from "./notifications";
|
||||||
import { notifyActionError } from "./actionErrors";
|
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() });
|
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.
|
// 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
|
// 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
|
// 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),
|
queryKey: linksKey(target),
|
||||||
queryFn: () => fetchLinks(target),
|
queryFn: () => fetchLinks(target),
|
||||||
});
|
});
|
||||||
// The newest link whose address can still be shown; one that cannot is no use to copy.
|
path = liveLink(existing)?.url;
|
||||||
path = existing.find((l) => l.url)?.url;
|
|
||||||
if (!path) {
|
if (!path) {
|
||||||
const made = await makeLink(target, {}); // omit allow_download → backend default (true)
|
const made = await makeLink(target, {}); // omit allow_download → backend default (true)
|
||||||
qc.setQueryData(linksKey(target), [made, ...existing]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -434,8 +434,18 @@ export function usePlayerVolume(opts: {
|
|||||||
return { setVol, nudgeVolume, toggleMute, onWheel };
|
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;
|
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
|
// 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
|
// 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,
|
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||||
);
|
);
|
||||||
const activeMarkerRef = useLatest(activeMarker);
|
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
|
// 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.
|
// 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;
|
const activeAuto = activeMarker != null && markerSkip(activeMarker, markers ?? []).auto;
|
||||||
@@ -543,14 +554,17 @@ export function useAutoSkip(opts: {
|
|||||||
if (autoSkipDelay <= 0) return doSkip(m); // immediate
|
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
|
// 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.
|
// 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);
|
setSkipProgress(0);
|
||||||
const iv = window.setInterval(() => {
|
const iv = window.setInterval(() => {
|
||||||
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
|
// 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.
|
// deliberately-paused video. It resumes from where it stopped when playback resumes.
|
||||||
if (videoRef.current?.paused) return;
|
if (videoRef.current?.paused) return;
|
||||||
elapsed += 0.1;
|
elapsed += 0.1;
|
||||||
const p = Math.max(0, Math.min(1, elapsed / autoSkipDelay));
|
const p = skipCountdown(elapsed, autoSkipDelay);
|
||||||
setSkipProgress(p);
|
setSkipProgress(p);
|
||||||
if (p >= 1) {
|
if (p >= 1) {
|
||||||
window.clearInterval(iv);
|
window.clearInterval(iv);
|
||||||
@@ -573,6 +587,7 @@ export function useAutoSkip(opts: {
|
|||||||
doSkip,
|
doSkip,
|
||||||
videoRef,
|
videoRef,
|
||||||
activeMarkerRef,
|
activeMarkerRef,
|
||||||
|
absRef,
|
||||||
activeAuto,
|
activeAuto,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const tag = (id: number, name: string, system: boolean): Tag => ({
|
|||||||
color: null,
|
color: null,
|
||||||
category: "topic",
|
category: "topic",
|
||||||
channel_count: 1,
|
channel_count: 1,
|
||||||
|
subscribed_count: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
function wrapper({ children }: { children: ReactNode }) {
|
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
|
// 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.
|
// measurement can only ever produce one answer.
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -114,4 +115,76 @@ describe("TagFilterChips counts", () => {
|
|||||||
expect(name("alpha")?.textContent).toBe("alpha42");
|
expect(name("alpha")?.textContent).toBe("alpha42");
|
||||||
expect(name("beta")?.textContent).toBe("beta0");
|
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 { useTranslation } from "react-i18next";
|
||||||
import { fitChipsWithPill } from "../../lib/chipOverflow";
|
import { fitChipsWithPill } from "../../lib/chipOverflow";
|
||||||
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
|
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 (
|
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} />
|
<ChipText chip={chip} />
|
||||||
</Chip>
|
</Chip>
|
||||||
);
|
);
|
||||||
@@ -104,6 +113,32 @@ export default function TagFilterChips({
|
|||||||
const { setReference } = pop.refs;
|
const { setReference } = pop.refs;
|
||||||
const moreLabel = t("channels.tags.moreCount", { count: hidden });
|
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 (
|
return (
|
||||||
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
<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
|
{/* 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">
|
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
|
||||||
{chips.slice(0, visible).map((c) => (
|
{chips.slice(0, visible).map((c) => (
|
||||||
<TagChip key={c.key} chip={c} />
|
<TagChip key={c.key} chip={c} onToggled={onToggled} />
|
||||||
))}
|
))}
|
||||||
{hidden > 0 && (
|
{hidden > 0 && (
|
||||||
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
|
<Chip ref={setMore} aria-label={moreLabel} {...pop.getReferenceProps()}>
|
||||||
+{hidden}
|
+{hidden}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
@@ -146,9 +181,11 @@ export default function TagFilterChips({
|
|||||||
ariaLabel={moreLabel}
|
ariaLabel={moreLabel}
|
||||||
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
|
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) => (
|
<div ref={foldRef} className="contents">
|
||||||
<TagChip key={c.key} chip={c} />
|
{chips.slice(visible).map((c) => (
|
||||||
))}
|
<TagChip key={c.key} chip={c} onToggled={onToggled} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -188,7 +188,9 @@ describe("buildChannelColumns — Added", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("tagFilterChips", () => {
|
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 = {
|
const tag = {
|
||||||
id: 3,
|
id: 3,
|
||||||
name: "Tech",
|
name: "Tech",
|
||||||
@@ -196,6 +198,7 @@ describe("tagFilterChips", () => {
|
|||||||
category: "topic",
|
category: "topic",
|
||||||
system: false,
|
system: false,
|
||||||
channel_count: 17,
|
channel_count: 17,
|
||||||
|
subscribed_count: 15,
|
||||||
};
|
};
|
||||||
const chips = tagFilterChips({
|
const chips = tagFilterChips({
|
||||||
userTags: [tag],
|
userTags: [tag],
|
||||||
@@ -206,7 +209,7 @@ describe("tagFilterChips", () => {
|
|||||||
toggleNoTag: () => {},
|
toggleNoTag: () => {},
|
||||||
t: ((k: string) => k) as never,
|
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.find((c) => c.key === "t99")?.count).toBeUndefined();
|
||||||
expect(chips[chips.length - 1]?.count).toBeUndefined();
|
expect(chips[chips.length - 1]?.count).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function tagFilterChips(o: {
|
|||||||
...o.userTags.map((tg) => ({
|
...o.userTags.map((tg) => ({
|
||||||
key: `t${tg.id}`,
|
key: `t${tg.id}`,
|
||||||
label: tg.name,
|
label: tg.name,
|
||||||
count: tg.channel_count,
|
count: tg.subscribed_count,
|
||||||
active: o.picked.includes(String(tg.id)),
|
active: o.picked.includes(String(tg.id)),
|
||||||
onToggle: () => o.toggle(tg.id),
|
onToggle: () => o.toggle(tg.id),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const tag = (id: number, name: string): Tag => ({
|
|||||||
category: "other",
|
category: "other",
|
||||||
system: false,
|
system: false,
|
||||||
channel_count: 0,
|
channel_count: 0,
|
||||||
|
subscribed_count: 0,
|
||||||
});
|
});
|
||||||
const docu = tag(1, "docu");
|
const docu = tag(1, "docu");
|
||||||
const history = tag(2, "history");
|
const history = tag(2, "history");
|
||||||
|
|||||||
@@ -110,24 +110,58 @@ describe("ShareDialog · opened to share", () => {
|
|||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const row = (id: number, url: string | null) => ({
|
const row = (id: number, url: string | null, expires_at: string | null = null) => ({
|
||||||
id,
|
id,
|
||||||
token: "t",
|
token: "t",
|
||||||
url,
|
url,
|
||||||
allow_download: true,
|
allow_download: true,
|
||||||
has_password: false,
|
has_password: false,
|
||||||
expires_at: null,
|
expires_at,
|
||||||
view_count: 0,
|
view_count: 0,
|
||||||
created_at: null,
|
created_at: null,
|
||||||
});
|
});
|
||||||
const origin = window.location.origin;
|
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 () => {
|
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")];
|
h.links = [row(1, null), row(2, "/file/b"), row(3, "/file/c")];
|
||||||
open();
|
open();
|
||||||
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
|
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
|
||||||
expect(h.copyText).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("copies the link it made when there was none", async () => {
|
||||||
@@ -143,7 +177,8 @@ describe("ShareDialog · opened to share", () => {
|
|||||||
open();
|
open();
|
||||||
await waitFor(() => expect(h.copyText).toHaveBeenCalled());
|
await waitFor(() => expect(h.copyText).toHaveBeenCalled());
|
||||||
await screen.findByRole("button", { name: "downloads.share.copy" });
|
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 () => {
|
it("offers the link in a new tab, beside Copy", async () => {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../.
|
|||||||
import {
|
import {
|
||||||
fetchLinks,
|
fetchLinks,
|
||||||
invalidateLinks,
|
invalidateLinks,
|
||||||
|
linkExpired,
|
||||||
linksKey,
|
linksKey,
|
||||||
|
liveLink,
|
||||||
makeLink,
|
makeLink,
|
||||||
type LinkTarget,
|
type LinkTarget,
|
||||||
} from "../../lib/downloads";
|
} from "../../lib/downloads";
|
||||||
@@ -80,11 +82,14 @@ function UserShare({ job }: { job: DownloadJob }) {
|
|||||||
function LinkRow({
|
function LinkRow({
|
||||||
link,
|
link,
|
||||||
copiedOnOpen,
|
copiedOnOpen,
|
||||||
|
onCopied,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
link: ShareLink;
|
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;
|
copiedOnOpen: boolean;
|
||||||
|
/** Its Copy was pressed: the clipboard holds this link now, not the one the dialog copied. */
|
||||||
|
onCopied: () => void;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -95,7 +100,9 @@ function LinkRow({
|
|||||||
const fullUrl = link.url && window.location.origin + link.url;
|
const fullUrl = link.url && window.location.origin + link.url;
|
||||||
|
|
||||||
const copy = () => {
|
const copy = () => {
|
||||||
if (fullUrl) void copyValue(fullUrl);
|
if (!fullUrl) return;
|
||||||
|
onCopied();
|
||||||
|
void copyValue(fullUrl);
|
||||||
};
|
};
|
||||||
const toggle = useMutation({
|
const toggle = useMutation({
|
||||||
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
|
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.allow_download) badges.push(t("downloads.share.downloadable"));
|
||||||
if (link.expires_at)
|
if (link.expires_at)
|
||||||
badges.push(
|
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 (
|
return (
|
||||||
@@ -198,11 +207,9 @@ function LinkRow({
|
|||||||
<Trash2 className="w-4 h-4 text-danger" />
|
<Trash2 className="w-4 h-4 text-danger" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
{copiedOnOpen && (
|
{/* Seen here, heard from the dialog's own live region (`LinkShare`): one mounted already
|
||||||
<p role="status" className="text-xs text-success">
|
filled may never be announced. */}
|
||||||
{t("downloads.share.linkCopied")}
|
{copiedOnOpen && <p className="text-xs text-success">{t("downloads.share.linkCopied")}</p>}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
|
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Eye className="w-3 h-3" /> {t("downloads.share.views", { count: link.view_count })}
|
<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
|
// 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,
|
// 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.
|
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||||
const autoTried = useRef(false);
|
const autoTried = useRef(false);
|
||||||
@@ -327,13 +334,19 @@ function LinkShare({ target }: { target: LinkTarget }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!links.data || autoTried.current) return;
|
if (!links.data || autoTried.current) return;
|
||||||
autoTried.current = true;
|
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) });
|
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]);
|
}, [links.data, createLink]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<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")}
|
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
|
||||||
</div>
|
</div>
|
||||||
@@ -360,6 +373,7 @@ function LinkShare({ target }: { target: LinkTarget }) {
|
|||||||
key={l.id}
|
key={l.id}
|
||||||
link={l}
|
link={l}
|
||||||
copiedOnOpen={copiedOnOpen === l.id}
|
copiedOnOpen={copiedOnOpen === l.id}
|
||||||
|
onCopied={() => setCopiedOnOpen(null)}
|
||||||
onChanged={invalidate}
|
onChanged={invalidate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -186,6 +186,74 @@ describe("UploadDialog · where the file goes", () => {
|
|||||||
expect(await sentTo()).toBe(7);
|
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 () => {
|
it("an empty name makes no folder and leaves the target", async () => {
|
||||||
wrap(vi.fn(), 5);
|
wrap(vi.fn(), 5);
|
||||||
fireEvent.click(await target());
|
fireEvent.click(await target());
|
||||||
|
|||||||
@@ -88,6 +88,20 @@ export default function UploadDialog({
|
|||||||
refocusTarget.current = false;
|
refocusTarget.current = false;
|
||||||
targetBox.current?.querySelector("button")?.focus();
|
targetBox.current?.querySelector("button")?.focus();
|
||||||
}, [naming]);
|
}, [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({
|
const makeFolder = useMutation({
|
||||||
mutationFn: (folder: string) => api.createFolder(folder),
|
mutationFn: (folder: string) => api.createFolder(folder),
|
||||||
onSuccess: (made) => {
|
onSuccess: (made) => {
|
||||||
@@ -113,8 +127,10 @@ export default function UploadDialog({
|
|||||||
const pickTarget = ([value]: string[]) => {
|
const pickTarget = ([value]: string[]) => {
|
||||||
// After the menu has closed: it hands the focus back as it goes, which took it off a field already
|
// 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.
|
// there, and a name field that loses the focus is done, with no name.
|
||||||
if (value === NEW_FOLDER) window.setTimeout(() => setNaming(true));
|
if (value === NEW_FOLDER) {
|
||||||
else if (value === UPLOADS) setTarget(null);
|
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));
|
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.
|
// 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}
|
maxLength={DISPLAY_NAME_MAX}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p id="upload-target-label" className="block text-sm font-medium mt-4 mb-1">
|
<p className="block text-sm font-medium mt-4 mb-1">{t("downloads.upload.targetLabel")}</p>
|
||||||
{t("downloads.upload.targetLabel")}
|
{foldersQ.isError && !foldersQ.data ? (
|
||||||
</p>
|
// The file still goes where the dialog was opened on: only the list could not be read. A
|
||||||
{foldersQ.isError ? (
|
// failed REFETCH keeps the list it had (react-query sets isError beside the data): the
|
||||||
// The file still goes where the dialog was opened on: only the list could not be read.
|
// target the file goes to stays shown, as the rail does it.
|
||||||
<StateMessage tone="error" compact onRetry={() => void foldersQ.refetch()}>
|
<StateMessage tone="error" compact onRetry={() => void foldersQ.refetch()}>
|
||||||
{t("downloads.upload.targetFailed")}
|
{t("downloads.upload.targetFailed")}
|
||||||
</StateMessage>
|
</StateMessage>
|
||||||
@@ -249,12 +265,20 @@ export default function UploadDialog({
|
|||||||
onDone={(folder, byKey) => {
|
onDone={(folder, byKey) => {
|
||||||
refocusTarget.current = byKey;
|
refocusTarget.current = byKey;
|
||||||
setNaming(false);
|
setNaming(false);
|
||||||
|
if (!byKey && dismissing.current) return;
|
||||||
if (folder?.trim()) makeFolder.mutate(folder.trim());
|
if (folder?.trim()) makeFolder.mutate(folder.trim());
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div ref={targetBox} aria-labelledby="upload-target-label">
|
// The select's own height held while the list loads, so it does not push the form down.
|
||||||
{foldersQ.data && !busy && (
|
<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
|
<FilterSelect
|
||||||
hideLabel
|
hideLabel
|
||||||
label={t("downloads.upload.targetLabel")}
|
label={t("downloads.upload.targetLabel")}
|
||||||
@@ -300,6 +324,7 @@ export default function UploadDialog({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
|
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
|
||||||
|
data-dismiss=""
|
||||||
>
|
>
|
||||||
{t("downloads.actions.cancel")}
|
{t("downloads.actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -31,14 +31,18 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
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 = {
|
const job = {
|
||||||
id: 7,
|
id: 7,
|
||||||
display_name: "Talk",
|
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;
|
} as unknown as DownloadJob;
|
||||||
|
|
||||||
describe("VideoEditor crop", () => {
|
describe("VideoEditor crop", () => {
|
||||||
it("sends the size the crop was measured on with it", async () => {
|
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
|
const enqueue = vi
|
||||||
.spyOn(api, "enqueueEdit")
|
.spyOn(api, "enqueueEdit")
|
||||||
.mockResolvedValue({ id: 8 } as unknown as DownloadJob);
|
.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -592,7 +592,10 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
|
|||||||
sub.open({ kind: "grid" });
|
sub.open({ kind: "grid" });
|
||||||
}}
|
}}
|
||||||
onSeeAllContinue={() => {
|
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" });
|
sub.open({ kind: "grid" });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1169,6 +1169,29 @@ describe("PlexPlayer — clip mode", () => {
|
|||||||
enterMarker(true);
|
enterMarker(true);
|
||||||
advance(10_000);
|
advance(10_000);
|
||||||
expect(left).not.toHaveBeenCalled();
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,19 @@ describe("WatchPage honest failures", () => {
|
|||||||
return page;
|
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 () => {
|
it("too many password tries says so, and keeps the form", async () => {
|
||||||
window.history.replaceState({}, "", "/watch/tok-429");
|
window.history.replaceState({}, "", "/watch/tok-429");
|
||||||
const page = await tryPassword(async () => res(429));
|
const page = await tryPassword(async () => res(429));
|
||||||
|
|||||||
@@ -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.
|
// What the link answers before it is opened: a password link says only which kind it is.
|
||||||
type Answer =
|
type Answer =
|
||||||
| (Meta & { needs_password?: false })
|
| (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.
|
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
|
||||||
function displayUrl(url: string): string {
|
function displayUrl(url: string): string {
|
||||||
@@ -97,8 +97,9 @@ export default function WatchPage() {
|
|||||||
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
|
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
|
||||||
>(token ? "loading" : "gone");
|
>(token ? "loading" : "gone");
|
||||||
const [meta, setMeta] = useState<Meta | null>(null);
|
const [meta, setMeta] = useState<Meta | null>(null);
|
||||||
// A password link's kind, for its title, until it is unlocked.
|
// What a password link opens, for its prompt's words, until it is unlocked. A server from before
|
||||||
const [locked, setLocked] = useState<"file" | "folder">("file");
|
// 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 [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const errorId = useId();
|
const errorId = useId();
|
||||||
@@ -124,7 +125,7 @@ export default function WatchPage() {
|
|||||||
.then((m) => {
|
.then((m) => {
|
||||||
if (typeof m === "string") setStatus(m);
|
if (typeof m === "string") setStatus(m);
|
||||||
else if (m.needs_password) {
|
else if (m.needs_password) {
|
||||||
setLocked(m.kind ?? "file");
|
setLocked(m.kind === "folder" ? "folder" : m.video === false ? "file" : "video");
|
||||||
setStatus("password");
|
setStatus("password");
|
||||||
} else {
|
} else {
|
||||||
setMeta(m);
|
setMeta(m);
|
||||||
@@ -246,7 +247,11 @@ export default function WatchPage() {
|
|||||||
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
|
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
|
||||||
<div className="flex items-center gap-2 font-medium mb-4">
|
<div className="flex items-center gap-2 font-medium mb-4">
|
||||||
<Lock className="w-4 h-4 text-accent" />{" "}
|
<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>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -272,7 +277,7 @@ export default function WatchPage() {
|
|||||||
disabled={!password || unlocking}
|
disabled={!password || unlocking}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
{T.watch}
|
{locked === "video" ? T.watch : T.open}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const STR = {
|
|||||||
crashHint: "Reload the page to try again.",
|
crashHint: "Reload the page to try again.",
|
||||||
reload: "Reload",
|
reload: "Reload",
|
||||||
passwordTitleFolder: "This folder is password-protected",
|
passwordTitleFolder: "This folder is password-protected",
|
||||||
|
passwordTitleFile: "This file is password-protected",
|
||||||
|
open: "Open",
|
||||||
folderEmpty: "This folder is empty.",
|
folderEmpty: "This folder is empty.",
|
||||||
play: "Play",
|
play: "Play",
|
||||||
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
|
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.",
|
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
|
||||||
reload: "Újratöltés",
|
reload: "Újratöltés",
|
||||||
passwordTitleFolder: "Ez a mappa jelszóval védett",
|
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.",
|
folderEmpty: "Ez a mappa üres.",
|
||||||
play: "Lejátszás",
|
play: "Lejátszás",
|
||||||
files: (n: number) => `${n} fájl`,
|
files: (n: number) => `${n} fájl`,
|
||||||
@@ -83,6 +87,8 @@ const STR = {
|
|||||||
crashHint: "Lade die Seite neu und versuche es erneut.",
|
crashHint: "Lade die Seite neu und versuche es erneut.",
|
||||||
reload: "Neu laden",
|
reload: "Neu laden",
|
||||||
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
|
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
|
||||||
|
passwordTitleFile: "Diese Datei ist passwortgeschützt",
|
||||||
|
open: "Öffnen",
|
||||||
folderEmpty: "Dieser Ordner ist leer.",
|
folderEmpty: "Dieser Ordner ist leer.",
|
||||||
play: "Abspielen",
|
play: "Abspielen",
|
||||||
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
|
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
|
||||||
|
|||||||
Reference in New Issue
Block a user