Compare commits

..
96 Commits
Author SHA1 Message Date
peter 2a70d145e9 Merge: promote dev to prod 2026-07-30 05:27:10 +02:00
peter bca67a3547 Merge: promote dev to prod 2026-07-29 18:33:57 +02:00
peter 2c7c8e0f8a Merge: promote dev to prod 2026-07-28 03:30:44 +02:00
peter 5e2195eb5a Merge: promote dev to prod 2026-07-28 03:21:39 +02:00
peter 53b599271d Merge: promote dev to prod 2026-07-28 03:01:00 +02:00
peter ab1272beb4 Merge: promote dev to prod 2026-07-28 00:25:52 +02:00
peter 8ee46aac09 Merge: promote dev to prod 2026-07-26 21:30:05 +02:00
peter edb0f8a4e1 Merge: promote dev to prod 2026-07-26 03:39:41 +02:00
peter 3a982b8be8 Merge: promote dev to prod 2026-07-26 01:24:30 +02:00
peter 9556b33648 Merge: promote dev to prod 2026-07-23 04:29:12 +02:00
peter 092df4f7eb Merge: promote dev to prod 2026-07-22 04:53:28 +02:00
peter 01ea9ab8e1 Merge: promote dev to prod 2026-07-22 03:19:24 +02:00
peter 9089096d1e Merge: promote dev to prod 2026-07-22 02:53:15 +02:00
peter e34cd7b04f Merge: promote dev to prod 2026-07-22 00:34:57 +02:00
peter 47d955b828 Merge: promote dev to prod 2026-07-21 04:32:10 +02:00
peter 486a5cd1b0 Merge: promote dev to prod 2026-07-21 00:47:08 +02:00
peter ca9fd2a5ce Merge: promote dev to prod 2026-07-20 22:33:37 +02:00
peter 8501509acb Merge: promote dev to prod 2026-07-20 01:32:45 +02:00
peter 9f26ab3c80 Merge: promote dev to prod 2026-07-19 06:09:30 +02:00
peter 29b8dd4629 Merge: promote dev to prod 2026-07-19 04:59:36 +02:00
peter 9adb403ca8 Merge: promote dev to prod 2026-07-16 01:24:24 +02:00
peter 759fb41f78 Merge: promote dev to prod 2026-07-15 20:33:56 +02:00
peter d6d6a0f717 Merge: promote dev to prod 2026-07-15 19:54:07 +02:00
peter a3b62a2535 Merge: promote dev to prod 2026-07-13 05:16:36 +02:00
peter 2834056025 Merge: promote dev to prod 2026-07-12 17:59:43 +02:00
peter 2840d1b532 Merge: promote dev to prod 2026-07-12 06:56:51 +02:00
peter 70f419edf3 Merge: promote dev to prod 2026-07-12 04:58:45 +02:00
peter 2fef26cd75 Merge: promote dev to prod 2026-07-12 03:51:36 +02:00
peter 4353327e30 Merge: promote dev to prod 2026-07-12 02:26:55 +02:00
peter 7842e48952 Merge: promote dev to prod 2026-07-11 03:52:32 +02:00
peter 1401294ad1 Merge: promote dev to prod 2026-07-10 18:16:15 +02:00
peter 409f8c0ce8 Merge: promote dev to prod 2026-07-10 17:32:43 +02:00
peter c3987a8d52 Merge: promote dev to prod 2026-07-10 16:58:07 +02:00
peter 92bba3abf2 Merge: promote dev to prod 2026-07-10 16:29:30 +02:00
peter 1d908cabb2 Merge: promote dev to prod 2026-07-10 15:54:24 +02:00
peter 033b1d454e Merge: promote dev to prod 2026-07-10 00:50:10 +02:00
peter f863181778 Merge: promote dev to prod 2026-07-09 15:36:21 +02:00
peter 91eb8d464a Merge: promote dev to prod 2026-07-09 15:20:48 +02:00
peter ecdbde04e7 Merge: promote dev to prod 2026-07-09 14:44:48 +02:00
peter 26ae20ec0c Merge: promote dev to prod 2026-07-09 09:46:14 +02:00
peter bb4e9f5e57 Merge: promote dev to prod 2026-07-09 02:37:31 +02:00
peter 6bdc872c1c Merge: promote dev to prod 2026-07-07 22:58:09 +02:00
peter 0c761533e7 Merge: promote dev to prod 2026-07-07 22:44:45 +02:00
peter 4fbabefb81 Merge: promote dev to prod 2026-07-07 21:43:10 +02:00
peter 0256fddfb8 Merge: promote dev to prod 2026-07-07 20:21:38 +02:00
peter 938cf2d8da Merge: promote dev to prod 2026-07-06 20:22:13 +02:00
peter ded573ff16 Merge: promote dev to prod 2026-07-06 17:35:14 +02:00
peter dfc858759e Merge: promote dev to prod 2026-07-06 08:47:22 +02:00
peter fb6a1d3ba9 Merge: promote dev to prod 2026-07-06 08:06:11 +02:00
peter d5dccf4c92 Merge: promote dev to prod 2026-07-06 07:16:13 +02:00
peter bc9b208de1 Merge: promote dev to prod 2026-07-06 06:52:53 +02:00
peter b262669cea Merge: promote dev to prod 2026-07-06 01:29:40 +02:00
peter 0283773456 Merge: promote dev to prod 2026-07-06 00:26:00 +02:00
peter 1741607ebf Merge: promote dev to prod 2026-07-05 22:10:19 +02:00
peter e3d26ef671 Merge: promote dev to prod 2026-07-05 21:12:19 +02:00
peter 3287aa1208 Merge: promote dev to prod 2026-07-05 19:00:57 +02:00
peter b75f07a2bb Merge: promote dev to prod 2026-07-05 06:30:19 +02:00
peter e1aa0e29c3 Merge: promote dev to prod 2026-07-05 00:33:30 +02:00
peter c5fd4dfa0f Merge: promote dev to prod 2026-07-04 20:59:53 +02:00
peter a8e498d730 Merge: promote dev to prod 2026-07-04 17:34:36 +02:00
peter fde5066fd8 Merge: promote dev to prod 2026-07-04 06:44:48 +02:00
peter 0e552e452d Merge: promote dev to prod 2026-07-04 06:36:02 +02:00
peter 1b91d7673d Merge: promote dev to prod 2026-07-02 02:20:39 +02:00
peter e2ad2ab798 Merge: promote dev to prod 2026-07-01 14:32:25 +02:00
peter 870fff2196 Merge: promote dev to prod 2026-07-01 12:08:48 +02:00
peter a13d24d249 Merge: promote dev to prod 2026-07-01 11:29:48 +02:00
peter 7b18fdc47a Merge: promote dev to prod 2026-07-01 02:38:15 +02:00
peter d3d1760f15 Merge: promote dev to prod 2026-06-29 23:43:44 +02:00
peter 3d2755d138 Merge: promote dev to prod 2026-06-29 01:13:52 +02:00
peter a146a353f7 Merge: promote dev to prod 2026-06-26 01:42:54 +02:00
peter e1f8bf0bd8 Merge: promote dev to prod 2026-06-26 00:37:25 +02:00
peter b4e50d348e Merge: promote dev to prod 2026-06-26 00:02:57 +02:00
peter bc81ac5d08 Merge: promote dev to prod 2026-06-25 20:06:44 +02:00
peter 0831ae78fe Merge: promote dev to prod 2026-06-21 07:11:58 +02:00
peter 43e2805ec3 Merge: promote dev to prod 2026-06-21 05:53:19 +02:00
peter c1ff1bad1b Merge: promote dev to prod 2026-06-21 05:26:36 +02:00
peter fed1446b90 Merge: promote dev to prod 2026-06-21 03:28:56 +02:00
peter d950bfd477 Merge: promote dev to prod 2026-06-19 19:54:26 +02:00
peter 4e990efab0 Merge: promote dev to prod 2026-06-19 04:27:30 +02:00
peter 139e0d067d Merge: promote dev to prod 2026-06-19 04:16:41 +02:00
peter 022bab3518 Merge: promote dev to prod 2026-06-19 04:02:45 +02:00
peter 7c0655a8f5 Merge: promote dev to prod 2026-06-19 00:20:38 +02:00
peter db6b108a6f Merge: promote dev to prod 2026-06-19 00:07:35 +02:00
peter 657cdfa507 Merge: promote dev to prod 2026-06-18 17:56:49 +02:00
peter a1fd6fce91 Merge: promote dev to prod 2026-06-18 04:39:29 +02:00
peter 19eb0242f8 Merge: promote dev to prod 2026-06-18 01:19:23 +02:00
peter 0edd7af45d Merge: promote dev to prod 2026-06-17 19:18:19 +02:00
peter c2bf5b025c Merge: promote dev to prod 2026-06-16 17:46:15 +02:00
peter 1b2e0741c6 Merge: promote dev to prod 2026-06-16 12:02:38 +02:00
peter 659fb5b228 Merge: promote dev to prod 2026-06-16 10:23:30 +02:00
peter 831dee52bc Merge: promote dev to prod 2026-06-16 10:19:05 +02:00
peter 6d2042d8ae Merge: promote dev to prod 2026-06-16 03:18:58 +02:00
peter 079c8182b4 Merge: promote dev to prod 2026-06-16 02:16:33 +02:00
peter 424e820276 Merge: promote dev to prod 2026-06-16 00:17:10 +02:00
peter ff1d36238f Merge: promote dev to prod 2026-06-15 13:58:34 +02:00
peter bd2e39d6f8 Merge: promote dev to prod 2026-06-15 04:32:05 +02:00
33 changed files with 1318 additions and 2141 deletions
+1 -1
View File
@@ -1 +1 @@
0.59.0
0.58.1
-12
View File
@@ -230,7 +230,6 @@ def _channel_detail_dict(
blocked: bool,
stored: int,
deep_requested: bool,
tag_ids: list[int],
) -> dict:
return {
"id": channel.id,
@@ -258,9 +257,6 @@ def _channel_detail_dict(
"backfill_done": channel.backfill_done,
"from_explore": channel.from_explore,
"details_synced": channel.details_synced_at is not None,
# This user's personal tag links for the channel (same shape as the manager list), so the
# channel page can offer the same tag editor next to Subscribe.
"tag_ids": tag_ids,
}
@@ -301,13 +297,6 @@ def channel_detail(
).first() is not None
blocked = _is_blocked(db, user, channel_id)
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
tag_ids = list(
db.scalars(
select(ChannelTag.tag_id).where(
ChannelTag.user_id == user.id, ChannelTag.channel_id == channel_id
)
).all()
)
return _channel_detail_dict(
channel,
subscribed=subscribed,
@@ -315,7 +304,6 @@ def channel_detail(
blocked=blocked,
stored=int(stored),
deep_requested=bool(sub.deep_requested) if sub else False,
tag_ids=tag_ids,
)
-54
View File
@@ -1,54 +0,0 @@
"""DB-backed regression (R8 Part 2): GET /api/channels/{id} returns exactly THIS user's personal
tag links for the channel — not another user's, and not the auto/system links (user_id NULL) —
so the channel detail page can reuse the manager's tag editor. Would ship broken if the tag_ids
query ever dropped its user_id filter.
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from datetime import datetime, timezone
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import Channel, ChannelTag, Tag, User
def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch):
# published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests).
ch = Channel(
id="UC_tags_test",
title="Tag Test Channel",
published_at=datetime(2020, 1, 1, tzinfo=timezone.utc),
)
db.add(ch)
me = User(email="me@example.com")
other = User(email="other@example.com")
db.add_all([me, other])
db.flush()
mine = Tag(user_id=me.id, name="Mine", category="other")
theirs = Tag(user_id=other.id, name="Theirs", category="other")
auto = Tag(user_id=None, name="English", category="language") # a system/auto tag
db.add_all([mine, theirs, auto])
db.flush()
# My personal link, another user's link, and a system/auto link on the same channel — only MINE
# must come back for `me`.
db.add(ChannelTag(channel_id=ch.id, tag_id=mine.id, user_id=me.id, source="user"))
db.add(ChannelTag(channel_id=ch.id, tag_id=theirs.id, user_id=other.id, source="user"))
db.add(ChannelTag(channel_id=ch.id, tag_id=auto.id, user_id=None, source="auto"))
db.commit()
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: me
try:
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
resp = client.get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
assert resp.json()["tag_ids"] == [mine.id]
-60
View File
@@ -9,7 +9,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.20",
"@tanstack/react-query": "^5.51.0",
"@tanstack/react-virtual": "^3.14.3",
"clsx": "^2.1.1",
@@ -996,59 +995,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.20",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
"integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.9",
"@floating-ui/utils": "^0.2.12",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -5348,12 +5294,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
-1
View File
@@ -22,7 +22,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.20",
"@tanstack/react-query": "^5.51.0",
"@tanstack/react-virtual": "^3.14.3",
"clsx": "^2.1.1",
+109 -39
View File
@@ -1,34 +1,28 @@
import { useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
const MARGIN = 8;
const POP_W = 480; // the overlay's fixed width (px)
/**
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
* (the same content as the detail page's About tab — description, links, country, language, topics,
* keywords) in a glass hover card. The detail is fetched ON DEMAND on hover/focus, sharing the
* ["channel", id] react-query cache with the channel page, so the list stays light. The card is
* interactive (scrollable, links clickable) — the platform's safePolygon lets the cursor travel the
* gap, replacing the hand-rolled 160 ms hide-timer bridge. Shared by the subscribed + discovery
* tables.
*
* The card keeps the platform's default hover role (`tooltip`); making this rich, interactive panel
* properly keyboard-reachable belongs to R9 (focus/a11y), not to the mechanical migration.
* keywords) in a portalled glass overlay. The detail is fetched ON DEMAND on hover/focus, sharing
* the ["channel", id] react-query cache with the channel page, so the list stays light. The overlay
* is interactive (scrollable, links clickable) via a small hover-bridge; it closes on page scroll
* (but not when scrolling inside itself) or resize. Shared by the subscribed + discovery tables.
*/
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
const preview = (text ?? "").trim();
const ref = useRef<HTMLSpanElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open: open && !!preview,
onOpenChange: setOpen,
mode: "hover",
offset: 6,
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
hoverDelay: { open: 0, close: 160 },
});
const [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
const { data: ch } = useQuery({
queryKey: qk.channel(channelId),
@@ -37,29 +31,105 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
staleTime: 5 * 60_000,
});
function place() {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
setCoords({
left: Math.min(Math.max(r.left, MARGIN), window.innerWidth - POP_W - MARGIN),
top: r.bottom + 6,
});
}
function show() {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
place();
setOpen(true);
}
// Small grace period so the cursor can travel the gap from the cell to the overlay without it
// vanishing — the overlay's own mouseenter cancels it.
function scheduleHide() {
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => setOpen(false), 160);
}
function cancelHide() {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
}
useEffect(
() => () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
},
[],
);
// Close on page scroll (the fixed overlay would strand at a stale anchor) or resize — but NOT
// when the scroll happens inside the overlay itself (it is scrollable).
useEffect(() => {
if (!open) return;
const onScroll = (e: Event) => {
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
setOpen(false);
};
const onResize = () => setOpen(false);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open]);
// Flip above the anchor if the overlay would run off the bottom of the viewport.
useLayoutEffect(() => {
const pop = popRef.current;
const el = ref.current;
if (!open || !coords || !pop || !el) return;
const r = el.getBoundingClientRect();
const h = pop.offsetHeight;
if (r.bottom + 6 + h > window.innerHeight - MARGIN && r.top - 6 - h > MARGIN) {
const top = r.top - 6 - h;
if (Math.abs(top - coords.top) > 0.5) setCoords((c) => (c ? { ...c, top } : c));
}
}, [open, coords, ch]);
if (!preview) return <span className="text-muted"></span>;
return (
<>
<span
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
tabIndex={0}
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
>
{preview}
</span>
<PopoverSurface
popover={pop}
className="glass w-[480px] max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
>
{/* Full About once the detail loads; until then the list's truncated description. */}
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
<span
ref={ref}
onMouseEnter={show}
onMouseLeave={scheduleHide}
onFocus={show}
onBlur={scheduleHide}
tabIndex={0}
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
>
{preview}
{open &&
coords &&
createPortal(
<div
ref={popRef}
onMouseEnter={cancelHide}
onMouseLeave={scheduleHide}
style={{ position: "fixed", left: coords.left, top: coords.top, width: POP_W }}
className="glass z-tooltip max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
>
{/* Full About once the detail loads; until then the list's truncated description. */}
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
)}
</div>,
document.body,
)}
</PopoverSurface>
</>
</span>
);
}
+103 -85
View File
@@ -1,17 +1,16 @@
import { useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
// video's membership in the user's playlists, with inline "new playlist" creation.
export default function AddToPlaylist({
videoId,
className,
@@ -22,25 +21,60 @@ export default function AddToPlaylist({
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
offset: 6,
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
// roving list so they don't steal the caret.
hostsInput: true,
});
const membership = useQuery({
queryKey: qk.playlistMembership(videoId),
queryFn: () => api.playlists(videoId),
enabled: open,
});
// Re-place once the panel is actually rendered (so we know its real height) and whenever
// its content — hence height — changes, so the flip-above decision uses the true size.
useLayoutEffect(() => {
if (open) place();
}, [open, membership.data]);
function place() {
const r = triggerRef.current?.getBoundingClientRect();
if (!r) return;
const width = 240;
const margin = 8;
// Use the panel's real height once it's mounted; estimate on the first open.
const h = panelRef.current?.offsetHeight ?? 300;
let top = r.bottom + 6;
if (top + h > window.innerHeight - margin) {
// Not enough room below — open upward, clamped to the viewport.
top = Math.max(margin, r.top - h - 6);
}
const left = Math.min(Math.max(margin, r.left), window.innerWidth - width - margin);
setCoords({ top: Math.round(top), left: Math.round(left) });
}
function toggleOpen(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (!open) place();
setOpen((o) => !o);
}
useDismiss(open, () => setOpen(false), [panelRef, triggerRef]);
// Keep the panel anchored to the trigger as the page resizes/scrolls while open.
useEffect(() => {
if (!open) return;
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open]);
function refresh() {
qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) });
qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -61,7 +95,8 @@ export default function AddToPlaylist({
}
}
async function createAndAdd() {
async function createAndAdd(e: React.FormEvent) {
e.preventDefault();
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
@@ -82,15 +117,8 @@ export default function AddToPlaylist({
return (
<>
<button
ref={pop.refs.setReference}
{...pop.getReferenceProps({
// The trigger sits on a feed card that opens the player on click, so the toggle must not
// reach it.
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
})}
ref={triggerRef}
onClick={toggleOpen}
title={t("playlists.addToPlaylist")}
aria-label={t("playlists.addToPlaylist")}
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
@@ -98,69 +126,59 @@ export default function AddToPlaylist({
<ListPlus className="w-4 h-4" />
</button>
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2 shadow-2xl">
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
{open &&
createPortal(
<div
ref={fade.ref}
style={fade.style}
aria-busy={busy}
className="max-h-56 overflow-y-auto no-scrollbar"
ref={panelRef}
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
className="z-overlay glass-menu rounded-xl p-2 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)}
{lists.map((pl) => (
<PopoverItem
key={pl.id}
// A video can sit in several playlists at once — checkboxes, not one choice.
role="menuitemcheckbox"
checked={pl.has_video}
onSelect={() => toggle(pl)}
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
// <body> mid-request and permanently kill roving focus (useListNavigation listens on
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
label={playlistName(pl, t)}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-none transition hover:bg-card/60 data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]"
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
<div
ref={fade.ref}
style={fade.style}
className="max-h-56 overflow-y-auto no-scrollbar"
>
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)}
{lists.map((pl) => (
<button
key={pl.id}
onClick={() => toggle(pl)}
disabled={busy}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg hover:bg-card/60 transition disabled:opacity-50"
>
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
)}
</PopoverItem>
))}
</div>
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss; every other key is text editing here and
// must not reach the surrounding menu's list navigation.
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
void createAndAdd();
}
}}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</div>
</div>
</PopoverSurface>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
)}
</button>
))}
</div>
<form
onSubmit={createAndAdd}
className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5"
>
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</form>
</div>,
document.body,
)}
</>
);
}
-442
View File
@@ -1,442 +0,0 @@
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
type ButtonHTMLAttributes,
type CSSProperties,
type ReactNode,
} from "react";
import {
useFloating,
autoUpdate,
offset as offsetMiddleware,
flip,
shift,
size,
hide,
useClick,
useHover,
useFocus,
useDismiss,
useRole,
useListNavigation,
useTypeahead,
useInteractions,
useFloatingNodeId,
useListItem,
safePolygon,
FloatingNode,
FloatingList,
FloatingPortal,
FloatingFocusManager,
type Placement,
type UseRoleProps,
} from "@floating-ui/react";
import { useLayer } from "../lib/layerStack";
import { useFullscreenElement } from "../lib/fullscreen";
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
// visibility) that had each re-derived the same maths. Floating UI's middleware does all four:
// - flip() → measured two-way flip (top/bottom) when a side runs out of room
// - shift() → slides back into view horizontally (the old MEASURED-width clamp)
// - hide() → sets referenceHidden when the trigger is scrolled out of its clipping scroller, so we
// hide-and-follow instead of the old "close on leave scroller" (auto-reappears on scroll back)
// - autoUpdate → follows the trigger on scroll/resize (the old rAF loop), coalesced by the library
// Positioning is portalled through FloatingPortal (escapes ancestor stacking contexts — same job the
// house <Overlay> did for TagsCell). Every popover is a node in the app's <FloatingTree> so Escape /
// outside-press dispatch is topmost-only across nested layers (S3 folds the Modal + players in too).
//
// S2 (this layer) adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's
// textbook menu) but via Floating UI's idioms rather than a second hand-rolled roving engine:
// - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only
// - useTypeahead → type-to-focus a matching item by its label
// - FloatingFocusManager → focus moves into the surface on open and RETURNS to the trigger on close
// (a body-portaled popover sits at the end of the document; without this, Tab can't reach it and
// Escape strands focus on <body>). Non-modal so the rest of the page stays interactive.
// Call sites render their options as <PopoverItem> — it self-registers (useListItem) and carries the
// right role (menuitem / menuitemradio+aria-checked / option+aria-selected). A menu that also holds a
// free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and
// typeahead act on the text, not the list — see ChannelTags.
// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
type PopoverMode = "menu" | "listbox" | "hover" | "dialog";
export interface AnchoredPopoverOptions {
open: boolean;
onOpenChange: (open: boolean, event?: Event) => void;
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards). */
mode?: PopoverMode;
placement?: Placement;
/** Gap between trigger and panel, px. Default 4. */
offset?: number;
/** Hover mode only: open/close intent delays, ms. */
hoverDelay?: number | { open?: number; close?: number };
/** ARIA role for the panel; defaults to "tooltip" in hover mode, else "menu". */
role?: UseRoleProps["role"];
/** Cap the panel height to the available space (adds the `size` middleware). Off by default so a
* caller's own max-height class wins; turn on for long lists that should shrink to fit. */
fitHeight?: boolean;
/** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that
* contain a free-text input, where character keys belong to the text field. */
typeahead?: boolean;
/** This menu hosts a free-text input (an inline "create" field). Turns off BOTH typeahead AND
* focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals
* focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */
hostsInput?: boolean;
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
selectedIndex?: number | null;
}
// Internal: call sites use the inferred hook return, they don't name the type.
type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
const mode = opts.mode ?? "menu";
const isHover = mode === "hover";
// Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds
// arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves.
const isNav = mode === "menu" || mode === "listbox";
// null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
const nodeId = useFloatingNodeId();
// Item registry for list navigation + typeahead, populated by <PopoverItem> via useListItem.
const elementsRef = useRef<Array<HTMLElement | null>>([]);
const labelsRef = useRef<Array<string | null>>([]);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({
nodeId,
open: opts.open,
onOpenChange: opts.onOpenChange,
placement: opts.placement ?? "bottom-start",
whileElementsMounted: autoUpdate,
middleware: [
offsetMiddleware(opts.offset ?? 4),
flip({ padding: 8 }),
shift({ padding: 8 }),
...(opts.fitHeight
? [
size({
padding: 8,
apply({ availableHeight, elements }) {
elements.floating.style.maxHeight = `${availableHeight}px`;
},
}),
]
: []),
hide({ padding: 8 }),
],
});
// A stale active item would highlight (and steal the next open's initial focus) the second time a
// menu opens; clear it whenever the surface closes.
useEffect(() => {
if (!opts.open) setActiveIndex(null);
}, [opts.open]);
const click = useClick(context, { enabled: !isHover });
const hover = useHover(context, {
enabled: isHover,
move: false,
delay: opts.hoverDelay ?? { open: 120, close: 140 },
handleClose: safePolygon(),
});
const focus = useFocus(context, { enabled: isHover });
// Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it
// is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape
// off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
// stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
// press stays with floating-ui (tree-aware).
const dismiss = useDismiss(context, { escapeKey: false });
const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
const listNav = useListNavigation(context, {
enabled: isNav,
listRef: elementsRef,
activeIndex,
onNavigate: setActiveIndex,
selectedIndex: opts.selectedIndex ?? null,
loop: true,
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
// which strands focus on the menu container — arrows then can't enter the roving list at all).
focusItemOnOpen: true,
// A menu that hosts a free-text input must NOT pull focus onto a list item when the pointer merely
// passes over it (the library default) — that blurs the input mid-typing and mis-routes the next
// Enter to the hovered item. Turned off together with typeahead for such menus.
focusItemOnHover: !opts.hostsInput,
});
const typeahead = useTypeahead(context, {
enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
listRef: labelsRef,
activeIndex,
onMatch: setActiveIndex,
});
const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]);
// `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is
// then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back.
const referenceHidden = middlewareData.hide?.referenceHidden ?? false;
// Register the open popover as a dismissable layer so Escape is topmost-only and a player/modal
// underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take
// Escape before the surface beneath it — and, when it is a player's own overlay card, keep the
// player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat
// an Escape the user aimed at whatever is actually on screen.
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false));
return {
nodeId,
mode,
isNav,
open: opts.open,
selectedIndex: opts.selectedIndex ?? null,
refs,
floatingStyles,
context,
isPositioned,
referenceHidden,
elementsRef,
labelsRef,
activeIndex,
...interactions,
};
}
// Carries the active index + item-props getter down to <PopoverItem>. FloatingList (in PopoverSurface)
// separately owns the elementsRef/labelsRef registration; this context is the active-state channel.
interface PopoverListContextValue {
activeIndex: number | null;
getItemProps: AnchoredPopover["getItemProps"];
}
const PopoverListContext = createContext<PopoverListContextValue | null>(null);
type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option";
/** One keyboard-navigable option inside a menu/listbox `PopoverSurface`. Self-registers for roving
* focus + typeahead and applies the correct ARIA. Use `menuitemradio`+`checked` when the items are
* one exclusive choice, `menuitemcheckbox`+`checked` when several can be on at once,
* `option`+`selected` for a listbox choice, plain `menuitem` for an action.
*
* The active (roving) item carries `data-active` — style the highlight off THAT, not `:focus-visible`:
* a click-opened menu focuses the first item programmatically, which does NOT reliably trip the
* focus-visible heuristic, so a `:focus-visible` highlight can stay invisible until the first arrow
* key. The recommended recipe is an accent tint — and note `bg-accent/NN` does NOT work here (the
* theme's `--accent` is a hex var, and Tailwind v3 can't inject alpha into it), so use a color-mix
* arbitrary value like the app's own glass idiom:
* `data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg`. */
export function PopoverItem({
children,
onSelect,
role = "menuitem",
checked,
selected,
disabled,
className,
/** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */
label,
...rest
}: {
children: ReactNode;
onSelect?: () => void;
role?: ItemRole;
checked?: boolean;
selected?: boolean;
disabled?: boolean;
className?: string;
label?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
const ctx = useContext(PopoverListContext);
const textLabel = label ?? (typeof children === "string" ? children : undefined);
const { ref, index } = useListItem({ label: textLabel });
const active = ctx?.activeIndex === index;
const ariaProps =
role === "menuitemradio" || role === "menuitemcheckbox"
? { role, "aria-checked": !!checked }
: role === "option"
? { role, "aria-selected": !!selected }
: { role };
// Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which
// composes handlers rather than dropping them — spreading `{...rest}` separately would let the
// getter's own onClick/onFocus/onMouseMove silently clobber the caller's. `onClick` here runs the
// caller's first (e.g. a stopPropagation), then the select.
const mergedClick = (e: React.MouseEvent<HTMLButtonElement>) => {
rest.onClick?.(e);
if (!disabled) onSelect?.();
};
const handlers = ctx
? ctx.getItemProps({ ...rest, onClick: mergedClick })
: { ...rest, onClick: mergedClick };
return (
<button
ref={ref}
type="button"
{...ariaProps}
{...handlers}
// These win over anything in `handlers` — the roving tabindex must not be a caller override,
// and disabled/className/data-active are the platform's to set.
tabIndex={active ? 0 : -1}
data-active={active ? "true" : undefined}
disabled={disabled}
className={className}
>
{children}
</button>
);
}
interface PopoverSurfaceProps {
popover: AnchoredPopover;
className?: string;
style?: CSSProperties;
children: ReactNode;
/** Accessible name for the panel itself — a menu whose trigger is icon-only has nothing else to
* take its name from. */
ariaLabel?: string;
focusManaged?: boolean;
initialFocus?: number | React.MutableRefObject<HTMLElement | null>;
/** The surface never receives pointer events — for a passive caption that must not swallow a click
* aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable
* content, links) must NOT set this. */
passthrough?: boolean;
}
/** Renders the popover panel: the FloatingTree node + portal + the positioned surface. Merges the
* library's floating ref/props with the caller's className/style. Menu/listbox surfaces trap+return
* focus and host the list-navigation registry by default; pass `focusManaged={false}` to opt a
* surface out (e.g. a hover card).
*
* A thin wrapper so the closed case runs NO hooks: a mounted-but-closed popover (there can be ~10
* per table row) must not subscribe fullscreen listeners or build a portal container. */
export function PopoverSurface(props: PopoverSurfaceProps) {
if (!props.popover.open) return null;
return <PopoverSurfaceInner {...props} />;
}
function PopoverSurfaceInner({
popover,
className,
style,
children,
focusManaged,
initialFocus,
passthrough,
ariaLabel,
}: PopoverSurfaceProps) {
const {
context,
refs,
floatingStyles,
getFloatingProps,
getItemProps,
nodeId,
referenceHidden,
isPositioned,
mode,
isNav,
selectedIndex,
activeIndex,
elementsRef,
labelsRef,
} = popover;
// A node portaled to <body> is painted UNDERNEATH whatever holds the fullscreen lock, so a popover
// opened from a player's control bar in fullscreen would be invisible; it must portal INTO the
// fullscreen element. We own one persistent container div and re-parent it (body ↔ fullscreen
// element) with appendChild, which MOVES the node atomically — no detach, so the open panel's
// focus, caret, scroll offset and animation state survive a fullscreen transition, and an element
// SWAP (A→B without exiting) is handled by the element-identity dependency. (Keying the portal to
// remount instead would rebuild the subtree and lose all of that.)
const fsRoot = useFullscreenElement();
const [container] = useState<HTMLDivElement | null>(() =>
typeof document === "undefined" ? null : document.createElement("div"),
);
useLayoutEffect(() => {
if (container) (fsRoot ?? document.body).appendChild(container);
}, [container, fsRoot]);
useEffect(() => () => container?.remove(), [container]);
const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
style?: CSSProperties;
} & Record<string, unknown>;
// useRole stamps `aria-labelledby` (the trigger) on a menu-role panel, and it WINS over aria-label
// in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
if (ariaLabel) delete floatingProps["aria-labelledby"];
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
// Everything but a passive hover card manages focus by default (trap into the surface, return to
// the trigger on close).
const managed = focusManaged ?? mode !== "hover";
const surface = (
<div
ref={refs.setFloating}
{...floatingProps}
aria-label={ariaLabel}
className={zClass}
style={{
...floatingPropStyle,
...floatingStyles,
// Hide until the first position is measured (else it flashes at 0,0 before the transform
// lands) and while the trigger is scrolled out of its clipping scroller (hide-and-follow).
...(!isPositioned || referenceHidden
? { visibility: "hidden" as const, pointerEvents: "none" as const }
: null),
...(passthrough ? { pointerEvents: "none" as const } : null),
}}
>
{/* Inner wrapper carries the visual styling + pop-in animation. Keeping the ANIMATION off the
positioned element is essential: the popIn keyframe animates `transform`, which would
otherwise override Floating UI's `transform: translate(x,y)` and park the popover at (0,0)
for the animation's duration — the "flash in the top-left corner" bug. */}
<div className={className} style={style}>
{children}
</div>
</div>
);
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
// <PopoverItem>s can self-register and rove.
const positioned = isNav ? (
<PopoverListContext.Provider value={{ activeIndex, getItemProps }}>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{surface}
</FloatingList>
</PopoverListContext.Provider>
) : (
surface
);
return (
<FloatingNode id={nodeId}>
<FloatingPortal root={container ?? undefined}>
{managed ? (
<FloatingFocusManager
context={context}
modal={false}
// Only when a `selectedIndex` is set does useListNavigation focus its item SYNCHRONOUSLY
// (in a layout effect) and race the focus manager's own initial focus — so ONLY then do we
// suppress the manager's initial focus with -1. For a plain nav menu we let the manager
// focus normally: it lands on the first tabbable child, which is the first roving item, or
// — in an item-less menu (e.g. a first-run tag editor with only a create input) — the
// input, so such a menu still has a keyboard entry point instead of stranding focus.
initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)}
returnFocus
>
{positioned}
</FloatingFocusManager>
) : (
positioned
)}
</FloatingPortal>
</FloatingNode>
);
}
+1 -41
View File
@@ -22,11 +22,9 @@ import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import { api, type FeedFilters, type Me } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -168,31 +166,6 @@ export default function ChannelPage({
onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")),
});
// Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic
// on this channel's detail cache; a failed attach/detach reverts by refetching, and a success
// invalidates the manager list so a tag added here shows there too.
// Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an
// unsubscribed/demo channel page never shows the control, so the request would be thrown away.
const tagsQuery = useQuery({
queryKey: qk.tags(),
queryFn: api.tags,
enabled: !!ch?.subscribed && !me.is_demo,
});
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) =>
qc.setQueryData<ChannelDetail>(qk.channel(id), (old) =>
old
? {
...old,
tag_ids: attach ? [...old.tag_ids, tagId] : old.tag_ids.filter((x) => x !== tagId),
}
: old,
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.channels() }),
onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
});
const onUnsubscribe = async () => {
const ok = await confirm({
title: t("channel.unsubTitle"),
@@ -438,19 +411,6 @@ export default function ChannelPage({
{t("channel.subscribe")}
</button>
))}
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
otherwise), and personal tags are for your subscriptions — so only offer it on a
channel you actually follow. */}
{ch?.subscribed && !me.is_demo && (
<ChannelTagsControl
userTags={userTags}
attachedIds={ch.tag_ids}
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(channelId, name)}
// The header row has free width — show tags inline instead of collapsing to "+N".
bounded={false}
/>
)}
</div>
</div>
</div>
-325
View File
@@ -1,325 +0,0 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Check, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useScrollFade } from "../lib/useScrollFade";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
// 1. Stable edit anchor — the "+" edit trigger sits in a FIXED slot at the end, so toggling tags
// (which grows the chip row) never moves it, and the editor popover doesn't drift.
// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
// collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never
// wraps to a 2nd line, so it can't widen its table column and reflow every other row.
// The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline — tagging is
// no longer gated on the top-of-page "Manage tags" dialog.
interface TagChipProps {
tag: Tag;
onClick?: () => void;
title?: string;
}
function TagChip({ tag, onClick, title }: TagChipProps) {
const cls =
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-[9rem] truncate";
return onClick ? (
<button
type="button"
onClick={onClick}
title={title}
className={`${cls} hover:opacity-80 transition`}
>
{tag.name}
</button>
) : (
<span className={cls}>{tag.name}</span>
);
}
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
* detail page in Part 2; internal for now.) */
function TagEditorMenu({
userTags,
attachedIds,
onToggle,
onCreate,
}: {
userTags: Tag[];
attachedIds: number[];
onToggle: (tagId: number) => void;
onCreate: (name: string) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState("");
const fade = useScrollFade();
const attached = new Set(attachedIds);
const submit = () => {
const name = draft.trim();
if (!name) return;
onCreate(name);
setDraft("");
};
return (
<div className="flex flex-col">
<div ref={fade.ref} style={fade.style} className="max-h-[240px] overflow-y-auto no-scrollbar">
{userTags.map((tg) => {
const on = attached.has(tg.id);
return (
<PopoverItem
key={tg.id}
// Several tags can be on at once — a checkbox set, not one exclusive choice.
role="menuitemcheckbox"
checked={on}
onSelect={() => onToggle(tg.id)}
label={tg.name}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg ${
on ? "text-fg" : "text-muted"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
<span className="truncate">{tg.name}</span>
</PopoverItem>
);
})}
{userTags.length === 0 && (
<p className="text-[11px] text-muted px-2 py-1.5">{t("channels.tags.noneYet")}</p>
)}
</div>
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss (close + refocus the trigger); every other key —
// arrows, Home/End, typed characters — is text editing here, so keep it out of the menu's
// list-navigation/typeahead handlers on the surrounding surface.
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
maxLength={64}
placeholder={t("channels.tags.createPlaceholder")}
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:border-accent outline-none"
/>
<button
type="button"
onClick={submit}
disabled={!draft.trim()}
title={t("channels.tags.create")}
aria-label={t("channels.tags.create")}
className="shrink-0 w-6 h-6 inline-flex items-center justify-center rounded-md border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-40 transition"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
/** The full tag control for a channel: a width-bounded chip row with "+N" overflow (hover reveals
* all), followed by a stable "+" trigger that opens the shared editor. Fills its container width,
* so a table cell bounds it via the column width and a card bounds it via the card. */
export function ChannelTagsControl({
userTags,
attachedIds,
onToggle,
onCreate,
onChipClick,
bounded = true,
}: {
userTags: Tag[];
attachedIds: number[];
onToggle: (tagId: number) => void;
onCreate: (name: string) => void;
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
onChipClick?: (tagId: number, name: string) => void;
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
* table (the manager). Set false where there is free width (the channel detail header), so tags
* fit inline instead of needlessly collapsing into "+N". */
bounded?: boolean;
}) {
const { t } = useTranslation();
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
// natural widths; we read each chip's right edge and compute EXACTLY how many fit, reserving the
// pill's width whenever any overflow. One pass (no iterative re-render), so the "+N" count is always
// right as tags are added/removed. Re-measures on width/scale change via ResizeObserver — off real
// rem boxes, never a px guess.
const rowRef = useRef<HTMLDivElement | null>(null);
const measureRef = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(attached.length);
// Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove.
const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|");
useLayoutEffect(() => {
const row = rowRef.current;
const measure = measureRef.current;
if (!row || !measure) return;
const compute = () => {
const nodes = Array.from(measure.children) as HTMLElement[];
const pill = nodes[nodes.length - 1];
if (!pill) return;
const pillW = pill.offsetWidth;
const chips = nodes.slice(0, -1);
// Measure the real rendered gap (gap-1 is rem — it scales with the font slider) off the box
// between the last chip and the sample pill, rather than assuming a px constant.
const lastChip = chips[chips.length - 1];
const gap = lastChip
? Math.max(0, pill.offsetLeft - (lastChip.offsetLeft + lastChip.offsetWidth))
: 0;
const avail = row.clientWidth;
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and no "+N"
// pill is needed. The reservation loop below reserves the pill's width for every chip except
// the last and breaks at the first overflow, so on its own it never reaches the only "does the
// whole set fit?" test — it would collapse chips that would have fitted (e.g. a short trailing
// chip narrower than the pill), leaving "+N" beside empty space.
const lastRight = lastChip ? lastChip.offsetLeft + lastChip.offsetWidth : 0;
if (lastRight <= avail) {
setVisible(chips.length);
return;
}
let count = chips.length;
for (let i = 0; i < chips.length; i++) {
const chip = chips[i];
if (!chip) break;
const right = chip.offsetLeft + chip.offsetWidth; // right edge within the measurer
const moreAfter = i < chips.length - 1;
if (right > avail - (moreAfter ? pillW + gap : 0)) {
count = i;
break;
}
}
setVisible(count);
};
compute();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(compute);
ro.observe(row);
return () => ro.disconnect();
}, [tagsKey]);
const hidden = attached.length - visible;
const [editorOpen, setEditorOpen] = useState(false);
const editPop = useAnchoredPopover({
open: editorOpen,
onOpenChange: setEditorOpen,
mode: "menu",
placement: "bottom-start",
// The menu contains a free-text "create tag" input — keep both typed chars and pointer-hover out
// of the roving list so neither steals the field's caret.
hostsInput: true,
});
const [overflowOpen, setOverflowOpen] = useState(false);
const overflowPop = useAnchoredPopover({
open: overflowOpen,
onOpenChange: setOverflowOpen,
mode: "hover",
placement: "bottom",
});
// If tags change so nothing is collapsed anymore, the "+N" trigger unmounts — force the reveal-all
// popover's state closed so it can't linger open against a gone reference.
useEffect(() => {
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
}, [hidden, overflowOpen]);
const pillClass =
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
return (
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
// content-driven cell re-solves EVERY column's width as chips are added, visibly shifting the whole
// table. The fixed width keeps the table stable and centered; the chip row fits what it can and
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
<div
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-[12rem] max-w-full" : "w-full"}`}
>
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
<div
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{attached.map((tg) => (
<TagChip key={tg.id} tag={tg} />
))}
<span className={pillClass}>+{attached.length}</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{attached.slice(0, visible).map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
/>
))}
{hidden > 0 && (
<button
ref={overflowPop.refs.setReference}
type="button"
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
<button
ref={editPop.refs.setReference}
type="button"
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
className="shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
{...editPop.getReferenceProps()}
>
<Plus className="w-3 h-3" />
</button>
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
<PopoverSurface
popover={overflowPop}
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
>
{attached.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
/>
))}
</PopoverSurface>
{/* Click: the shared editor (toggle existing + create inline). */}
<PopoverSurface
popover={editPop}
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<TagEditorMenu
userTags={userTags}
attachedIds={attachedIds}
onToggle={onToggle}
onCreate={onCreate}
/>
</PopoverSurface>
</div>
);
}
+206 -31
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -12,14 +12,17 @@ import {
History,
LayoutGrid,
Pencil,
Plus,
RefreshCw,
Table,
UserMinus,
X,
} from "lucide-react";
import { api, type ManagedChannel } from "../lib/api";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
@@ -27,8 +30,7 @@ import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import Overlay from "./Overlay";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl";
import Pager from "./Pager";
@@ -196,25 +198,21 @@ export default function Channels() {
}, []);
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
// attach/detach + create-and-attach flow is shared with the channel detail page.
const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) =>
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) =>
ch.id === id
? {
...ch,
tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
}
: ch,
),
// made the toggle feel ~2s slow); only revert by refetching if the server call fails.
const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) =>
ch.id === id
? {
...ch,
tag_ids: hasTag ? ch.tag_ids.filter((x) => x !== tagId) : [...ch.tag_ids, tagId],
}
: ch,
),
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side.
onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
});
);
const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId);
call.catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
};
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
@@ -455,21 +453,17 @@ export default function Channels() {
key: "tags",
header: t("channels.cols.tags"),
cardLabel: false,
// Bounded width so the cell can't widen the column and reflow the whole table; the control
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
width: "12rem",
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
},
render: (c) => (
<ChannelTagsControl
<TagsCell
c={c}
userTags={userTags}
attachedIds={c.tag_ids}
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(c.id, name)}
onChipClick={onFilterByTag}
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onTagClick={onFilterByTag}
/>
),
},
@@ -995,7 +989,7 @@ function SyncCell({ c }: { c: ManagedChannel }) {
);
}
return (
<div className="flex flex-nowrap items-center gap-1 whitespace-nowrap">
<div className="flex flex-wrap items-center gap-1">
<SyncBadge
ok={c.recent_synced}
label={t("channels.row.recent")}
@@ -1030,6 +1024,187 @@ function SyncCell({ c }: { c: ManagedChannel }) {
);
}
function TagsCell({
c,
userTags,
onToggleTag,
onTagClick,
}: {
c: ManagedChannel;
userTags: Tag[];
onToggleTag: (tagId: number) => void;
onTagClick: (tagId: number, name: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// Anchor for the portalled menu. It renders through <Overlay> (i.e. at <body>) rather than
// `absolute` inside the cell, because in the CARD layout every VirtualGrid row carries a
// `transform` — which makes it a stacking context and traps a z-indexed child, so the menu drew
// UNDER the next card. Same hazard, same blessed fix as the players in E3.
const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>(
null,
);
const fade = useScrollFade();
const ref = useRef<HTMLDivElement | null>(null);
const popRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
// The menu is no longer a DOM descendant of `ref`, so dismiss has to watch both.
useDismiss(open, () => setOpen(false), [ref, popRef]);
// Keep the scroll-fade hook's callback ref working alongside our own.
const setPopRef = useCallback(
(node: HTMLDivElement | null) => {
popRef.current = node;
fade.ref(node);
},
[fade.ref],
);
// ONE placement rule, so the position the menu opens at and the one it keeps while scrolling can't
// drift apart. `flip` keeps it anchored above the trigger once it has been flipped; the horizontal
// clamp uses the MEASURED width (`w-44` is rem — it grows with the text-size slider, so a px guess
// reads short at 1.3). Before first paint the menu isn't measurable yet; the layout effect below
// re-runs this once it is.
const coordsFrom = useCallback((r: DOMRect, flip: boolean) => {
const w = popRef.current?.offsetWidth ?? 0;
const left =
w && r.left + w > window.innerWidth - 8 ? Math.max(8, window.innerWidth - 8 - w) : r.left;
return flip ? { left, bottom: window.innerHeight - r.top + 4 } : { left, top: r.bottom + 4 };
}, []);
const place = useCallback(() => {
const r = btnRef.current?.getBoundingClientRect();
if (!r) return false;
setCoords(coordsFrom(r, false));
return true;
}, [coordsFrom]);
const toggle = () => {
if (open) setOpen(false);
else if (place()) setOpen(true);
};
// Correct the placement against the MEASURED box: flip above when it would run off the bottom (and
// there is room above), and apply the width clamp now that the menu can actually be measured.
useLayoutEffect(() => {
const pop = popRef.current;
const r = btnRef.current?.getBoundingClientRect();
if (!open || !pop || !coords || !r) return;
const h = pop.offsetHeight; // the width is read inside coordsFrom, off the same node
const fitsBelow = r.bottom + 4 + h <= window.innerHeight - 8;
const fitsAbove = r.top - 4 - h >= 8;
// Decide BOTH ways, every time. Prefer the side it is already on (no flapping while scrolling),
// but leave it as soon as that side stops fitting and the other one does — a one-way flip would
// keep an above-anchored menu above a row scrolling toward the top and push it off the screen.
const above = coords.bottom !== undefined;
const flip = above ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove;
const next = coordsFrom(r, flip);
if (next.left !== coords.left || next.top !== coords.top || next.bottom !== coords.bottom) {
setCoords(next);
}
}, [open, coords, coordsFrom]);
// A fixed menu doesn't travel with its row, so FOLLOW the trigger on scroll rather than closing:
// in the table the menu used to scroll with the row, and closing on every wheel tick would make
// attaching several tags a reopen-per-tag chore. Closes only once the trigger leaves the SCROLLER
// (not the window — the page scroller starts below ~230px of fixed chrome, so a row hidden behind
// the toolbar is still "on screen" by window coords and the menu would float over the chrome).
// Scrolling INSIDE the menu must not move it.
useEffect(() => {
if (!open) return;
let raf = 0;
const reposition = () => {
raf = 0;
const r = btnRef.current?.getBoundingClientRect();
const scroller = document.querySelector("main")?.getBoundingClientRect();
const topEdge = scroller ? scroller.top : 0;
const bottomEdge = scroller ? scroller.bottom : window.innerHeight;
if (!r || r.bottom < topEdge || r.top > bottomEdge) {
setOpen(false);
return;
}
// Keep whichever side it is anchored to, so the layout effect has nothing left to correct —
// resetting to "below" every frame would make it flip back and forth, two renders per frame.
setCoords((cur) => (cur ? coordsFrom(r, cur.bottom !== undefined) : cur));
};
const onScroll = (e: Event) => {
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
if (!raf) raf = requestAnimationFrame(reposition); // coalesce a scroll burst into one move
};
const onResize = () => setOpen(false);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
if (raf) cancelAnimationFrame(raf);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open, coordsFrom]);
if (userTags.length === 0) return null;
// Show only the tags actually attached to this channel; the “+” opens a per-channel
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
// on every row). Tag create/delete still lives in the top "YOUR TAGS" row.
const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id));
return (
<div ref={ref} className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
{attached.map((tg) => (
<button
key={tg.id}
onClick={() => onTagClick(tg.id, tg.name)}
title={t("channels.row.filterFeedByTag", { name: tg.name })}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent hover:opacity-80 transition"
>
{tg.name}
</button>
))}
<button
ref={btnRef}
onClick={toggle}
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<Plus className="w-3 h-3" />
</button>
{open && coords && (
<Overlay>
<div
ref={setPopRef}
style={{
...fade.style,
position: "fixed",
left: coords.left,
top: coords.top,
bottom: coords.bottom,
}}
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (
<button
key={tg.id}
onClick={() => onToggleTag(tg.id)}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
{tg.name}
</button>
);
})}
</div>
</Overlay>
)}
</div>
);
}
function ActionsCell({
c,
isAdmin,
+121 -62
View File
@@ -1,16 +1,15 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
//
// Two variants, now one implementation on the R8 popover platform: "header" opens below the pill
// (used on the landing page and the setup wizard) and "rail" is an icon-only button in the left
// nav's bottom cluster whose menu opens to the RIGHT, bottom-aligned. Both used to hand-roll their
// own anchoring, and the header one also opened on hover — dropped on purpose: the platform focuses
// the first item when a menu opens, so a hover-open would yank focus off whatever the user was doing.
// Two variants: "header" (legacy inline dropdown, opens below) and "rail" (used in the left
// nav's bottom icon cluster — an icon-only button whose menu is portaled to <body> and
// anchored to the right + above the button, escaping the nav's backdrop-filter which would
// otherwise trap an absolutely-positioned popover).
export default function LanguageSwitcher({
value,
onChange,
@@ -23,68 +22,128 @@ export default function LanguageSwitcher({
variant?: "header" | "rail";
}) {
const [open, setOpen] = useState(false);
const currentIdx = Math.max(
0,
LANGUAGES.findIndex((l) => l.code === value),
);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
const rail = variant === "rail";
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
placement: rail ? "right-end" : align === "right" ? "bottom-end" : "bottom-start",
offset: rail ? 8 : 4,
// Single choice: opening lands on the language already in use.
selectedIndex: currentIdx,
});
function openNow() {
if (closeTimer.current) clearTimeout(closeTimer.current);
setOpen(true);
}
function closeSoon() {
closeTimer.current = setTimeout(() => setOpen(false), 200);
}
function toggleRail() {
if (!open) {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
}
setOpen((o) => !o);
}
return (
<>
<button
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
title={current.label}
aria-label={current.label}
className={
rail
? "relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
: "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
}
>
<Globe className={rail ? "w-5 h-5" : "w-4 h-4"} />
{rail ? (
/* Current-language badge (HU/EN) so the active language reads at a glance without
opening the menu. The ring matches the page background to detach it from the icon. */
// Rail popover: dismiss on outside click / Escape (it's portaled, so a contains() check
// spans both the button and the floating panel).
useEffect(() => {
if (variant !== "rail" || !open) return;
function onDoc(e: MouseEvent) {
const target = e.target as Node;
if (panelRef.current?.contains(target) || btnRef.current?.contains(target)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [variant, open]);
const menu = (
<div ref={panelRef} className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]">
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => {
onChange(l.code);
setOpen(false);
}}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</button>
))}
</div>
);
if (variant === "rail") {
return (
<>
<button
ref={btnRef}
onClick={toggleRail}
title={current.label}
aria-label={current.label}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Globe className="w-5 h-5" />
{/* Current-language badge (HU/EN) so the active language reads at a glance without
opening the menu. The ring matches the page background to detach it from the icon. */}
<span className="absolute -bottom-1 -right-1 text-[8px] font-bold leading-none px-1 py-[1px] rounded bg-accent text-accent-fg ring-2 ring-bg">
{current.code.toUpperCase()}
</span>
) : (
<span className="text-xs font-semibold uppercase">{current.code}</span>
)}
</button>
{open &&
createPortal(
<div
className="z-rail"
style={{ position: "fixed", left: pos.left, bottom: pos.bottom }}
>
{menu}
</div>,
document.body,
)}
</>
);
}
return (
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
<button
onClick={() => setOpen((o) => !o)}
title={current.label}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
>
<Globe className="w-4 h-4" />
<span className="text-xs font-semibold uppercase">{current.code}</span>
</button>
<PopoverSurface
popover={pop}
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{LANGUAGES.map((l) => (
<PopoverItem
key={l.code}
role="menuitemradio"
checked={l.code === value}
onSelect={() => {
onChange(l.code);
setOpen(false);
}}
label={l.label}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-none transition hover:text-fg hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</PopoverItem>
))}
</PopoverSurface>
</>
{open && (
<div
className={`glass-menu absolute ${
align === "right" ? "right-0" : "left-0"
} mt-1 w-40 rounded-xl p-1.5 z-rail animate-[popIn_0.16s_ease]`}
>
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => {
onChange(l.code);
setOpen(false);
}}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</button>
))}
</div>
)}
</div>
);
}
+18 -13
View File
@@ -3,15 +3,20 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { useBackToClose } from "../lib/history";
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
import { useFullscreenElement } from "../lib/fullscreen";
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
// dismisses just the error and returns to the editor underneath, and a player below defers to it
// (R8 S3). Portals into `document.fullscreenElement ?? document.body`: a node under <body> is painted
// beneath whatever holds the fullscreen lock, so a Modal opened from a player in true fullscreen (the
// download dialog) would otherwise open invisibly.
// Stack of open modals so ESC only closes the topmost one — e.g. an error dialog over the
// tag editor: pressing ESC dismisses just the error and returns to the editor underneath.
let modalStack: number[] = [];
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.
export default function Modal({
title,
onClose,
@@ -26,7 +31,6 @@ export default function Modal({
const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose);
const fsRoot = useFullscreenElement();
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
@@ -37,9 +41,10 @@ export default function Modal({
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const id = pushLayer();
const id = nextModalId++;
modalStack.push(id);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isTopmost(id)) {
if (e.key === "Escape" && modalStack[modalStack.length - 1] === id) {
e.stopPropagation();
onCloseRef.current();
}
@@ -49,7 +54,7 @@ export default function Modal({
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
removeLayer(id);
modalStack = modalStack.filter((x) => x !== id);
document.body.style.overflow = prev;
};
}, []);
@@ -83,6 +88,6 @@ export default function Modal({
<div className="px-5 pb-5">{children}</div>
</div>
</div>,
fsRoot ?? document.body,
document.body,
);
}
+109 -84
View File
@@ -1,5 +1,6 @@
import { useState, useSyncExternalStore } from "react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
@@ -24,7 +25,6 @@ import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
@@ -57,20 +57,41 @@ export default function NavSidebar({
const expanded = pinned || peek.active;
const slim = !expanded; // visual: icon-only rail
const [acctOpen, setAcctOpen] = useState(false);
// The account panel holds mixed content with its own tab order (profile, account switches, two
// actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
// backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through
// to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its
// hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now
// comes from the platform instead of a pair of document listeners.
const acctPop = useAnchoredPopover({
open: acctOpen,
onOpenChange: setAcctOpen,
mode: "dialog",
role: "dialog",
placement: "right-end",
offset: 8,
});
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
const acctPanelRef = useRef<HTMLDivElement | null>(null);
// Popover position (fixed, viewport coords). It's portaled to <body> so it escapes the
// nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let
// clicks fall through to the controls behind it.
const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
function toggleAccount() {
if (!acctOpen) {
const r = acctBtnRef.current?.getBoundingClientRect();
if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
}
setAcctOpen((o) => !o);
}
// Dismiss on outside click / Escape via document listeners — NOT a full-screen backdrop
// div, which (sitting between the popover and the page) would break the popover's
// backdrop-filter and make it look solid.
useEffect(() => {
if (!acctOpen) return;
function onDoc(e: MouseEvent) {
const t = e.target as Node;
if (acctPanelRef.current?.contains(t) || acctBtnRef.current?.contains(t)) return;
setAcctOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setAcctOpen(false);
}
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [acctOpen]);
async function logout() {
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then
@@ -317,10 +338,10 @@ export default function NavSidebar({
{!slim && <span className="truncate">{t("header.account.settings")}</span>}
</button>
<div>
<div className="relative">
<button
ref={acctPop.refs.setReference}
{...acctPop.getReferenceProps()}
ref={acctBtnRef}
onClick={toggleAccount}
title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
>
@@ -338,75 +359,79 @@ export default function NavSidebar({
)}
</button>
<PopoverSurface
popover={acctPop}
ariaLabel={name}
className="glass w-60 rounded-xl p-3 animate-[popIn_0.16s_ease]"
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-10 h-10 rounded-full text-sm shrink-0"
/>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
</div>
</div>
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
{t("header.account.admin")}
</div>
)}
{otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
{acctOpen &&
createPortal(
<div
ref={acctPanelRef}
style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }}
className="glass w-60 rounded-xl p-3 z-overlay animate-[popIn_0.16s_ease]"
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-10 h-10 rounded-full text-sm shrink-0"
/>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
</div>
</div>
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
{t("header.account.admin")}
</div>
)}
{otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
<button
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
>
<AvatarImg
src={a.avatar_url}
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-[10px] shrink-0"
/>
<span className="min-w-0 text-left">
<span className="block text-[13px] text-fg truncate">
{a.display_name ?? a.email.split("@")[0]}
</span>
<span className="block text-[11px] text-muted truncate">{a.email}</span>
</span>
</button>
))}
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
onClick={() => {
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
// and pins that. Other tabs keep their own pinned account.
clearActiveAccount();
window.location.href = "/auth/login";
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<AvatarImg
src={a.avatar_url}
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-[10px] shrink-0"
/>
<span className="min-w-0 text-left">
<span className="block text-[13px] text-fg truncate">
{a.display_name ?? a.email.split("@")[0]}
</span>
<span className="block text-[11px] text-muted truncate">{a.email}</span>
</span>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
))}
</div>
<button
onClick={logout}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</button>
</div>
</div>,
document.body,
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
onClick={() => {
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
// and pins that. Other tabs keep their own pinned account.
clearActiveAccount();
window.location.href = "/auth/login";
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
onClick={logout}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</button>
</div>
</PopoverSurface>
</div>
</div>
</nav>
+204 -348
View File
@@ -27,8 +27,7 @@ import {
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
import { modalCount } from "./Modal";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
@@ -60,32 +59,6 @@ import { useScrollFade } from "../lib/useScrollFade";
// How close to the end (seconds) counts as "finished" → auto-mark watched.
const FINISH_MARGIN = 10;
// Force a high starting resolution. YouTube decides quality at player-CREATION time from the
// iframe's INTERNAL viewport size, then latches it. A windowed-size embed (~900×500) is stuck at
// ~360480p, which is why the video used to open low and needed a manual fullscreen + gear trip to
// 1080p.
//
// Two phases (see applyHdPhase1 / switchToHdNative / the quality poll):
// 1. Create the iframe at this large FIXED logical size and CSS `transform: scale()` it DOWN to fit
// the visible box. A CSS transform does NOT change the iframe's internal viewport, so YouTube
// still sees 1920×1080 and latches 1080p — even while it paints small. (Its own controls, incl.
// the gear, also paint scaled-down in the windowed box — the one downside of phase 1 alone.)
// 2. Once 1080p has actually latched, drop to NATIVE responsive sizing (setSize) so YouTube renders
// its controls at full size.
//
// ⚠️ CONDITIONAL / UNDER OBSERVATION (2026-07-31): phase 2 is NOT proven safe everywhere. In dev, on
// ONE `was_live` stream, quality dropped hd1080 → large (480p) ~2s after going native (YouTube
// re-caps to the now-small internal viewport); on the author's own machine/connection it HELD 1080p
// on a normal VOD. So it's environment-/video-dependent and is being watched in prod. Two levers:
// • HD_DROP_TO_NATIVE = false → skip phase 2, keep phase 1 forever = GUARANTEED 1080p (the viewport
// stays 1920), at the cost of the scaled-down controls. The safe fallback if phase 2 downgrades.
// • FORCE_HD_LOGICAL_SIZE = false → disable the whole trick, back to responsive 100%/100% sizing.
// 16:9 to match the `aspect-video` stage.
const FORCE_HD_LOGICAL_SIZE = true;
const HD_DROP_TO_NATIVE = true;
const HD_W = 1920;
const HD_H = 1080;
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a
// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends
// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist).
@@ -94,108 +67,6 @@ type LoopMode = "off" | "one" | "all";
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
function PlayerTransportControls({
variant,
index,
queueLength,
autoMode,
loopMode,
onPrev,
onNext,
onCycleAuto,
onCycleLoop,
}: {
variant: "docked" | "modal";
index: number;
queueLength: number;
autoMode: AutoMode;
loopMode: LoopMode;
onPrev: () => void;
onNext: () => void;
onCycleAuto: () => void;
onCycleLoop: () => void;
}) {
const { t } = useTranslation();
const docked = variant === "docked";
const navBtn = docked
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
const navLabel = docked ? "hidden sm:inline" : "";
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
const toggle = (on: boolean) =>
docked
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
}`
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`;
const toggleLabel = docked ? "hidden md:inline" : "";
return (
<>
<button
onClick={onPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className={navBtn}
>
<SkipBack className="h-4 w-4" />
<span className={navLabel}>{t("player.previous")}</span>
</button>
<span className={counter}>
{index + 1} / {queueLength}
</span>
<button
onClick={onNext}
disabled={index === queueLength - 1}
title={`${t("player.next")} · Shift+→`}
className={navBtn}
>
<span className={navLabel}>{t("player.next")}</span>
<SkipForward className="h-4 w-4" />
</button>
<span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={onCycleAuto}
title={t("player.autoAdvance.hint")}
className={toggle(autoMode !== "off")}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</button>
<button
onClick={onCycleLoop}
title={t("player.loop.hint")}
className={toggle(loopMode !== "off")}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</button>
</>
);
}
/** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)?
* Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut
* must not eat the only keyboard way to read a long description. Stops at `<body>`: the page
@@ -289,13 +160,6 @@ export default function PlayerModal({
const resumeAt =
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
const mountRef = useRef<HTMLDivElement | null>(null);
// The box the (fixed HD-sized) player iframe is scaled to fit — see FORCE_HD_LOGICAL_SIZE.
const videoBoxRef = useRef<HTMLDivElement | null>(null);
// Rescales the HD iframe into videoBoxRef; set up once (ResizeObserver) and also called on ready.
const fitPlayerRef = useRef<(() => void) | null>(null);
// HD phase: false = phase 1 (fixed 1920×1080 iframe scaled down, to latch 1080p); true = phase 2
// (native responsive sizing, so YouTube's own controls render full-size once 1080p has latched).
const hdNativeRef = useRef(false);
const playerRef = useRef<YTPlayer | null>(null);
const autoMarkedRef = useRef(false);
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
@@ -456,42 +320,6 @@ export default function PlayerModal({
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
const [playerError, setPlayerError] = useState<number | null>(null);
// Put the HD iframe into phase 1 (fixed 1920×1080, scaled to fit) so YouTube (re)latches 1080p from
// a full-HD internal viewport. Called on player-ready and when a NEW video is loaded into the same
// player (a description link) — otherwise the new clip would start at the native box's low quality.
const applyHdPhase1 = () => {
if (!FORCE_HD_LOGICAL_SIZE) return;
hdNativeRef.current = false;
const f = playerRef.current?.getIframe?.();
if (f) {
// Anchor the fixed-HD iframe top-left of its box so the scale-transform (fitPlayerRef) fits it
// in; `maxWidth:none` guards against a global iframe { max-width:100% } reset.
f.style.position = "absolute";
f.style.top = "0";
f.style.left = "0";
f.style.width = `${HD_W}px`;
f.style.height = `${HD_H}px`;
f.style.maxWidth = "none";
f.style.transformOrigin = "0 0";
}
fitPlayerRef.current?.();
};
// Once 1080p has latched (see the quality poll), drop to native responsive sizing so YouTube renders
// its own controls — the gear especially — at full size. Gated by HD_DROP_TO_NATIVE (see the note at
// the top): if phase 2 proves to downgrade in the wild, flip that off to keep phase 1 forever.
const switchToHdNative = () => {
const f = playerRef.current?.getIframe?.();
if (!f) return;
hdNativeRef.current = true;
f.style.position = "";
f.style.top = "";
f.style.left = "";
f.style.transform = "none";
f.style.transformOrigin = "";
f.style.maxWidth = "";
fitPlayerRef.current?.(); // now runs the native setSize branch
};
const loadVideo = (id: string, start: number | null) => {
const p = playerRef.current;
if (!p || typeof p.loadVideoById !== "function") return;
@@ -499,9 +327,6 @@ export default function PlayerModal({
const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
setPlayerError(null);
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
// Re-latch 1080p for the newly-loaded clip: back to phase 1 (the quality poll flips it to native
// once 1080p lands). Without this a description-link video would inherit the native box's low pick.
applyHdPhase1();
currentIdRef.current = id;
setCurrentVideoId(id);
setLiveData(null);
@@ -621,21 +446,41 @@ export default function PlayerModal({
}
};
// Lazy description (fetched only when the title is hovered). Anchored ABOVE the title so it grows
// upward over the player rather than downward off-screen / behind the OS taskbar — `flip` still
// rescues it when there is no room up there either, which the old fixed `bottom` could not. The
// hover-intent delay (only pop it if the pointer lingers, so a quick pass doesn't flash the card)
// and the travel grace are now the platform's, along with the safePolygon bridge that replaces the
// pair of hand-managed timers.
// Lazy description (fetched only when the title is hovered). The popover is
// portaled to <body> with fixed positioning so the modal card's overflow-y-auto
// can't clip it. A small close grace lets the mouse travel title → popover.
const [showDesc, setShowDesc] = useState(false);
const descPop = useAnchoredPopover({
open: showDesc,
onOpenChange: setShowDesc,
mode: "hover",
placement: "top-start",
offset: 8,
hoverDelay: { open: 400, close: 150 },
});
// `bottom` anchors the popover just above the title so it grows upward (over the
// player) instead of downward off-screen / behind the OS taskbar.
const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>(
null,
);
const titleRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const openTimer = useRef<number | undefined>(undefined);
const openDescNow = () => {
window.clearTimeout(closeTimer.current);
window.clearTimeout(openTimer.current);
const el = titleRef.current;
if (el) {
const r = el.getBoundingClientRect();
const width = Math.min(560, window.innerWidth - 32);
const left = Math.max(16, Math.min(r.left, window.innerWidth - width - 16));
setDescRect({ left, bottom: window.innerHeight - r.top + 8, width });
}
setShowDesc(true);
};
// Hover-intent: only pop the description if the pointer lingers on the title (~400ms), so a
// quick pass over it doesn't flash the popover.
const scheduleOpenDesc = () => {
window.clearTimeout(closeTimer.current);
window.clearTimeout(openTimer.current);
openTimer.current = window.setTimeout(openDescNow, 400);
};
const scheduleCloseDesc = () => {
window.clearTimeout(openTimer.current);
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
};
const detail = useQuery({
queryKey: qk.videoDetail(currentVideoId),
queryFn: () => api.videoDetail(currentVideoId),
@@ -645,28 +490,12 @@ export default function PlayerModal({
staleTime: 5 * 60_000,
});
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
// download dialog, or — after S4 — an anchored menu) takes Escape first and this player defers.
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer();
layerIdRef.current = id;
return () => {
removeLayer(id);
layerIdRef.current = null;
};
}, []);
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
// maximise or close the player underneath it.
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
@@ -675,6 +504,9 @@ export default function PlayerModal({
setMaximized(false);
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();
return;
}
@@ -728,6 +560,8 @@ export default function PlayerModal({
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
window.clearTimeout(closeTimer.current);
window.clearTimeout(openTimer.current);
window.clearTimeout(volTimerRef.current);
// Flush a debounced volume write — closing the player right after a wheel spin (the normal
// way to leave) must not drop the level the user just set.
@@ -804,53 +638,12 @@ export default function PlayerModal({
};
}, []);
// Keep the fixed-HD player iframe scaled to fit its visible box (see FORCE_HD_LOGICAL_SIZE). The
// iframe stays 1920×1080 internally (so YouTube serves 1080p) while we CSS-scale it DOWN to the
// box with a letterbox-`contain` fit — one factor + centring handles both the 16:9 windowed box
// and the non-16:9 big-view box (viewport minus the 56px control bar). Bound once; reads live refs.
useEffect(() => {
if (!FORCE_HD_LOGICAL_SIZE) return;
const box = videoBoxRef.current;
if (!box) return;
const fit = () => {
const p = playerRef.current;
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
if (!f) return;
const bw = box.clientWidth;
const bh = box.clientHeight;
if (!bw || !bh) return;
if (hdNativeRef.current) {
// Phase 2: native responsive sizing — the iframe IS the box, so YouTube's controls render
// at full size. Set the inline size explicitly too: phase 1 left an inline 1920×1080px on the
// element, which would otherwise override setSize's attributes and keep it overflowing.
f.style.width = `${bw}px`;
f.style.height = `${bh}px`;
if (p && typeof p.setSize === "function") p.setSize(bw, bh);
return;
}
// Phase 1: latch 1080p — scale the fixed 1920×1080 iframe DOWN to fit, letterbox-`contain`.
const k = Math.min(bw / HD_W, bh / HD_H);
const cx = (bw - HD_W * k) / 2;
const cy = (bh - HD_H * k) / 2;
f.style.transform = `translate(${cx}px, ${cy}px) scale(${k})`;
};
fitPlayerRef.current = fit;
const ro = new ResizeObserver(fit);
ro.observe(box);
return () => {
ro.disconnect();
fitPlayerRef.current = null;
};
}, []);
// Create the player, resume from the saved position, persist progress, and
// auto-mark watched once playback reaches the end.
useEffect(() => {
let cancelled = false;
const id = active.id;
autoMarkedRef.current = false;
// A fresh player starts in HD phase 1 (scaled), regardless of what the previous video ended on.
hdNativeRef.current = false;
setPlayerError(null);
// Auto-watch only applies to the active item — not to other videos the player
@@ -890,10 +683,8 @@ export default function PlayerModal({
loadYouTubeApi().then((YT) => {
if (cancelled || !mountRef.current) return;
playerRef.current = new YT.Player(mountRef.current, {
// A FIXED large logical size (scaled down to fit via fitPlayerRef) so YouTube starts at
// 1080p; falls back to the old responsive sizing if the trick is disabled. See HD_W/HD_H.
width: FORCE_HD_LOGICAL_SIZE ? String(HD_W) : "100%",
height: FORCE_HD_LOGICAL_SIZE ? String(HD_H) : "100%",
width: "100%",
height: "100%",
videoId: id,
playerVars: {
autoplay: 1,
@@ -904,21 +695,16 @@ export default function PlayerModal({
playsinline: 1,
// Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
// now (YouTube removed them), and the undocumented `vq` URL param is only occasionally
// honoured — kept because it's harmless. The real HD lever is the fixed HD_W×HD_H iframe
// size above (see FORCE_HD_LOGICAL_SIZE): YouTube reads quality from the iframe's INTERNAL
// viewport at creation, which a CSS transform-scale does not shrink — so scaling a LARGE
// player DOWN keeps the 1080p pick (an earlier note here that "the scale trick fails" was
// scaling a SMALL player UP, which genuinely doesn't fool the cap; the opposite direction
// does — measured 2026-07-31).
// honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by
// the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen
// lifts the cap (after which the higher pick persists for the session). We can't beat that
// programmatically — a CSS transform-scale trick was tried and does NOT fool the cap.
vq: "hd1080",
},
events: {
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and
// restore the remembered volume — a fresh player always starts at 100.
onReady: () => {
// Start in phase 1 (fixed-HD iframe scaled to fit) so YouTube latches 1080p; the quality
// poll below flips to native sizing once it lands.
applyHdPhase1();
focusModal();
applyVolume(volumeRef.current, false);
},
@@ -961,18 +747,6 @@ export default function PlayerModal({
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
const timer = window.setInterval(persist, 5000);
// HD phase 2 trigger: once the (phase-1, scaled) player has actually latched 1080p, drop to native
// sizing so YouTube's controls render full-size. Only at hd1080+. Gated by HD_DROP_TO_NATIVE — the
// conditional/under-observation switch (see the note up top): a video that never reaches 1080p, a
// constrained network, or HD_DROP_TO_NATIVE=false all keep phase 1 (the guaranteed-1080p fallback).
// Re-arms automatically after a description-link load (applyHdPhase1 resets the flag).
const HD_NATIVE_QUALITIES = new Set(["hd1080", "hd1440", "hd2160"]);
const hdPoll = window.setInterval(() => {
if (!FORCE_HD_LOGICAL_SIZE || !HD_DROP_TO_NATIVE || hdNativeRef.current) return;
const p = playerRef.current;
const q = p && typeof p.getPlaybackQuality === "function" ? p.getPlaybackQuality() : "";
if (HD_NATIVE_QUALITIES.has(q)) switchToHdNative();
}, 700);
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
// without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
// beacon so the POST survives the unload. Only for the library item we opened with — a navigated
@@ -993,7 +767,6 @@ export default function PlayerModal({
return () => {
cancelled = true;
window.clearInterval(timer);
window.clearInterval(hdPoll);
window.removeEventListener("pagehide", onHide);
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
Promise.resolve(persist()).finally(() => {
@@ -1065,11 +838,8 @@ export default function PlayerModal({
YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent),
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
<div
ref={videoBoxRef}
className={
maximized || isFullscreen
? "absolute inset-x-0 top-0 bottom-14 overflow-hidden"
: "relative w-full h-full overflow-hidden"
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
}
>
<div
@@ -1105,9 +875,8 @@ export default function PlayerModal({
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
visible (no auto-hide) so there's no guessing whether it will appear and no fight with the
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
the exit button also re-arms our shortcuts. Add-to-playlist works in true fullscreen (its
popover portals INTO the fullscreen element); download stays hidden there because it opens
a Modal, which still portals to <body> — outside the fullscreen element. */}
the exit button also re-arms our shortcuts. Add-to-playlist / download are hidden in true
fullscreen — their popovers portal to <body>, outside the fullscreen element. */}
{(maximized || isFullscreen) && (
<div
onClick={(e) => e.stopPropagation()}
@@ -1115,30 +884,76 @@ export default function PlayerModal({
>
{hasQueue && (
<>
<PlayerTransportControls
variant="docked"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
<button
onClick={goPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
>
<SkipBack className="h-4 w-4" />
<span className="hidden sm:inline">{t("player.previous")}</span>
</button>
<span className="tabular-nums text-white/70">
{index + 1} / {queue!.length}
</span>
<button
onClick={goNext}
disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
>
<span className="hidden sm:inline">{t("player.next")}</span>
<SkipForward className="h-4 w-4" />
</button>
<span className="h-4 w-px bg-white/20" />
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
autoMode !== "off"
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
<span className="hidden md:inline">
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
loopMode !== "off"
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
<span className="hidden md:inline">
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</button>
<span className="h-4 w-px bg-white/20" />
</>
)}
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && (
{!navigated && !isFullscreen && (
<AddToPlaylist
videoId={active.id}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
/>
)}
{!navigated && (
{!navigated && !isFullscreen && (
<DownloadButton
videoId={active.id}
title={active.title}
@@ -1236,17 +1051,61 @@ export default function PlayerModal({
{hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<PlayerTransportControls
variant="modal"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
<button
onClick={goPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
<SkipBack className="w-4 h-4" /> {t("player.previous")}
</button>
<span className="text-muted tabular-nums">
{index + 1} / {queue!.length}
</span>
<button
onClick={goNext}
disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
{t("player.next")} <SkipForward className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-border" />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
autoMode !== "off"
? "bg-accent/15 text-accent"
: "text-muted hover:bg-surface hover:text-fg"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
loopMode !== "off"
? "bg-accent/15 text-accent"
: "text-muted hover:bg-surface hover:text-fg"
}`}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</button>
</div>
)}
@@ -1256,9 +1115,10 @@ export default function PlayerModal({
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */}
<span
ref={descPop.refs.setReference}
{...descPop.getReferenceProps()}
ref={titleRef}
className="cursor-default"
onMouseEnter={scheduleOpenDesc}
onMouseLeave={scheduleCloseDesc}
>
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</span>
@@ -1273,32 +1133,43 @@ export default function PlayerModal({
{t("player.back")}
</button>
)}
<PopoverSurface
popover={descPop}
className="w-[min(35rem,calc(100vw-2rem))] bg-surface border border-border rounded-xl shadow-2xl p-4"
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
{showDesc &&
descRect &&
createPortal(
<div
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
className="fixed z-popover bg-surface border border-border rounded-xl shadow-2xl p-4"
style={{
left: descRect.left,
bottom: descRect.bottom,
width: descRect.width,
}}
onMouseEnter={openDescNow}
onMouseLeave={scheduleCloseDesc}
>
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</div>,
document.body,
)}
</PopoverSurface>
<button
data-testid="player-close"
onClick={onClose}
@@ -1415,25 +1286,10 @@ export default function PlayerModal({
</div>
)}
{/* Reliable, discoverable path into our big view straight from the windowed player —
the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and
the only fullscreen button previously reachable in the windowed video was YouTube's
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
the docked bar offers OUR true fullscreen. */}
{!navigated && (
<button
onClick={toggleFullscreen}
title={`${t("player.fullscreen")} · F`}
aria-label={t("player.fullscreen")}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
>
<Maximize className="w-4 h-4" />
</button>
)}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
+3 -17
View File
@@ -46,7 +46,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history";
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
import { modalCount } from "./Modal";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -808,18 +808,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
handleBack();
}, [handleBack]);
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer();
layerIdRef.current = id;
return () => {
removeLayer(id);
layerIdRef.current = null;
};
}, []);
// --- keyboard --------------------------------------------------------------------------------
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -886,10 +874,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
else handleBack();
break;
case "Escape":
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug).
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
// 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();
else if (menuOpenRef.current) {
setMenuOpen(false);
+58 -53
View File
@@ -1,11 +1,12 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
import { formatViews } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { useDismiss } from "../lib/useDismiss";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
@@ -20,17 +21,12 @@ export default function SyncStatus({
const { t } = useTranslation();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
// A read-out with a couple of controls rather than a list of choices, so it is a `dialog`
// popover. It portals out of the header, whose glass backdrop-filter would otherwise trap its
// stacking and positioning.
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "dialog",
role: "dialog",
placement: "bottom-end",
offset: 8,
});
const chipRef = useRef<HTMLButtonElement | null>(null);
const popRef = useRef<HTMLDivElement | null>(null);
// Fixed viewport coords: the popover is portaled out of the header, whose glass backdrop-filter
// would otherwise trap its stacking and positioning.
const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 });
useDismiss(open, () => setOpen(false), [chipRef, popRef]);
const { data } = useQuery({
queryKey: qk.myStatus(),
queryFn: api.myStatus,
@@ -110,11 +106,16 @@ export default function SyncStatus({
return (
<div className="pointer-events-auto shrink-0">
<button
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
ref={chipRef}
onClick={() => {
const r = chipRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 8, right: window.innerWidth - r.right });
setOpen((o) => !o);
}}
// Short label only: the popover already explains the numbers, and the long one overflowed
// the viewport edge from here.
title={countsText}
aria-expanded={open}
className="glass glass-hover relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs text-muted"
>
{active ? (
@@ -128,46 +129,50 @@ export default function SyncStatus({
)}
</button>
<PopoverSurface
popover={pop}
ariaLabel={countsText}
className="glass-menu w-64 rounded-xl p-3 text-xs text-muted animate-[popIn_0.16s_ease]"
>
<div
title={t("header.sync.countTooltip")}
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
>
<Database className="w-3.5 h-3.5 shrink-0" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</div>
{showMain && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
{pauseBtn}
</div>
)}
{notFull > 0 && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<button
onClick={() => {
setOpen(false);
onGoToFullHistory();
}}
title={t("header.sync.fullHistoryTooltip")}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
{open &&
createPortal(
<div
ref={popRef}
style={{ position: "fixed", top: pos.top, right: pos.right }}
className="glass-menu w-64 rounded-xl p-3 z-overlay text-xs text-muted animate-[popIn_0.16s_ease]"
>
<div
title={t("header.sync.countTooltip")}
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
{!showMain && pauseBtn}
</div>
<Database className="w-3.5 h-3.5 shrink-0" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</div>
{showMain && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
{pauseBtn}
</div>
)}
{notFull > 0 && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<button
onClick={() => {
setOpen(false);
onGoToFullHistory();
}}
title={t("header.sync.fullHistoryTooltip")}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
{!showMain && pauseBtn}
</div>
)}
</div>,
document.body,
)}
</PopoverSurface>
</div>
);
}
+43 -38
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -8,12 +9,10 @@ import { notify } from "../lib/notifications";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the
// Channel manager). The popover rides the R8 platform, so the list's own scroll can't clip it and
// the cursor can travel the gap into it (safePolygon) without the old hand-rolled 150 ms timer.
// Channel manager). The popover is portaled to <body> so the list's own scroll can't clip it.
function TagRow({
tag,
channels,
@@ -30,20 +29,25 @@ function TagRow({
const { t } = useTranslation();
const [name, setName] = useState(tag.name);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ left: 0, top: 0 });
const popFade = useScrollFade();
const countRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const commit = () => {
const v = name.trim();
if (v && v !== tag.name) onRename(v);
else setName(tag.name);
};
const pop = useAnchoredPopover({
open: open && channels.length > 0,
onOpenChange: setOpen,
mode: "hover",
placement: "right-start",
offset: 8,
hoverDelay: { open: 0, close: 150 },
});
const openPop = () => {
window.clearTimeout(closeTimer.current);
if (channels.length === 0) return;
const r = countRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, top: r.top });
setOpen(true);
};
const closeSoon = () => {
closeTimer.current = window.setTimeout(() => setOpen(false), 150);
};
return (
<div className="flex items-center gap-2">
<input
@@ -54,8 +58,9 @@ function TagRow({
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<span
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
ref={countRef}
onMouseEnter={openPop}
onMouseLeave={closeSoon}
className={`text-xs text-muted tabular-nums shrink-0 ${
channels.length
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
@@ -72,30 +77,30 @@ function TagRow({
>
<Trash2 className="w-4 h-4" />
</button>
<PopoverSurface
popover={pop}
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
{t("tagManager.onChannels")}
</div>
{/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
<div
ref={popFade.ref}
style={popFade.style}
className="max-h-72 overflow-y-auto no-scrollbar"
>
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => onPickChannel(ch.title)}
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
>
{ch.title}
</button>
))}
</div>
</PopoverSurface>
{open &&
createPortal(
<div
ref={popFade.ref}
onMouseEnter={openPop}
onMouseLeave={closeSoon}
style={{ position: "fixed", left: pos.left, top: pos.top, ...popFade.style }}
className="z-popover glass-menu w-56 max-h-72 overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
{t("tagManager.onChannels")}
</div>
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => onPickChannel(ch.title)}
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
>
{ch.title}
</button>
))}
</div>,
document.body,
)}
</div>
);
}
+73 -30
View File
@@ -1,13 +1,16 @@
import { useState, useSyncExternalStore } from "react";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { hintsEnabled, subscribeHints } from "../lib/hints";
type Side = "top" | "bottom";
type Coords = { left: number; top: number; placement: Side };
const MARGIN = 8;
const MAX_HALF = 120; // half of the tooltip's max-w-[240px] — the widest it can get
/** Wrap any element to show a short glass hint caption on hover — but only while the
* app-wide hints toggle (Settings → Appearance) is on. Anchored by the R8 popover platform, so it
* is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room
* and slides back inside the viewport near an edge. */
* app-wide hints toggle (Settings → Appearance) is on. Rendered in a portal with
* fixed positioning so it is never clipped by an overflow/stacking ancestor. */
export default function Tooltip({
hint,
side = "top",
@@ -18,34 +21,74 @@ export default function Tooltip({
children: React.ReactNode;
}) {
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open: open && enabled && !!hint,
onOpenChange: setOpen,
mode: "hover",
placement: side,
offset: 8,
// Show/hide on contact, as the pre-platform tooltip did — the platform's 120/140 ms hover-intent
// default suits interactive cards but makes a fast sweep across an icon row surface no hints.
hoverDelay: { open: 0, close: 0 },
});
const ref = useRef<HTMLSpanElement>(null);
const tipRef = useRef<HTMLDivElement>(null);
const [coords, setCoords] = useState<Coords | null>(null);
function show() {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
// Prefer the requested side; flip to bottom if there's no room above.
const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top";
const center = r.left + r.width / 2;
// Clamp using the MAX half-width so the caption can never overflow the viewport, even on the
// first paint (before we know its real width). A left-edge anchor near x=0 would otherwise push
// the centered box off-screen. The layout effect below refines this to the actual width.
setCoords({
left: Math.min(Math.max(center, MAX_HALF + MARGIN), window.innerWidth - MAX_HALF - MARGIN),
top: placement === "top" ? r.top - 8 : r.bottom + 8,
placement,
});
}
function hide() {
setCoords(null);
}
// Once rendered, re-center on the anchor using the caption's ACTUAL width (a short hint doesn't
// need the full 240px reservation), still clamped inside the viewport.
useLayoutEffect(() => {
const tip = tipRef.current;
const el = ref.current;
if (!coords || !tip || !el) return;
const r = el.getBoundingClientRect();
const half = tip.offsetWidth / 2;
const left = Math.min(
Math.max(r.left + r.width / 2, half + MARGIN),
window.innerWidth - half - MARGIN,
);
if (Math.abs(left - coords.left) > 0.5) setCoords((c) => (c ? { ...c, left } : c));
}, [coords]);
if (!enabled || !hint) return <>{children}</>;
return (
<>
<span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
{children}
</span>
{/* Passive caption: it must not swallow a click aimed at a neighbouring control that it
overlaps while the hover-close delay runs out. */}
<PopoverSurface
popover={pop}
passthrough
className="glass w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
>
{hint}
</PopoverSurface>
</>
<span
ref={ref}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
className="inline-flex"
>
{children}
{coords &&
createPortal(
<div
ref={tipRef}
role="tooltip"
style={{
position: "fixed",
left: coords.left,
top: coords.top,
transform: `translateX(-50%) translateY(${coords.placement === "top" ? "-100%" : "0"})`,
}}
className="glass pointer-events-none z-tooltip w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
>
{hint}
</div>,
document.body,
)}
</span>
);
}
+100 -51
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { useDismiss } from "../lib/useDismiss";
export interface ViewOption<T extends string> {
id: T;
@@ -13,11 +13,9 @@ export interface ViewOption<T extends string> {
// menu behind it. Generic over the mode id so any module's list can adopt it (the feed's
// density modes; the managers' layouts) without re-deriving the menu behaviour.
//
// This was the house's textbook menu, with its own hand-rolled roving-tabindex/arrow/Escape engine.
// It now rides the R8 popover platform like every other menu, which supplies the same model
// (arrows/Home/End rove, Escape closes and hands focus back to the trigger, Tab out closes) plus the
// two things the hand-rolled version lacked: typeahead, and anchoring that flips and shifts instead
// of being absolutely positioned inside whatever ancestor happens to clip it.
// This is a real menu, so it carries the whole keyboard model the role promises: arrows/Home/End
// move a roving focus, Escape closes, and closing always hands focus back to the trigger (the
// menu unmounts under the focused item, which would otherwise strand focus on <body>).
export default function ViewSwitcher<T extends string>({
value,
options,
@@ -36,6 +34,19 @@ export default function ViewSwitcher<T extends string>({
labelClassName?: string;
}) {
const [open, setOpen] = useState(false);
// Which item holds the roving tabindex; seeded to the active one when the menu opens.
const [focusIdx, setFocusIdx] = useState(0);
const btnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
function close({ refocus = true }: { refocus?: boolean } = {}) {
setOpen(false);
if (refocus) btnRef.current?.focus();
}
// Outside-click must NOT refocus the trigger — that would steal focus from whatever the user
// just clicked. Escape is handled on the menu itself (below), where returning focus is right.
useDismiss(open, () => close({ refocus: false }), [btnRef, menuRef]);
// A `value` outside `options` is a caller bug; degrade to the first option rather than render
// nothing. The menu then shows no checked item, which is honest — nothing IS selected.
@@ -43,24 +54,55 @@ export default function ViewSwitcher<T extends string>({
0,
options.findIndex((o) => o.id === value),
);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
placement: "bottom-end",
// Opening a "pick one of these modes" menu should land on the mode that is already picked.
selectedIndex: currentIdx,
});
// Seeding the roving index HERE — rather than in an effect keyed on `open` — matters: an effect
// would only schedule the update, so the focus effect below would still see the previous index
// and focus the wrong item for one render before correcting.
function openMenu() {
setFocusIdx(currentIdx);
setOpen(true);
}
// Clamped at render, so a caller whose option list shrinks while the menu is open can't leave
// the roving tabindex pointing past the end (which would make every item un-tabbable).
const rovingIdx = Math.min(focusIdx, options.length - 1);
useEffect(() => {
if (open) itemRefs.current[rovingIdx]?.focus();
}, [open, rovingIdx]);
const current = options[currentIdx];
if (!current) return null;
const CurrentIcon = current.icon;
function onMenuKeyDown(e: React.KeyboardEvent) {
const last = options.length - 1;
if (e.key === "Escape") {
// Stop it reaching useDismiss's document listener, which would close without refocusing.
e.stopPropagation();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setFocusIdx((i) => (i >= last ? 0 : i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIdx((i) => (i <= 0 ? last : i - 1));
} else if (e.key === "Home") {
e.preventDefault();
setFocusIdx(0);
} else if (e.key === "End") {
e.preventDefault();
setFocusIdx(last);
} else if (e.key === "Tab") {
// Tabbing out of a menu closes it, but the focus is moving on by itself.
close({ refocus: false });
}
}
return (
<>
<div className="relative">
<button
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
ref={btnRef}
onClick={() => (open ? close() : openMenu())}
aria-haspopup="menu"
aria-expanded={open}
// The aria-label carries the mode's name at every size — the visible text below is a
// nicety for wide windows, not the accessible name.
aria-label={`${label}: ${current.label}`}
@@ -73,39 +115,46 @@ export default function ViewSwitcher<T extends string>({
className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
>
{/* The modes are one exclusive choice, so the radio items need a group to scope their
checked-ness to — otherwise they read as independent checkable items. */}
<div role="group">
{options.map((o) => {
const Icon = o.icon;
const on = o.id === value;
return (
<PopoverItem
key={o.id}
role="menuitemradio"
checked={on}
onSelect={() => {
onChange(o.id);
setOpen(false);
}}
label={o.label}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
on ? "text-accent" : "text-fg hover:bg-card"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex-1 truncate">{o.label}</span>
{on && <Check className="w-3.5 h-3.5 shrink-0" />}
</PopoverItem>
);
})}
{open && (
<div
ref={menuRef}
role="menu"
aria-label={label}
onKeyDown={onMenuKeyDown}
className="glass-menu absolute right-0 top-full mt-1 z-chrome min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
>
{/* The modes are one exclusive choice, so the radio items need a group to scope their
checked-ness to — otherwise they read as independent checkable items. */}
<div role="group">
{options.map((o, i) => {
const Icon = o.icon;
const on = o.id === value;
return (
<button
key={o.id}
ref={(el) => {
itemRefs.current[i] = el;
}}
role="menuitemradio"
aria-checked={on}
tabIndex={i === rovingIdx ? 0 : -1}
onClick={() => {
onChange(o.id);
close();
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition ${
on ? "text-accent" : "text-fg hover:bg-card"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex-1 truncate">{o.label}</span>
{on && <Check className="w-3.5 h-3.5 shrink-0" />}
</button>
);
})}
</div>
</div>
</PopoverSurface>
</>
)}
</div>
);
}
+122 -130
View File
@@ -1,7 +1,7 @@
import { useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { type SortState } from "../lib/columnSort";
@@ -85,145 +85,100 @@ function FilterOptionList({ children }: { children: ReactNode }) {
);
}
// MODULE LEVEL, deliberately. The panel used to be declared inside DataTable's body, which made it a
// NEW component type on every render: React unmounted and remounted the popover on each keystroke,
// so the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The
// MODULE LEVEL, deliberately. This used to be declared inside DataTable's body, which made it a NEW
// component type on every render: React unmounted and remounted the popover on each keystroke, so
// the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The
// `autoFocus` masked it well enough that it read as "the filter is just twitchy".
//
// The funnel and its panel live in ONE component because the R8 popover platform anchors the panel
// to its trigger. That also retires this file's three hand-rolled focus chores: capturing the
// trigger element at open time (a `ref` scoped to the open column nulled out before focus could go
// back), the restore-focus effect that stopped Escape stranding focus on <body>, and the useDismiss
// registration that had to include the trigger so its own mousedown didn't close-then-reopen.
function FilterFunnel<T>({
function FilterPopover<T>({
col,
value,
onApply,
open,
onOpenChange,
panelRef,
}: {
col: Column<T>;
value: string | string[] | undefined;
onApply: (key: string, value: string | string[]) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
panelRef: RefObject<HTMLDivElement>;
}) {
const { t } = useTranslation();
const f = col.filter!;
const inputRef = useRef<HTMLInputElement | null>(null);
// A text filter is a free-text field, so it is a dialog whose input takes focus; the option lists
// are real menus with roving focus. `select` is one exclusive choice (radio), `multi` is several
// independent ones (checkbox).
const isText = f.kind === "text";
const pop = useAnchoredPopover({
open,
onOpenChange,
mode: isText ? "dialog" : "menu",
role: isText ? "dialog" : "menu",
});
const filterOn = isActive(value);
return (
<>
<button
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={t("datatable.filter")}
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
className={`-m-1.5 p-1.5 transition ${
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
}`}
>
<ListFilter className="w-3.5 h-3.5" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={col.header}
initialFocus={isText ? inputRef : undefined}
className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
>
{f.kind === "text" && (
<input
ref={inputRef}
value={(value as string) ?? ""}
onChange={(e) => onApply(col.key, e.target.value)}
placeholder={col.header}
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
)}
{f.kind === "select" && (
<div className="flex flex-col gap-0.5">
<PopoverItem
role="menuitemradio"
checked={!filterOn}
onSelect={() => onApply(col.key, "")}
label={t("datatable.all")}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
<div
ref={panelRef}
className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
>
{f.kind === "text" && (
<input
autoFocus
value={(value as string) ?? ""}
onChange={(e) => onApply(col.key, e.target.value)}
placeholder={col.header}
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
)}
{f.kind === "select" && (
<div className="flex flex-col gap-0.5">
<button
onClick={() => onApply(col.key, "")}
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
!isActive(value) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
{t("datatable.all")}
</button>
{f.options.map((o) => (
<button
key={o.value}
onClick={() => onApply(col.key, o.value)}
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
{t("datatable.all")}
</PopoverItem>
{f.options.map((o) => (
<PopoverItem
{o.label}
</button>
))}
</div>
)}
{f.kind === "multi" && (
<FilterOptionList>
{f.options.length === 0 && (
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
)}
{f.options.map((o) => {
const arr = (value as string[]) ?? [];
const on = arr.includes(o.value);
return (
<button
key={o.value}
role="menuitemradio"
checked={value === o.value}
onSelect={() => onApply(col.key, o.value)}
label={o.label}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
onClick={() =>
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
}
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
{o.label}
</PopoverItem>
))}
</div>
)}
{f.kind === "multi" && (
<FilterOptionList>
{f.options.length === 0 && (
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
)}
{f.options.map((o) => {
const arr = (value as string[]) ?? [];
const on = arr.includes(o.value);
return (
<PopoverItem
key={o.value}
role="menuitemcheckbox"
checked={on}
onSelect={() =>
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
}
label={o.label}
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
on ? "text-fg" : "text-muted hover:bg-card"
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <X className="w-2.5 h-2.5" />}
</span>
{o.label}
</PopoverItem>
);
})}
</FilterOptionList>
)}
{filterOn && (
<button
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
</PopoverSurface>
</>
{on && <X className="w-2.5 h-2.5" />}
</span>
{o.label}
</button>
);
})}
</FilterOptionList>
)}
{isActive(value) && (
<button
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
</div>
);
}
@@ -248,14 +203,33 @@ export function ColumnHead<T>({
filters: FilterMap;
onFilter: (key: string, value: string | string[]) => void;
}) {
// Only one column's filter panel is open at a time; each funnel owns its own anchoring.
const { t } = useTranslation();
const [openFilter, setOpenFilter] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
// The funnel that opened the panel. `useDismiss` must know about it too, or the trigger's own
// mousedown closes the panel and the click that follows immediately reopens it — clicking the
// funnel a second time then looks like it does nothing. Also the thing to hand focus back to.
const triggerRef = useRef<HTMLButtonElement | null>(null);
const restoreFocus = useRef(false);
useDismiss(!!openFilter, () => setOpenFilter(null), [panelRef, triggerRef]);
// Escape (or an outside click) used to unmount the focused input and strand focus on <body>,
// so the next Tab restarted from the top of the page.
useEffect(() => {
if (openFilter) {
restoreFocus.current = true;
} else if (restoreFocus.current) {
restoreFocus.current = false;
triggerRef.current?.focus();
}
}, [openFilter]);
return (
<thead className="sticky top-0 z-base after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
{columns.map((col) => {
const isSorted = sort?.key === col.key;
const filterOn = isActive(filters[col.key]);
return (
<th
key={col.key}
@@ -293,15 +267,33 @@ export function ColumnHead<T>({
<span>{col.header}</span>
)}
{col.filter && (
<FilterFunnel
col={col}
value={filters[col.key]}
onApply={onFilter}
open={openFilter === col.key}
onOpenChange={(o) => setOpenFilter(o ? col.key : null)}
/>
<button
// Captured at open time rather than bound with `ref`, so it survives the close
// (a `ref` scoped to the open column nulls out before focus can go back).
onClick={(e) => {
triggerRef.current = e.currentTarget;
setOpenFilter((o) => (o === col.key ? null : col.key));
}}
aria-label={t("datatable.filter")}
aria-expanded={openFilter === col.key}
aria-haspopup="dialog"
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
className={`-m-1.5 p-1.5 transition ${
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
}`}
>
<ListFilter className="w-3.5 h-3.5" />
</button>
)}
</span>
{openFilter === col.key && col.filter && (
<FilterPopover
col={col}
value={filters[col.key]}
onApply={onFilter}
panelRef={panelRef}
/>
)}
</th>
);
})}
+3 -7
View File
@@ -46,11 +46,7 @@
"tags": {
"yourTags": "Your tags",
"manage": "Manage tags",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
"noneYet": "No tags yet — create one below.",
"create": "Create tag",
"createPlaceholder": "New tag…",
"createFailed": "Could not create the tag."
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)"
},
"loading": "Loading channels…",
"empty": "No channels.",
@@ -107,8 +103,8 @@
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
"resetBackfill": "Reset & re-fetch this channel",
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
"fullHistoryQueued": "queued",
"fullHistoryComing": "coming",
"fullHistoryQueued": "full history queued",
"fullHistoryComing": "full history coming",
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
+3 -7
View File
@@ -46,11 +46,7 @@
"tags": {
"yourTags": "Címkéid",
"manage": "Címkék kezelése",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
"noneYet": "Még nincs címke — hozz létre egyet lent.",
"create": "Címke létrehozása",
"createPlaceholder": "Új címke…",
"createFailed": "A címke létrehozása nem sikerült."
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)"
},
"loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.",
@@ -107,8 +103,8 @@
"backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.",
"resetBackfill": "Csatorna resetelése és újraletöltése",
"resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
"fullHistoryQueued": "sorban",
"fullHistoryComing": "úton",
"fullHistoryQueued": "teljes előzmény sorban",
"fullHistoryComing": "teljes előzmény úton",
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
-1
View File
@@ -31,7 +31,6 @@ export interface ChannelDetail {
backfill_done: boolean;
from_explore: boolean;
details_synced: boolean;
tag_ids: number[];
}
export interface ExploreResult {
+1 -1
View File
@@ -12,7 +12,7 @@ export interface Tag {
export const tagsApi = {
tags: (): Promise<Tag[]> => req("/api/tags"),
createTag: (t: { name: string; color?: string; category?: string }): Promise<Tag> =>
createTag: (t: { name: string; color?: string; category?: string }) =>
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
updateTag: (id: number, patch: { name?: string; color?: string }) =>
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
-15
View File
@@ -1,5 +1,3 @@
import { useSyncExternalStore } from "react";
// The browser's Fullscreen API, WebKit included.
//
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no
@@ -49,16 +47,3 @@ export function onFullscreenChange(handler: () => void): () => void {
document.removeEventListener("webkitfullscreenchange", handler);
};
}
// `fullscreenElement()` returns the live DOM node, so the same element is the same reference across
// calls — a valid useSyncExternalStore snapshot without any caching of our own.
const fsSnapshot = () => fullscreenElement() as HTMLElement | null;
const fsServerSnapshot = () => null;
/** The fullscreen element as reactive state, for code that must RENDER differently while something
* holds the lock — chiefly the popover platform, which has to portal INTO that element (a node
* portaled to <body> is painted underneath the fullscreen element, i.e. invisible). Components that
* merely react to the transition should keep using `onFullscreenChange` directly. */
export function useFullscreenElement(): HTMLElement | null {
return useSyncExternalStore(onFullscreenChange, fsSnapshot, fsServerSnapshot);
}
-76
View File
@@ -1,76 +0,0 @@
import { useEffect, useRef } from "react";
// R8 S3 — one shared registry of open "dismissable layers" (modals, popovers, players), ordered by
// open time. It replaces the three mutually-unaware Escape systems that each guessed at ordering with
// its own `stopPropagation`: Modal's private `modalStack`, the popovers' document listeners, and the
// two players' `modalCount()` checks. Now EVERY layer keeps its own key handler but coordinates
// through this one stack — a layer acts on Escape only when it is topmost, and reads `anyAbove()` to
// know whether to defer. Because the decision is a stack lookup, not event-propagation order, the fix
// is robust to which listener happens to run first (the old stopPropagation was not).
//
// Notably this fixes the documented PlexPlayer bug: its menus register here, so the player sees a menu
// as a layer ABOVE it and defers, letting the menu's own Escape close it first — the player's
// auto-skip-cancel Escape then lands on the next press, instead of being swallowed by a blanket
// stopPropagation. (Popover-vs-popover ordering is still floating-ui's <FloatingTree>; this stack adds
// the cross-TYPE ordering the tree can't see.)
let stack: number[] = [];
let nextId = 1;
export function pushLayer(): number {
const id = nextId++;
stack.push(id);
return id;
}
export function removeLayer(id: number): void {
stack = stack.filter((x) => x !== id);
}
export function isTopmost(id: number): boolean {
return stack.length > 0 && stack[stack.length - 1] === id;
}
/** True when at least one layer sits above `id` — the "should I defer?" predicate for a layer with a
* richer key handler than a plain close (the players). */
export function anyLayerAbove(id: number): boolean {
const i = stack.indexOf(id);
return i !== -1 && i < stack.length - 1;
}
/** Register an open layer for as long as `active` is true, and get live predicates to read inside your
* own key handler. Pass `onEscape` to also install a topmost-only Escape listener (the common case —
* modals, popovers). Omit it for a layer that owns a bigger key handler and only needs `anyAbove()`
* to decide whether to defer (the players). */
export function useLayer(
active: boolean,
onEscape?: () => void,
): { isTopmost: () => boolean; anyAbove: () => boolean } {
const idRef = useRef<number | null>(null);
const escRef = useRef(onEscape);
escRef.current = onEscape;
useEffect(() => {
if (!active) return;
const id = pushLayer();
idRef.current = id;
let onKey: ((e: KeyboardEvent) => void) | undefined;
if (escRef.current) {
onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isTopmost(id)) escRef.current?.();
};
document.addEventListener("keydown", onKey);
}
return () => {
if (onKey) document.removeEventListener("keydown", onKey);
removeLayer(id);
idRef.current = null;
};
// Re-register only when open/closed toggles; onEscape is read fresh via escRef.
}, [active]);
return {
isTopmost: () => idRef.current != null && isTopmost(idRef.current),
anyAbove: () => idRef.current != null && anyLayerAbove(idRef.current),
};
}
-8
View File
@@ -134,12 +134,6 @@ export function DetailCustomizeMenu({
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
// Deliberately NOT on the R8 popover platform. In the player's OVERLAY variant this menu renders
// inside PlexPlayer's `transform: scale(1.25)` windowed subtree, and the platform portals its panel
// out to <body> — which would paint it at 1× while the surrounding player UI is at 1.25×. A plain
// `absolute` child stays in the scaled subtree and matches, in both this variant and the unscaled
// detail-page variant. Same reason the player's own gear/tracks menus stay hand-rolled; migrating
// any of them needs the platform to portal INTO a caller-named (scaled) container.
return (
<div className="relative shrink-0">
<button
@@ -174,8 +168,6 @@ export function DetailCustomizeMenu({
);
}
/** One switch row inside `DetailCustomizeMenu`. Independent on/off toggles (all can be on at once),
* so a plain button, not a menuitemradio. */
export function PrefToggle({
label,
on,
-19
View File
@@ -14,25 +14,6 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.59.0",
date: "2026-07-31",
summary:
"The menus, dropdowns and tooltips across the app are rebuilt on one system — with fuller keyboard and screen-reader support — and the player's Add to playlist and Download now work in fullscreen.",
features: [
"Player: Add to playlist and Download now work while the player is in fullscreen.",
"Channel page: for a channel you follow, your tags are now shown and editable right in the header, next to Subscribe.",
],
fixes: [
"Menus and dropdowns now support full keyboard navigation — arrow keys, Home/End and type-to-jump — and return focus to where you were when they close.",
"Escape now closes only the menu or dialog on top, instead of also closing the player or window behind it.",
"Tooltips and hover cards are steadier and no longer linger or get in the way; the header language switcher now opens on click.",
"Channel tags stay on one line in the manager and reveal any that don't fit in a hover card, so the table no longer reflows as you add them.",
],
chores: [
"Rebuilt the app's popovers, menus and tooltips on a single anchored-positioning system, replacing ~11 separate hand-written implementations.",
],
},
{
version: "0.58.0",
date: "2026-07-29",
-43
View File
@@ -1,43 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api } from "./api";
import { qk } from "./queryKeys";
import { notify } from "./notifications";
/** The shared channel-tag mutation flow used by both the channel manager and the channel detail
* page: optimistic attach/detach + a create-then-attach shortcut, with the cache-shape-specific
* optimistic update injected (the manager maps over a ManagedChannel[]; the detail patches one
* ChannelDetail). Keeps the two call sites from drifting. */
export function useChannelTagActions(opts: {
/** Apply the optimistic tag change to whichever cache the caller owns. `attach` true = add. */
optimisticToggle: (channelId: string, tagId: number, attach: boolean) => void;
/** Called after the attach/detach lands (e.g. sync a sibling cache). Optional. */
onSuccess?: (channelId: string) => void;
/** Called if the attach/detach fails — revert by refetching the owning cache. */
onError: (channelId: string) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const toggleTag = (channelId: string, tagId: number, hasTag: boolean) => {
opts.optimisticToggle(channelId, tagId, !hasTag);
const call = hasTag
? api.detachChannelTag(channelId, tagId)
: api.attachChannelTag(channelId, tagId);
call.then(() => opts.onSuccess?.(channelId)).catch(() => opts.onError(channelId));
};
// Create a brand-new personal tag and attach it in one step (so tagging doesn't require the
// top-of-page "Manage tags" dialog first). Refresh the tag catalog so it appears everywhere.
const createAndAttachTag = (channelId: string, name: string) => {
api
.createTag({ name })
.then((tag) => {
qc.invalidateQueries({ queryKey: qk.tags() });
toggleTag(channelId, tag.id, false);
})
.catch(() => notify({ level: "error", message: t("channels.tags.createFailed") }));
};
return { toggleTag, createAndAttachTag };
}
+20 -14
View File
@@ -1,25 +1,14 @@
import { useEffect, type RefObject } from "react";
import { useLayer } from "./layerStack";
/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given
* element(s). The reusable half of the popover/dropdown pattern that several components each
* hand-rolled; positioning stays with the caller (it varies per popover). Pass the panel ref
* (and optionally a trigger ref so clicking the trigger doesn't immediately re-close).
*
* Escape is dispatched through the shared `layerStack` (R8 S3): the popover registers as a layer and
* closes only when it is topmost, so a Modal or another popover opened above it takes the Escape
* first, and a player below defers to it. This replaces the old blanket `stopPropagation` — which
* swallowed a player's own Escape (e.g. PlexPlayer's auto-skip-cancel) whenever any popover was open. */
* (and optionally a trigger ref so clicking the trigger doesn't immediately re-close). */
export function useDismiss(
active: boolean,
onClose: () => void,
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
): void {
// Registration + topmost-only Escape.
useLayer(active, onClose);
// Outside-press stays per-instance (the refs/positioning are the caller's): close on a mousedown
// that lands outside every registered element.
useEffect(() => {
if (!active) return;
const list = Array.isArray(refs) ? refs : [refs];
@@ -27,12 +16,29 @@ export function useDismiss(
const target = e.target as Node;
if (!list.some((r) => r.current?.contains(target))) onClose();
};
const onKey = (e: KeyboardEvent) => {
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("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
// Matches the original effect: (re)subscribe only when open/closed toggles. Refs are stable and
// onClose is read fresh from this run's closure.
// Matches the original effects: (re)subscribe only when open/closed toggles. Refs are
// stable and onClose is read fresh from this run's closure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
}
-5
View File
@@ -15,11 +15,6 @@ export interface YTPlayer {
getDuration(): number;
getVideoData(): YTVideoData;
getIframe(): HTMLIFrameElement;
// Current latched playback quality (e.g. "hd1080") — used to detect when to drop the HD-latch
// iframe from its scaled-up size to native sizing. `setPlaybackQuality` is deliberately NOT typed:
// YouTube retired it (a hard no-op) — see [[reference-yt-iframe-api-remote-control-map]].
getPlaybackQuality(): string;
setSize(width: number, height: number): void;
getVolume(): number;
isMuted(): boolean;
setVolume(volume: number): void;
+20 -28
View File
@@ -1,7 +1,6 @@
import React, { lazy, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FloatingTree } from "@floating-ui/react";
import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
import { NavigationProvider } from "./components/NavigationProvider";
@@ -44,36 +43,29 @@ const root =
) : path === "/terms" ? (
<Terms />
) : path.startsWith("/watch/") ? (
// FloatingTree so the public player's own popovers dispatch Escape/outside-press topmost-only.
<FloatingTree>
<WatchPage />
</FloatingTree>
<WatchPage />
) : (
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
{/* One tree for the whole app: every anchored popover registers as a node so Escape and
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
<FloatingTree>
<ConfirmProvider>
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<PlexProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
</FeedFiltersProvider>
</NavigationProvider>
</ConfirmProvider>
</FloatingTree>
<ConfirmProvider>
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<PlexProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
</FeedFiltersProvider>
</NavigationProvider>
</ConfirmProvider>
</ErrorBoundary>
</QueryClientProvider>
);