fix(r5): address the seventh /code-review high round — 7 findings
Five of them sit where round 6 turned F from a Fullscreen API call into a CSS maximise: the guards around it still asked `isFullscreen`, which that pivot made permanently false. - Arrow-key volume was dead in the maximised view: the scroll-deference checked only `isFullscreenRef`, so the (now hidden but still scrollable) card kept swallowing Up/Down. Both flags now count as "the video fills the screen". Measured: 87px of scroll room on the focused card, ArrowDown moves 80 -> 75 and scrolls nothing. - The maximised view had no way out once the pointer touched the video. Reaching YouTube's controls means clicking into the cross-origin iframe, which takes keyboard focus with it, and the re-arm paths (stage mouseleave, window focus) need the pointer to leave the browser entirely while the stage fills it. So the exit now lives on screen, in our DOM: a button that un-maximises AND pulls focus back, re-arming every shortcut. - WebKit fires only `webkitfullscreenchange`/`webkitFullscreenElement`, so on Safari/iPadOS YouTube's own fullscreen never registered — no overlay yield, no focus reclaim. One `fullscreenEl()` reader, both event spellings. - The stage claimed `z-index: 9999`, the glass tuner's reserved level, for a job that only needs to beat the modal chrome it is nested in (its siblings top out at z-menu). Dropped to rail-level; nothing escapes the modal's stacking context either way. - `inset: 0` already sizes a fixed layer — the inherited `100vw`/`100vh` over-constrained it, which is how a classic scrollbar gets a horizontal overflow and a mobile URL bar hides the bottom edge (YouTube's control bar). - The shortcut hint still promised "F: fullscreen" in both locales. Backend, both "the fix stopped one layer short": - The startup HLS sweep was awaited inside lifespan, so uvicorn still served nothing until it finished — a thread doesn't help when nothing is listening yet. Fire-and-forget task instead, cancelled on shutdown. Measured with 200 planted segments: "Application startup complete" now precedes "swept 1". - An RSS poll where every channel failed returned normally, so the scheduler recorded `ok` with the outage buried in the summary string. `failed == total` now raises: a run that reached nothing is a failed run. Suites: backend 147 -> 150.
This commit is contained in:
+22
-6
@@ -70,20 +70,36 @@ async def lifespan(app: FastAPI):
|
|||||||
url,
|
url,
|
||||||
)
|
)
|
||||||
# No HLS session survives a restart (they live in this process), so anything still on disk is
|
# No HLS session survives a restart (they live in this process), so anything still on disk is
|
||||||
# a crashed run's segments — sweep them or they accumulate forever. Off the event loop: the
|
# a crashed run's segments — sweep them or they accumulate forever. FIRE AND FORGET, not
|
||||||
# first sweep after a long uptime can be gigabytes of `.ts` files, and blocking here delays
|
# awaited: uvicorn does not serve a single request until lifespan startup returns, so awaiting
|
||||||
# serving (a 502 + a failed post-deploy health probe).
|
# the sweep would hold the port shut for as long as the rmtree runs (gigabytes of `.ts` after a
|
||||||
swept = await asyncio.to_thread(plex_stream.sweep_orphans)
|
# long uptime) — the 502 + failed post-deploy health probe this is meant to avoid. A thread
|
||||||
if swept:
|
# alone doesn't buy that; not waiting does. The sweep only touches directories no live session
|
||||||
log.info("swept %d orphaned HLS segment directories", swept)
|
# owns, so racing it against early playback is safe.
|
||||||
|
sweep = asyncio.create_task(_sweep_hls_orphans())
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
log.info("Siftlode shutting down")
|
log.info("Siftlode shutting down")
|
||||||
|
sweep.cancel()
|
||||||
shutdown_scheduler()
|
shutdown_scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
async def _sweep_hls_orphans() -> None:
|
||||||
|
"""Background half of the startup HLS sweep (see lifespan). Owns its own errors: a failure here
|
||||||
|
must never take the app down, and a cancelled shutdown is not one."""
|
||||||
|
try:
|
||||||
|
swept = await asyncio.to_thread(plex_stream.sweep_orphans)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
log.exception("HLS orphan sweep failed")
|
||||||
|
return
|
||||||
|
if swept:
|
||||||
|
log.info("swept %d orphaned HLS segment directories", swept)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
@@ -52,9 +52,10 @@ def sync_subscriptions(
|
|||||||
def sync_rss(
|
def sync_rss(
|
||||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
# A partial failure comes back in `failed_feeds` so a manual poll reports it instead of an
|
||||||
|
# innocent-looking "0 new"; a TOTAL outage raises out of run_rss_poll and surfaces as a 500,
|
||||||
|
# which is the honest answer for a poll that reached nothing.
|
||||||
result = run_rss_poll(db, _user_channels(db, user))
|
result = run_rss_poll(db, _user_channels(db, user))
|
||||||
# `failed` = feeds that could not be read at all (see run_rss_poll); surfaced so a manual poll
|
|
||||||
# reports an outage instead of an innocent-looking "0 new".
|
|
||||||
return {"new_videos": result["new"], "failed_feeds": result["failed"]}
|
return {"new_videos": result["new"], "failed_feeds": result["failed"]}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
|||||||
|
|
||||||
`failed` counts a channel whose feed could not be READ *or* whose rows could not be APPLIED —
|
`failed` counts a channel whose feed could not be READ *or* whose rows could not be APPLIED —
|
||||||
both leave the channel un-polled, and counting only the first would move the blind spot rather
|
both leave the channel un-polled, and counting only the first would move the blind spot rather
|
||||||
than remove it."""
|
than remove it.
|
||||||
|
|
||||||
|
Raises RuntimeError when NOTHING could be polled (`failed == total`, total > 0): a run that
|
||||||
|
achieved literally nothing is a failed run, and only an exception turns the scheduler card red
|
||||||
|
— a returned count leaves it green with the outage buried in the summary line."""
|
||||||
if channels is None:
|
if channels is None:
|
||||||
channels = db.execute(select(Channel)).scalars().all()
|
channels = db.execute(select(Channel)).scalars().all()
|
||||||
total = len(channels)
|
total = len(channels)
|
||||||
@@ -99,6 +103,12 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
|||||||
progress.report(done, total, "rss_poll")
|
progress.report(done, total, "rss_poll")
|
||||||
if failed:
|
if failed:
|
||||||
log.warning("RSS poll: %d/%d channels could not be polled", failed, total)
|
log.warning("RSS poll: %d/%d channels could not be polled", failed, total)
|
||||||
|
if total and failed == total:
|
||||||
|
# Nothing was polled at all — an egress outage (the tinyproxy-after-reboot incident), a
|
||||||
|
# dead DNS, a read-only database. Returning normally would record the run as `ok` with the
|
||||||
|
# failure hidden inside the result string, i.e. a green pill for a total outage; the count
|
||||||
|
# in the message keeps the summary just as informative.
|
||||||
|
raise RuntimeError(f"RSS poll failed for all {total} channels")
|
||||||
return {"new": new, "failed": failed}
|
return {"new": new, "failed": failed}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -106,34 +106,71 @@ class TestRssPollResult:
|
|||||||
Counting only the fetch failures would move the "a broken run looks like a quiet hour" blind
|
Counting only the fetch failures would move the "a broken run looks like a quiet hour" blind
|
||||||
spot rather than remove it."""
|
spot rather than remove it."""
|
||||||
|
|
||||||
def _run(self, monkeypatch, *, fetch, apply_):
|
def _run(self, monkeypatch, *, fetch, apply_, channels=("UC1", "UC2")):
|
||||||
monkeypatch.setattr(runner, "fetch_channel_feed", fetch)
|
monkeypatch.setattr(runner, "fetch_channel_feed", fetch)
|
||||||
monkeypatch.setattr(runner, "apply_rss_feed", apply_)
|
monkeypatch.setattr(runner, "apply_rss_feed", apply_)
|
||||||
return runner.run_rss_poll(_StubDb(), [_StubChannel("UC1"), _StubChannel("UC2")])
|
return runner.run_rss_poll(_StubDb(), [_StubChannel(c) for c in channels])
|
||||||
|
|
||||||
def test_a_healthy_run_reports_no_failures(self, monkeypatch):
|
def test_a_healthy_run_reports_no_failures(self, monkeypatch):
|
||||||
out = self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=lambda db, ch, e: 1)
|
out = self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=lambda db, ch, e: 1)
|
||||||
assert out == {"new": 2, "failed": 0}
|
assert out == {"new": 2, "failed": 0}
|
||||||
|
|
||||||
def test_unreadable_feeds_are_counted(self, monkeypatch):
|
def test_unreadable_feeds_are_counted(self, monkeypatch):
|
||||||
def boom(cid):
|
# One dead channel among healthy ones: counted, but the run itself is still a run.
|
||||||
raise rss.RssError("404")
|
def fetch(cid):
|
||||||
|
if cid == "UC_dead":
|
||||||
|
raise rss.RssError("404")
|
||||||
|
return [{"id": "v"}]
|
||||||
|
|
||||||
out = self._run(monkeypatch, fetch=boom, apply_=lambda db, ch, e: 1)
|
out = self._run(
|
||||||
assert out == {"new": 0, "failed": 2}
|
monkeypatch, fetch=fetch, apply_=lambda db, ch, e: 1, channels=("UC1", "UC_dead")
|
||||||
|
)
|
||||||
|
assert out == {"new": 1, "failed": 1}
|
||||||
|
|
||||||
def test_apply_failures_are_counted_too(self, monkeypatch):
|
def test_apply_failures_are_counted_too(self, monkeypatch):
|
||||||
# A read-only database: every feed reads fine, nothing can be stored.
|
# A per-row write failure (a constraint clash on one channel) while the rest store fine.
|
||||||
def boom(db, ch, entries):
|
def apply_(db, ch, entries):
|
||||||
raise RuntimeError("read-only transaction")
|
if ch.id == "UC_bad":
|
||||||
|
raise RuntimeError("constraint")
|
||||||
|
return 1
|
||||||
|
|
||||||
out = self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=boom)
|
out = self._run(
|
||||||
assert out == {"new": 0, "failed": 2}, "a run where nothing was stored must not read as ok"
|
monkeypatch,
|
||||||
|
fetch=lambda cid: [{"id": "v"}],
|
||||||
|
apply_=apply_,
|
||||||
|
channels=("UC1", "UC_bad"),
|
||||||
|
)
|
||||||
|
assert out == {"new": 1, "failed": 1}, "an unstored channel must not read as ok"
|
||||||
|
|
||||||
def test_a_quiet_hour_is_distinguishable_from_an_outage(self, monkeypatch):
|
def test_a_quiet_hour_is_distinguishable_from_an_outage(self, monkeypatch):
|
||||||
quiet = self._run(monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0)
|
quiet = self._run(monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0)
|
||||||
assert quiet == {"new": 0, "failed": 0} # same "new", different "failed" — that's the point
|
assert quiet == {"new": 0, "failed": 0} # same "new", different "failed" — that's the point
|
||||||
|
|
||||||
|
def test_a_total_outage_raises_instead_of_returning_ok(self, monkeypatch):
|
||||||
|
# Every feed unreachable (the tinyproxy-lost-the-boot-race incident). Returning a result
|
||||||
|
# here recorded the scheduler run as "ok", with the outage only visible inside the summary
|
||||||
|
# text — a green pill for a run that did nothing at all.
|
||||||
|
def boom(cid):
|
||||||
|
raise rss.RssError("Connection refused")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="all 2 channels"):
|
||||||
|
self._run(monkeypatch, fetch=boom, apply_=lambda db, ch, e: 1)
|
||||||
|
|
||||||
|
def test_a_read_only_database_is_a_total_outage_too(self, monkeypatch):
|
||||||
|
# Every feed reads fine, nothing can be stored: just as un-polled, just as red.
|
||||||
|
def boom(db, ch, entries):
|
||||||
|
raise RuntimeError("read-only transaction")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="all 2 channels"):
|
||||||
|
self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=boom)
|
||||||
|
|
||||||
|
def test_no_channels_at_all_is_not_an_outage(self, monkeypatch):
|
||||||
|
# A fresh instance with nothing subscribed must not report an error every poll.
|
||||||
|
out = self._run(
|
||||||
|
monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0, channels=()
|
||||||
|
)
|
||||||
|
assert out == {"new": 0, "failed": 0}
|
||||||
|
|
||||||
|
|
||||||
class TestRssErrorVsEmpty:
|
class TestRssErrorVsEmpty:
|
||||||
"""A failed poll must RAISE (so the caller leaves `last_rss_at` alone); only a genuinely
|
"""A failed poll must RAISE (so the caller leaves `last_rss_at` alone); only a genuinely
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Minimize2,
|
||||||
Repeat,
|
Repeat,
|
||||||
Repeat1,
|
Repeat1,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -56,6 +57,18 @@ type LoopMode = "off" | "one" | "all";
|
|||||||
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
||||||
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
||||||
|
|
||||||
|
/** The element currently holding the browser's fullscreen lock, WebKit included.
|
||||||
|
*
|
||||||
|
* Safari/iPadOS still ship only the prefixed API: no `fullscreenchange`, no `fullscreenElement`.
|
||||||
|
* We never request fullscreen ourselves any more (F maximises via CSS), but YouTube's own button
|
||||||
|
* inside the embed does — and on WebKit that arrives as `webkitfullscreenchange`, so an unprefixed
|
||||||
|
* -only reader thinks the page never entered fullscreen and skips both the overlay yield and the
|
||||||
|
* focus reclaim. */
|
||||||
|
function fullscreenEl(): Element | null {
|
||||||
|
const d = document as Document & { webkitFullscreenElement?: Element | null };
|
||||||
|
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)?
|
/** 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
|
* 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
|
* must not eat the only keyboard way to read a long description. Stops at `<body>`: the page
|
||||||
@@ -468,7 +481,7 @@ export default function PlayerModal({
|
|||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||||
if (document.fullscreenElement) return;
|
if (fullscreenEl()) return;
|
||||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||||
if (maximizedRef.current) {
|
if (maximizedRef.current) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -500,10 +513,14 @@ export default function PlayerModal({
|
|||||||
// fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls
|
// fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls
|
||||||
// (a long description) and Up/Down is how you scroll it from the keyboard — so defer
|
// (a long description) and Up/Down is how you scroll it from the keyboard — so defer
|
||||||
// whenever the focused element can still scroll that way, exactly like Space defers to a
|
// whenever the focused element can still scroll that way, exactly like Space defers to a
|
||||||
// focused button. NOT in fullscreen though: the card is off-screen there, so deferring
|
// focused button. NOT while the video fills the screen though — in fullscreen OR in our own
|
||||||
// would scroll something invisible and leave fullscreen with no volume control at all.
|
// maximise — because the card is behind/off-screen there: deferring would scroll something
|
||||||
|
// invisible and leave the viewer with no volume control at all. Both flags, not just
|
||||||
|
// fullscreen: since F stopped taking the browser's lock, maximise is the common case and
|
||||||
|
// checking only `isFullscreen` made the keys dead exactly where they matter most.
|
||||||
const up = e.key === "ArrowUp";
|
const up = e.key === "ArrowUp";
|
||||||
if (typing || (!isFullscreenRef.current && canScrollBy(el, up ? -1 : 1))) return;
|
const covered = isFullscreenRef.current || maximizedRef.current;
|
||||||
|
if (typing || (!covered && canScrollBy(el, up ? -1 : 1))) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
nudgeVolume(up ? 5 : -5);
|
nudgeVolume(up ? 5 : -5);
|
||||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||||
@@ -559,7 +576,7 @@ export default function PlayerModal({
|
|||||||
// this is YouTube's own fullscreen — entered from its control bar, left by its button or Esc.
|
// this is YouTube's own fullscreen — entered from its control bar, left by its button or Esc.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChange = () => {
|
const onChange = () => {
|
||||||
const fsEl = document.fullscreenElement;
|
const fsEl = fullscreenEl();
|
||||||
// Refs only — this listener is bound once, so it must not close over render values.
|
// Refs only — this listener is bound once, so it must not close over render values.
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
||||||
@@ -573,8 +590,14 @@ export default function PlayerModal({
|
|||||||
// way out. Taking focus back here restores F/Space/arrows the moment fullscreen ends.
|
// way out. Taking focus back here restores F/Space/arrows the moment fullscreen ends.
|
||||||
if (!fsEl) focusModal();
|
if (!fsEl) focusModal();
|
||||||
};
|
};
|
||||||
|
// Both spellings: WebKit (Safari/iPadOS) fires only the prefixed one, and a browser that
|
||||||
|
// supports both never fires both for the same transition.
|
||||||
document.addEventListener("fullscreenchange", onChange);
|
document.addEventListener("fullscreenchange", onChange);
|
||||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
document.addEventListener("webkitfullscreenchange", onChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("fullscreenchange", onChange);
|
||||||
|
document.removeEventListener("webkitfullscreenchange", onChange);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
|
// Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
|
||||||
@@ -824,6 +847,28 @@ export default function PlayerModal({
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* The only way OUT of the maximised view that survives a click on the video. Reaching
|
||||||
|
YouTube's controls means clicking into the cross-origin iframe, which takes keyboard
|
||||||
|
focus with it — our window-level F/Esc then never fire, and while the stage fills the
|
||||||
|
viewport the re-arm paths (stage mouseleave, window focus) need the pointer to leave
|
||||||
|
the browser entirely. So the exit lives on screen, in OUR DOM: clicking it both
|
||||||
|
un-maximises and pulls focus back, which re-arms every shortcut. */}
|
||||||
|
{maximized && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMaximized(false);
|
||||||
|
setNativeControls(false);
|
||||||
|
focusModal();
|
||||||
|
}}
|
||||||
|
title={t("player.exitMaximize")}
|
||||||
|
aria-label={t("player.exitMaximize")}
|
||||||
|
className="absolute top-3 right-3 z-menu inline-flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm transition hover:bg-black/80 hover:text-white"
|
||||||
|
>
|
||||||
|
<Minimize2 className="h-3.5 w-3.5" />
|
||||||
|
{t("player.exitMaximize")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
|
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
|
||||||
{playerError == null && nativeControls && (
|
{playerError == null && nativeControls && (
|
||||||
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
|
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.",
|
"unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.",
|
||||||
"embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.",
|
"embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.",
|
||||||
"openOnYoutube": "Open on YouTube",
|
"openOnYoutube": "Open on YouTube",
|
||||||
"shortcutsHint": "Click: play/pause · Scroll or ↑ ↓: volume · F: fullscreen",
|
"shortcutsHint": "Click: play/pause · Scroll or ↑ ↓: volume · F: fill the window",
|
||||||
|
"exitMaximize": "Exit full window (Esc)",
|
||||||
"nativeControls": "YouTube controls active"
|
"nativeControls": "YouTube controls active"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.",
|
"unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.",
|
||||||
"embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az auto-generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.",
|
"embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az auto-generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.",
|
||||||
"openOnYoutube": "Megnyitás YouTube-on",
|
"openOnYoutube": "Megnyitás YouTube-on",
|
||||||
"shortcutsHint": "Kattintás: lejátszás/szünet · Görgő vagy ↑ ↓: hangerő · F: teljes képernyő",
|
"shortcutsHint": "Kattintás: lejátszás/szünet · Görgő vagy ↑ ↓: hangerő · F: teljes ablak",
|
||||||
|
"exitMaximize": "Kilépés a teljes ablakból (Esc)",
|
||||||
"nativeControls": "YouTube vezérlők aktívak"
|
"nativeControls": "YouTube vezérlők aktívak"
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-3
@@ -586,16 +586,29 @@ html[data-scheme="matrix"][data-theme="light"] {
|
|||||||
fullscreen button stops working (see PlayerModal.toggleFullscreen). */
|
fullscreen button stops working (see PlayerModal.toggleFullscreen). */
|
||||||
.player-stage:fullscreen,
|
.player-stage:fullscreen,
|
||||||
.player-stage--max {
|
.player-stage--max {
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
aspect-ratio: auto;
|
aspect-ratio: auto;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A fullscreened element is already sized by the browser; the viewport units are only for the
|
||||||
|
`:fullscreen` path, where the stage is not positioned. */
|
||||||
|
.player-stage:fullscreen {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `inset: 0` alone does the sizing here — adding width/height would over-constrain the box (left +
|
||||||
|
width win, right/bottom are ignored), which is how `100vw` sneaks a horizontal overflow in next
|
||||||
|
to a classic scrollbar and `100vh` pushes the bottom edge — YouTube's own control bar — under a
|
||||||
|
mobile browser's chrome. */
|
||||||
.player-stage--max {
|
.player-stage--max {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9999; /* the tuner level — above the modal chrome it is nested in */
|
/* rail-level (40): this stage only has to beat the modal chrome it is nested inside (whose own
|
||||||
|
children top out at z-menu/20). The modal root is a z-overlay stacking context, so nothing
|
||||||
|
here escapes it — 9999 is the glass tuner's reserved level and claimed an authority this
|
||||||
|
layer neither has nor needs. */
|
||||||
|
z-index: 40;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thin, theme-aware scrollbars */
|
/* Thin, theme-aware scrollbars */
|
||||||
|
|||||||
Reference in New Issue
Block a user