Merge: promote dev to prod
This commit is contained in:
+30
-10
@@ -8,7 +8,7 @@ from authlib.integrations.starlette_client import OAuth, OAuthError
|
|||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from starlette.requests import HTTPConnection
|
from starlette.requests import HTTPConnection
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
@@ -31,6 +31,7 @@ _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
|||||||
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
|
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||||
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
|
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
|
||||||
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
|
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||||
|
_request_access_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||||
# At most one "your account is suspended" email per address per hour, so a suspended user who
|
# At most one "your account is suspended" email per address per hour, so a suspended user who
|
||||||
# keeps retrying (or a script with their valid credentials) can't be used to mailbomb them.
|
# keeps retrying (or a script with their valid credentials) can't be used to mailbomb them.
|
||||||
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
|
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
|
||||||
@@ -512,16 +513,26 @@ async def logout(request: Request):
|
|||||||
@router.post("/request-access")
|
@router.post("/request-access")
|
||||||
async def request_access(
|
async def request_access(
|
||||||
payload: dict,
|
payload: dict,
|
||||||
|
request: Request,
|
||||||
background: BackgroundTasks,
|
background: BackgroundTasks,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Public: ask for access. Idempotent — repeat requests for the same email don't
|
"""Public: ask for access. Idempotent — repeat requests for the same email don't
|
||||||
duplicate or re-spam admins (upsert returns None for an already-pending row)."""
|
duplicate or re-spam admins (upsert returns None for an already-pending row).
|
||||||
|
|
||||||
|
Answers a uniform ``"pending"`` for EVERY outcome — already-allowed, newly-pending,
|
||||||
|
already-pending, and rate-limited alike. The old ``"approved"`` reply for an allow-listed
|
||||||
|
address made this a public enumeration oracle for the allow-list (which the rest of the module
|
||||||
|
is careful to avoid, cf. demo_login); an already-allowed user can still just sign in with Google.
|
||||||
|
Rate-limited per client IP like register/login/reset, and a block returns "pending" too so the
|
||||||
|
endpoint can't be probed for throttling either."""
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
if not valid_email(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||||
|
if not _request_access_limiter.allow(_client_ip(request)):
|
||||||
|
return {"status": "pending"}
|
||||||
if is_allowed(db, email):
|
if is_allowed(db, email):
|
||||||
return {"status": "approved"} # already allowed — just sign in
|
return {"status": "pending"} # already allowed — don't reveal it
|
||||||
inv = upsert_pending_invite(db, email)
|
inv = upsert_pending_invite(db, email)
|
||||||
admins = admin_notify_emails(db)
|
admins = admin_notify_emails(db)
|
||||||
if inv is not None and admins:
|
if inv is not None and admins:
|
||||||
@@ -583,18 +594,27 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||||
"""Validate + burn a token. Returns its user, or None if missing/expired/used/wrong kind."""
|
"""Validate + burn a token atomically. Returns its user, or None if missing/expired/used/wrong
|
||||||
|
kind. The burn is a single conditional UPDATE (…WHERE used_at IS NULL…RETURNING) rather than a
|
||||||
|
read-check-write, so two concurrent requests can't both consume the same verify/reset token —
|
||||||
|
only the one whose UPDATE flips used_at gets a row back."""
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
select(AuthToken).where(
|
update(AuthToken)
|
||||||
AuthToken.token_hash == hash_token(raw), AuthToken.kind == kind
|
.where(
|
||||||
|
AuthToken.token_hash == hash_token(raw),
|
||||||
|
AuthToken.kind == kind,
|
||||||
|
AuthToken.used_at.is_(None),
|
||||||
|
AuthToken.expires_at >= now,
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
.values(used_at=now)
|
||||||
if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc):
|
.returning(AuthToken.user_id)
|
||||||
return None
|
).first()
|
||||||
row.used_at = datetime.now(timezone.utc)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
return db.get(User, row.user_id)
|
return db.get(User, row.user_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,12 @@ def sanitize(name: str, limit: int = _MAX_TITLE) -> str:
|
|||||||
text = re.sub(r"\s+", "_", text.strip())
|
text = re.sub(r"\s+", "_", text.strip())
|
||||||
# Tidy separator pile-ups and trim junk from the ends.
|
# Tidy separator pile-ups and trim junk from the ends.
|
||||||
text = re.sub(r"_{2,}", "_", text).strip("_.-·—–|,;:!?ّ ")
|
text = re.sub(r"_{2,}", "_", text).strip("_.-·—–|,;:!?ّ ")
|
||||||
if len(text) > limit:
|
# Cap by BYTES, not characters: a path component's hard limit is 255 BYTES, and rel_path packs
|
||||||
text = text[:limit].rstrip("_.-")
|
# the (byte-capped) channel + date + id + ext into that same component — so an accented/multi-byte
|
||||||
|
# title capped only by char count could still overflow to ENAMETOOLONG. Truncate on a UTF-8
|
||||||
|
# boundary (decode(..., "ignore") drops a trailing partial char) so we never split a character.
|
||||||
|
if len(text.encode("utf-8")) > limit:
|
||||||
|
text = text.encode("utf-8")[:limit].decode("utf-8", "ignore").rstrip("_.-")
|
||||||
return text or "untitled"
|
return text or "untitled"
|
||||||
|
|
||||||
|
|
||||||
@@ -74,16 +78,23 @@ def display_filename(name: str) -> str:
|
|||||||
return text or "download"
|
return text or "download"
|
||||||
|
|
||||||
|
|
||||||
def rel_path(meta: MediaMeta, ext: str, layout: str = "plex") -> str:
|
def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | None = None) -> str:
|
||||||
"""Path of the media file relative to DOWNLOAD_ROOT (forward slashes)."""
|
"""Path of the media file relative to DOWNLOAD_ROOT (forward slashes).
|
||||||
|
|
||||||
|
`asset_id` disambiguates the file. A MediaAsset is unique on (source_kind, source_ref,
|
||||||
|
format_sig), so the SAME video downloaded in two formats is two distinct assets — but without
|
||||||
|
the id they compute the identical path, so the second silently overwrites the first and deleting
|
||||||
|
either removes the shared file. The `_a{id}` suffix (which Plex ignores — the .nfo sidecar carries
|
||||||
|
the metadata) keeps them apart. Optional so callers/tests that don't need it keep the plain name."""
|
||||||
title = sanitize(meta.title)
|
title = sanitize(meta.title)
|
||||||
vid = meta.video_id
|
vid = meta.video_id
|
||||||
|
disc = f"_a{asset_id}" if asset_id is not None else ""
|
||||||
if layout == "flat":
|
if layout == "flat":
|
||||||
return f"{title}_[{vid}].{ext}"
|
return f"{title}_[{vid}]{disc}.{ext}"
|
||||||
channel = sanitize(meta.uploader or "Unknown_Channel", 80)
|
channel = sanitize(meta.uploader or "Unknown_Channel", 80)
|
||||||
year = meta.upload_date.year if meta.upload_date else 0
|
year = meta.upload_date.year if meta.upload_date else 0
|
||||||
d = meta.upload_date.isoformat() if meta.upload_date else "0000-00-00"
|
d = meta.upload_date.isoformat() if meta.upload_date else "0000-00-00"
|
||||||
epname = f"{channel}_-_{d}_-_{title}_[{vid}].{ext}"
|
epname = f"{channel}_-_{d}_-_{title}_[{vid}]{disc}.{ext}"
|
||||||
return f"{channel}/Season_{year}/{epname}"
|
return f"{channel}/Season_{year}/{epname}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ survive reloads/devices and are produced server-side. First producer is the main
|
|||||||
job. Unread rows never auto-expire; read rows are trimmed past a soft per-user cap so the
|
job. Unread rows never auto-expire; read rows are trimmed past a soft per-user cap so the
|
||||||
table can't grow without bound for a heavy user.
|
table can't grow without bound for a heavy user.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Notification
|
from app.models import Notification
|
||||||
@@ -52,7 +52,7 @@ def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int:
|
|||||||
)
|
)
|
||||||
if not ids:
|
if not ids:
|
||||||
return 0
|
return 0
|
||||||
for nid in ids:
|
# One bulk DELETE rather than a get+delete per row (the ids are already in hand).
|
||||||
db.delete(db.get(Notification, nid))
|
db.execute(delete(Notification).where(Notification.id.in_(ids)))
|
||||||
db.commit()
|
db.commit()
|
||||||
return len(ids)
|
return len(ids)
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ def discover_channels(
|
|||||||
# (fresh subscriber counts/thumbnails carry through without a re-query). Only the title can
|
# (fresh subscriber counts/thumbnails carry through without a re-query). Only the title can
|
||||||
# change, which affects the tie-break order → re-sort in Python instead of re-hitting the DB.
|
# change, which affects the tie-break order → re-sort in Python instead of re-hitting the DB.
|
||||||
need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP]
|
need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP]
|
||||||
if need:
|
# Skip the quota-spending enrich for the demo account (it just sees the stub metadata).
|
||||||
|
if need and not user.is_demo:
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
|
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
|
||||||
apply_channel_details(db, yt.get_channels(need))
|
apply_channel_details(db, yt.get_channels(need))
|
||||||
@@ -390,7 +391,10 @@ def update_channel(
|
|||||||
sub.priority = int(payload["priority"])
|
sub.priority = int(payload["priority"])
|
||||||
if "hidden" in payload:
|
if "hidden" in payload:
|
||||||
sub.hidden = bool(payload["hidden"])
|
sub.hidden = bool(payload["hidden"])
|
||||||
if "deep_requested" in payload:
|
# The demo account is SHARED, so it must not persist deep_requested (a quota-relevant flag) onto
|
||||||
|
# the shared subscription — skip the write entirely for demo, which also keeps deep_turned_on
|
||||||
|
# False so the immediate backfill below can never fire for it.
|
||||||
|
if "deep_requested" in payload and not user.is_demo:
|
||||||
# Opt this channel into (or out of) full-history backfill. The deep scheduler
|
# Opt this channel into (or out of) full-history backfill. The deep scheduler
|
||||||
# picks the flag up on its next run; turning it off won't undo already-fetched
|
# picks the flag up on its next run; turning it off won't undo already-fetched
|
||||||
# videos, it just stops further deep paging if nobody else still wants it.
|
# videos, it just stops further deep paging if nobody else still wants it.
|
||||||
@@ -402,6 +406,7 @@ def update_channel(
|
|||||||
# Opting a channel into full history should give an immediate feed: if we haven't even
|
# Opting a channel into full history should give an immediate feed: if we haven't even
|
||||||
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
|
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
|
||||||
# the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep).
|
# the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep).
|
||||||
|
# (deep_turned_on is only ever True for a non-demo user — see the guard above.)
|
||||||
if deep_turned_on and channel is not None and channel.recent_synced_at is None:
|
if deep_turned_on and channel is not None and channel.recent_synced_at is None:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||||
run_recent_backfill(db, [channel], max_channels=1)
|
run_recent_backfill(db, [channel], max_channels=1)
|
||||||
|
|||||||
@@ -737,6 +737,11 @@ def get_video_detail(
|
|||||||
"duration_seconds": v.duration_seconds,
|
"duration_seconds": v.duration_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the
|
||||||
|
# demo account can't reach it — it just sees "unknown" for a video we don't already store.
|
||||||
|
if user.is_demo:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown video")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), YouTubeClient(db, user) as yt:
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), YouTubeClient(db, user) as yt:
|
||||||
items = yt.get_videos([video_id])
|
items = yt.get_videos([video_id])
|
||||||
|
|||||||
+19
-3
@@ -66,6 +66,22 @@ def _job_status(job_id: int) -> str | None:
|
|||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def _requeue_if_running(job_id: int, phase: str) -> None:
|
||||||
|
"""Put the job back to 'queued' for a later retry, but ONLY while it is still 'running'. The
|
||||||
|
route flips status to paused/cancelled to stop a job; a blind `status='queued'` here would
|
||||||
|
clobber that and silently revive a job the user just cancelled. The conditional WHERE makes the
|
||||||
|
requeue a no-op unless we still own a running job."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE download_jobs SET status='queued', phase=:phase, updated_at=now() "
|
||||||
|
"WHERE id=:id AND status='running'"
|
||||||
|
),
|
||||||
|
{"id": job_id, "phase": phase},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def _parse_upload_date(raw) -> date | None:
|
def _parse_upload_date(raw) -> date | None:
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
@@ -179,13 +195,13 @@ def _process_job(job_id: int) -> None:
|
|||||||
return
|
return
|
||||||
if asset_status == "downloading":
|
if asset_status == "downloading":
|
||||||
# Another worker owns this asset; wait and let the job be reclaimed later.
|
# Another worker owns this asset; wait and let the job be reclaimed later.
|
||||||
_set_job(job_id, status="queued", phase="waiting")
|
_requeue_if_running(job_id, "waiting")
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
return
|
return
|
||||||
|
|
||||||
# asset_status == "pending": try to win the download / edit.
|
# asset_status == "pending": try to win the download / edit.
|
||||||
if not _claim_asset(asset_id):
|
if not _claim_asset(asset_id):
|
||||||
_set_job(job_id, status="queued", phase="waiting")
|
_requeue_if_running(job_id, "waiting")
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -414,7 +430,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
)
|
)
|
||||||
|
|
||||||
layout, retention_days = _download_settings()
|
layout, retention_days = _download_settings()
|
||||||
rel = storage.rel_path(meta, ext, layout)
|
rel = storage.rel_path(meta, ext, layout, asset_id=asset_id)
|
||||||
thumb = _find_thumbnail(staging, produced)
|
thumb = _find_thumbnail(staging, produced)
|
||||||
storage.place_file(produced, settings.download_root, rel)
|
storage.place_file(produced, settings.download_root, rel)
|
||||||
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb)
|
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb)
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ class TestSanitize:
|
|||||||
def test_length_is_capped(self):
|
def test_length_is_capped(self):
|
||||||
assert len(sanitize("x" * 500, limit=10)) <= 10
|
assert len(sanitize("x" * 500, limit=10)) <= 10
|
||||||
|
|
||||||
|
def test_cap_is_by_bytes_not_characters(self):
|
||||||
|
# Accented (2-byte UTF-8) letters must not blow past the byte limit — the FS component cap
|
||||||
|
# is 255 BYTES, so a char-only cap would overflow for multi-byte titles.
|
||||||
|
out = sanitize("á" * 500, limit=10)
|
||||||
|
assert len(out.encode("utf-8")) <= 10
|
||||||
|
# And it never splits a character (would raise otherwise / leave a replacement char).
|
||||||
|
out.encode("utf-8").decode("utf-8")
|
||||||
|
|
||||||
def test_empty_or_all_junk_falls_back_to_untitled(self):
|
def test_empty_or_all_junk_falls_back_to_untitled(self):
|
||||||
assert sanitize("") == "untitled"
|
assert sanitize("") == "untitled"
|
||||||
assert sanitize("🔥🔥") == "untitled"
|
assert sanitize("🔥🔥") == "untitled"
|
||||||
@@ -54,6 +62,29 @@ class TestRelPath:
|
|||||||
out = rel_path(meta, "mp4")
|
out = rel_path(meta, "mp4")
|
||||||
assert "Season_0/" in out and "0000-00-00" in out
|
assert "Season_0/" in out and "0000-00-00" in out
|
||||||
|
|
||||||
|
def test_asset_id_disambiguates_two_formats_of_the_same_video(self):
|
||||||
|
# Same video, two assets (e.g. 1080p vs 720p) must NOT collide on one path.
|
||||||
|
a = rel_path(self.meta, "mp4", asset_id=5)
|
||||||
|
b = rel_path(self.meta, "mp4", asset_id=6)
|
||||||
|
assert a != b
|
||||||
|
assert a.endswith("_[abc123]_a5.mp4") and b.endswith("_[abc123]_a6.mp4")
|
||||||
|
# Omitting it keeps the plain name (unchanged for callers/tests that don't pass one).
|
||||||
|
assert rel_path(self.meta, "mp4", layout="flat") == "Cool_Clip_[abc123].mp4"
|
||||||
|
assert rel_path(self.meta, "mp4", layout="flat", asset_id=7) == "Cool_Clip_[abc123]_a7.mp4"
|
||||||
|
|
||||||
|
def test_every_component_stays_within_the_255_byte_fs_limit(self):
|
||||||
|
# A long accented channel AND title, plus the asset-id suffix, must not overflow any path
|
||||||
|
# component (the byte cap in sanitize is what guarantees this).
|
||||||
|
meta = MediaMeta(
|
||||||
|
video_id="abcdefghijk",
|
||||||
|
title="Á" * 300,
|
||||||
|
uploader="Ö" * 300,
|
||||||
|
upload_date=date(2024, 5, 2),
|
||||||
|
)
|
||||||
|
out = rel_path(meta, "webm", asset_id=999999)
|
||||||
|
for component in out.split("/"):
|
||||||
|
assert len(component.encode("utf-8")) <= 255, component
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadFilename:
|
class TestDownloadFilename:
|
||||||
def test_appends_the_container_extension(self):
|
def test_appends_the_container_extension(self):
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ import ErrorDialog from "./components/ErrorDialog";
|
|||||||
import VersionBanner from "./components/VersionBanner";
|
import VersionBanner from "./components/VersionBanner";
|
||||||
import DemoBanner from "./components/DemoBanner";
|
import DemoBanner from "./components/DemoBanner";
|
||||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||||
import { CURRENT_VERSION } from "./lib/releaseNotes";
|
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
||||||
|
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
|
||||||
|
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together).
|
||||||
|
import { FRONTEND_VERSION } from "./lib/version";
|
||||||
|
|
||||||
// The page modules (and their side rails) are code-split per route via the module registry
|
// The page modules (and their side rails) are code-split per route via the module registry
|
||||||
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
|
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
|
||||||
@@ -389,7 +392,7 @@ export default function App() {
|
|||||||
banners={
|
banners={
|
||||||
<>
|
<>
|
||||||
{meQuery.data!.is_demo && <DemoBanner />}
|
{meQuery.data!.is_demo && <DemoBanner />}
|
||||||
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ export default function ChannelPage({
|
|||||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
||||||
},
|
},
|
||||||
|
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case),
|
||||||
|
// and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) —
|
||||||
|
// a caller toast here would just double it.
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscribe = useMutation({
|
const subscribe = useMutation({
|
||||||
@@ -144,6 +147,7 @@ export default function ChannelPage({
|
|||||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
},
|
},
|
||||||
|
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
|
||||||
});
|
});
|
||||||
const onUnsubscribe = async () => {
|
const onUnsubscribe = async () => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
|
|||||||
@@ -681,6 +681,8 @@ export default function DownloadCenter() {
|
|||||||
? api.cancelDownload(id)
|
? api.cancelDownload(id)
|
||||||
: api.deleteDownload(id),
|
: api.deleteDownload(id),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
|
// No onError: a failed job action returns 400/409/422/500, already surfaced by the global
|
||||||
|
// error modal (api.ts req) with the backend's specific message — a caller toast would double it.
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
|
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export default function Feed({
|
|||||||
meta: {
|
meta: {
|
||||||
kind: "video-hidden",
|
kind: "video-hidden",
|
||||||
videoId: id,
|
videoId: id,
|
||||||
title: v?.title ?? "this video",
|
title: v?.title ?? i18n.t("feed.thisVideo"),
|
||||||
channelId: v?.channel_id ?? "",
|
channelId: v?.channel_id ?? "",
|
||||||
channelName: v?.channel_title ?? "",
|
channelName: v?.channel_title ?? "",
|
||||||
},
|
},
|
||||||
@@ -270,7 +270,7 @@ export default function Feed({
|
|||||||
meta: {
|
meta: {
|
||||||
kind: "video-watched",
|
kind: "video-watched",
|
||||||
videoId: id,
|
videoId: id,
|
||||||
title: v?.title ?? "this video",
|
title: v?.title ?? i18n.t("feed.thisVideo"),
|
||||||
channelId: v?.channel_id ?? "",
|
channelId: v?.channel_id ?? "",
|
||||||
channelName: v?.channel_title ?? "",
|
channelName: v?.channel_title ?? "",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
|
|
||||||
@@ -8,6 +9,13 @@ import { useBackToClose } from "../lib/history";
|
|||||||
let modalStack: number[] = [];
|
let modalStack: number[] = [];
|
||||||
let nextModalId = 1;
|
let nextModalId = 1;
|
||||||
|
|
||||||
|
/** How many <Modal>s are currently open. The players (which use their own window-level Escape
|
||||||
|
* listeners, not this stack) check it so their Escape defers to a Modal opened above them —
|
||||||
|
* otherwise one Escape closes the player UNDER the dialog and then the dialog too (U-C). */
|
||||||
|
export function modalCount(): number {
|
||||||
|
return modalStack.length;
|
||||||
|
}
|
||||||
|
|
||||||
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
|
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
|
||||||
export default function Modal({
|
export default function Modal({
|
||||||
title,
|
title,
|
||||||
@@ -20,6 +28,7 @@ export default function Modal({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||||
useBackToClose(onClose);
|
useBackToClose(onClose);
|
||||||
|
|
||||||
@@ -69,7 +78,8 @@ export default function Modal({
|
|||||||
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
|
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close"
|
title={t("common.close")}
|
||||||
|
aria-label={t("common.close")}
|
||||||
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { type ReactNode } from "react";
|
|||||||
import {
|
import {
|
||||||
closestCenter,
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
import type { PanelLayout } from "../lib/panelLayout";
|
import type { PanelLayout } from "../lib/panelLayout";
|
||||||
import PanelGroup from "./PanelGroup";
|
import PanelGroup from "./PanelGroup";
|
||||||
|
|
||||||
@@ -29,7 +35,12 @@ export default function PanelGroups({
|
|||||||
setLayout: (l: PanelLayout) => void;
|
setLayout: (l: PanelLayout) => void;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
}) {
|
}) {
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
|
// The group grips are focusable and call themselves reorderable; without this only a pointer
|
||||||
|
// could actually move them (keyboard drag matched the other sortable lists).
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
const byId = new Map(items.map((it) => [it.id, it]));
|
const byId = new Map(items.map((it) => [it.id, it]));
|
||||||
|
|
||||||
const orderedAvailable = layout.order.filter((id) => byId.has(id));
|
const orderedAvailable = layout.order.filter((id) => byId.has(id));
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
|
import { modalCount } from "./Modal";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
@@ -375,6 +376,9 @@ export default function PlayerModal({
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||||
if (document.fullscreenElement) return;
|
if (document.fullscreenElement) return;
|
||||||
|
// Defer to any Modal opened above the player (e.g. the download dialog): its Escape closes
|
||||||
|
// it, ours would otherwise also fire and close the player underneath it (U-C).
|
||||||
|
if (modalCount() > 0) return;
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
|
|||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
|
import { modalCount } from "./Modal";
|
||||||
|
|
||||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||||
@@ -809,6 +810,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
wake(); // keyboard use also reveals the controls
|
wake(); // keyboard use also reveals the controls
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case " ":
|
case " ":
|
||||||
|
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
|
||||||
|
// playback when the focus isn't on a control — matching PlayerModal's guard.
|
||||||
|
if (tag === "BUTTON" || tag === "A") return;
|
||||||
|
e.preventDefault();
|
||||||
|
togglePlay();
|
||||||
|
break;
|
||||||
case "k":
|
case "k":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
togglePlay();
|
togglePlay();
|
||||||
@@ -861,6 +868,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
else handleBack();
|
else handleBack();
|
||||||
break;
|
break;
|
||||||
case "Escape":
|
case "Escape":
|
||||||
|
// Defer to any Modal opened above the player — let its Escape close it, not ours (U-C).
|
||||||
|
if (modalCount() > 0) break;
|
||||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||||
else if (menuOpenRef.current) {
|
else if (menuOpenRef.current) {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
@@ -1332,7 +1341,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
style={tracksFade.style}
|
style={tracksFade.style}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
|
className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||||
>
|
>
|
||||||
{detail.audio_streams.length > 1 && (
|
{detail.audio_streams.length > 1 && (
|
||||||
<>
|
<>
|
||||||
@@ -1395,7 +1404,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="glass-menu absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||||
>
|
>
|
||||||
<div className="mb-1.5 flex gap-1">
|
<div className="mb-1.5 flex gap-1">
|
||||||
{settingsTabs.map((tab) => (
|
{settingsTabs.map((tab) => (
|
||||||
|
|||||||
@@ -20,13 +20,19 @@ export default function ReleaseNotes({
|
|||||||
highlight?: string;
|
highlight?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Ring the newest note if `highlight` isn't one of the listed versions — e.g. a Vite dev build
|
||||||
|
// where the caller's FRONTEND_VERSION is "dev", or a VERSION bump whose note hasn't landed yet —
|
||||||
|
// so the banner always rings *something* current instead of nothing.
|
||||||
|
const active = RELEASE_NOTES.some((r) => r.version === highlight)
|
||||||
|
? highlight
|
||||||
|
: RELEASE_NOTES[0]?.version;
|
||||||
return (
|
return (
|
||||||
<Modal title={t("about.releaseNotes")} onClose={onClose} maxWidth="max-w-2xl">
|
<Modal title={t("about.releaseNotes")} onClose={onClose} maxWidth="max-w-2xl">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{RELEASE_NOTES.map((r) => (
|
{RELEASE_NOTES.map((r) => (
|
||||||
<section
|
<section
|
||||||
key={r.version}
|
key={r.version}
|
||||||
className={highlight === r.version ? "rounded-xl ring-1 ring-accent/40 p-3 -m-3" : ""}
|
className={active === r.version ? "rounded-xl ring-1 ring-accent/40 p-3 -m-3" : ""}
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline gap-2 flex-wrap">
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
<h3 className="text-base font-semibold">v{r.version}</h3>
|
<h3 className="text-base font-semibold">v{r.version}</h3>
|
||||||
|
|||||||
@@ -533,6 +533,9 @@ function PlexWatchSync() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say
|
||||||
|
// "connect YouTube"), and their 409/500 are already surfaced by the global error modal — a caller
|
||||||
|
// toast would double it. Actual import failures come back as 200 {error}, handled by announce.
|
||||||
const toggle = useMutation({
|
const toggle = useMutation({
|
||||||
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
|
|||||||
@@ -16,12 +16,23 @@ export default function Toaster() {
|
|||||||
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
|
// Stable, always-mounted polite live region — a polite region must PRE-EXIST in the DOM for a
|
||||||
|
// screen reader to announce content later inserted into it (a region created together with its
|
||||||
|
// first message is unreliably announced). `aria-live` (not role="status") on purpose:
|
||||||
|
// role="status" implies aria-atomic="true", which would re-read the WHOLE stack on every toast;
|
||||||
|
// aria-live defaults atomic=false, so only the newly-added toast is spoken. Error/fatal toasts
|
||||||
|
// additionally carry role="alert", which announces assertively on insertion regardless.
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]"
|
||||||
|
>
|
||||||
{toasts.map((toast) => {
|
{toasts.map((toast) => {
|
||||||
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
||||||
|
const urgent = toast.level === "error" || toast.level === "fatal";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
|
role={urgent ? "alert" : undefined}
|
||||||
className="toast-card glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
className="toast-card glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
||||||
>
|
>
|
||||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ function Preview({
|
|||||||
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
|
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
|
||||||
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
|
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
|
||||||
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
|
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
@@ -235,7 +236,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label={t("common.close")}
|
||||||
className="absolute top-4 right-4 p-2 rounded-full glass-card glass-hover text-fg"
|
className="absolute top-4 right-4 p-2 rounded-full glass-card glass-hover text-fg"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"markedWatchedNamed": "Marked watched “{{title}}”",
|
"markedWatchedNamed": "Marked watched “{{title}}”",
|
||||||
"markedWatched": "Marked watched",
|
"markedWatched": "Marked watched",
|
||||||
|
"thisVideo": "this video",
|
||||||
"unwatch": "Unwatch",
|
"unwatch": "Unwatch",
|
||||||
"source": {
|
"source": {
|
||||||
"label": "Source",
|
"label": "Source",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"undo": "Visszavonás",
|
"undo": "Visszavonás",
|
||||||
"markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”",
|
"markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”",
|
||||||
"markedWatched": "Megnézettnek jelölve",
|
"markedWatched": "Megnézettnek jelölve",
|
||||||
|
"thisVideo": "ez a videó",
|
||||||
"unwatch": "Megnézés visszavonása",
|
"unwatch": "Megnézés visszavonása",
|
||||||
"source": {
|
"source": {
|
||||||
"label": "Forrás",
|
"label": "Forrás",
|
||||||
|
|||||||
@@ -129,6 +129,27 @@ let items: Notification[] = load();
|
|||||||
let listeners: Array<() => void> = [];
|
let listeners: Array<() => void> = [];
|
||||||
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1;
|
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1;
|
||||||
|
|
||||||
|
// Per-toast auto-dismiss timers. Keyed by id so re-arming (a coalesced repeat re-surfacing the same
|
||||||
|
// toast) can CLEAR the previous timer first — otherwise the old one still fires at the original
|
||||||
|
// deadline and dismisses the re-surfaced toast early (C-3.25).
|
||||||
|
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
function armDismiss(id: number, duration: number | undefined): void {
|
||||||
|
// Always clear any existing timer first. A falsy duration then arms nothing — so a toast that a
|
||||||
|
// coalesced repeat upgraded to requiresInteraction (duration undefined) correctly STAYS, instead
|
||||||
|
// of the original timer firing and dismissing the now-must-interact toast.
|
||||||
|
const existing = dismissTimers.get(id);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
dismissTimers.delete(id);
|
||||||
|
}
|
||||||
|
if (duration)
|
||||||
|
dismissTimers.set(
|
||||||
|
id,
|
||||||
|
setTimeout(() => dismiss(id), duration),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Cached derived snapshots — useSyncExternalStore needs stable references between
|
// Cached derived snapshots — useSyncExternalStore needs stable references between
|
||||||
// emits, so we recompute these only when `items` changes.
|
// emits, so we recompute these only when `items` changes.
|
||||||
let cachedActive: Notification[] = [];
|
let cachedActive: Notification[] = [];
|
||||||
@@ -243,7 +264,7 @@ export function notify(input: NotifyInput): number {
|
|||||||
: n,
|
: n,
|
||||||
);
|
);
|
||||||
emit();
|
emit();
|
||||||
if (duration) setTimeout(() => dismiss(prior.id), duration);
|
armDismiss(prior.id, duration); // clears the prior timer even when the repeat is now must-interact
|
||||||
// Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet.
|
// Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet.
|
||||||
return prior.id;
|
return prior.id;
|
||||||
}
|
}
|
||||||
@@ -275,12 +296,17 @@ export function notify(input: NotifyInput): number {
|
|||||||
) {
|
) {
|
||||||
beep();
|
beep();
|
||||||
}
|
}
|
||||||
if (duration) setTimeout(() => dismiss(id), duration);
|
armDismiss(id, duration);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the transient toast surface; the entry stays in the center's history. */
|
/** Close the transient toast surface; the entry stays in the center's history. */
|
||||||
export function dismiss(id: number): void {
|
export function dismiss(id: number): void {
|
||||||
|
const t = dismissTimers.get(id);
|
||||||
|
if (t) {
|
||||||
|
clearTimeout(t);
|
||||||
|
dismissTimers.delete(id);
|
||||||
|
}
|
||||||
items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n));
|
items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n));
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,23 @@ export interface ReleaseEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.50.0",
|
||||||
|
date: "2026-07-22",
|
||||||
|
summary: "A sweep of small correctness, keyboard and accessibility fixes across the app.",
|
||||||
|
fixes: [
|
||||||
|
"Downloads: two different formats of the same video no longer overwrite each other on disk (and deleting one no longer removes the other's file). Videos with very long titles — especially accented, non-English ones — no longer fail to save.",
|
||||||
|
"In the video player, pressing Escape now closes just the top layer — the open popover or dialog — instead of also closing the player behind it.",
|
||||||
|
"Plex player: the audio and subtitle menus are readable in the light theme again (they were white-on-white).",
|
||||||
|
"A repeated notification that resurfaces now stays on screen for its full time instead of vanishing early.",
|
||||||
|
"Unsubscribing from a channel now tells you if it failed, instead of failing silently.",
|
||||||
|
"The ✕ close button on dialogs is now translated (it was always English), and screen readers now announce toast notifications.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"Keyboard: reorder a side panel's sections with the keyboard, and Space now activates a focused button in the player instead of toggling playback.",
|
||||||
|
"Hardening: rate-limited the access-request endpoint and closed a couple of sign-in token races; the demo account can no longer spend YouTube quota; assorted internal safety and performance fixes.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.49.0",
|
version: "0.49.0",
|
||||||
date: "2026-07-21",
|
date: "2026-07-21",
|
||||||
@@ -923,5 +940,3 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CURRENT_VERSION = RELEASE_NOTES[0]?.version ?? "dev";
|
|
||||||
|
|||||||
@@ -17,7 +17,19 @@ export function useDismiss(
|
|||||||
if (!list.some((r) => r.current?.contains(target))) onClose();
|
if (!list.some((r) => r.current?.contains(target))) onClose();
|
||||||
};
|
};
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") {
|
||||||
|
// Stop the same Escape from also reaching a player's window-level listener underneath —
|
||||||
|
// this document listener bubbles first, so one Escape closes only this popover, not the
|
||||||
|
// popover AND the player behind it. (U-C quick-fix.)
|
||||||
|
//
|
||||||
|
// KNOWN LIMITATION (accepted, → R8 popover-platform): because this is a blanket
|
||||||
|
// stopPropagation, a PlexPlayer settings/tracks menu open DURING an auto-skip countdown
|
||||||
|
// swallows the Escape that would otherwise ALSO cancel the skip — Escape closes the menu,
|
||||||
|
// and the skip proceeds. Properly fixing this needs the shared layer-registry R8 builds
|
||||||
|
// (topmost-only dispatch), not a broad stopPropagation.
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", onDown);
|
document.addEventListener("mousedown", onDown);
|
||||||
document.addEventListener("keydown", onKey);
|
document.addEventListener("keydown", onKey);
|
||||||
|
|||||||
Reference in New Issue
Block a user