Merge: promote dev to prod

This commit is contained in:
2026-07-19 06:09:30 +02:00
10 changed files with 109 additions and 28 deletions
+1 -1
View File
@@ -1 +1 @@
0.45.0 0.45.1
+3 -3
View File
@@ -31,6 +31,7 @@ import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api"; import { api, type DownloadJob } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads"; import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format"; import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
import { inputCls } from "./ui/form"; import { inputCls } from "./ui/form";
@@ -129,10 +130,9 @@ function displayUrl(url: string): string {
function SourceRef({ url }: { url: string }) { function SourceRef({ url }: { url: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const copy = async () => { const copy = async () => {
try { if (await copyText(url)) {
await navigator.clipboard.writeText(url);
notify({ level: "success", message: t("downloads.source.copied") }); notify({ level: "success", message: t("downloads.source.copied") });
} catch { } else {
notify({ level: "error", message: t("downloads.source.copyFailed") }); notify({ level: "error", message: t("downloads.source.copyFailed") });
} }
}; };
+4 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Overlay from "./Overlay"; import Overlay from "./Overlay";
import { copyText } from "../lib/clipboard";
import { usePrefs } from "./PrefsProvider"; import { usePrefs } from "./PrefsProvider";
/** /**
@@ -205,11 +206,11 @@ export default function GlassTuner() {
return lines.length ? `${scope} {\n${lines.join("\n")}\n}` : "/* all values at defaults */"; return lines.length ? `${scope} {\n${lines.join("\n")}\n}` : "/* all values at defaults */";
}, [vars, palette, themeTick]); }, [vars, palette, themeTick]);
function copy() { async function copy() {
navigator.clipboard?.writeText(cssOut).then(() => { if (await copyText(cssOut)) {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1200); setTimeout(() => setCopied(false), 1200);
}); }
} }
function persistOpen(v: boolean) { function persistOpen(v: boolean) {
+1 -1
View File
@@ -601,7 +601,7 @@ export default function PlayerModal({
playlist). Each strip sits just outside the card, spans its full height, and fades out at playlist). Each strip sits just outside the card, spans its full height, and fades out at
the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens
where there's no room beside the card. */} where there's no room beside the card. */}
<div className="relative flex w-full max-w-6xl max-h-full"> <div className="relative flex w-full max-w-4xl max-h-full">
{hasQueue && ( {hasQueue && (
<button <button
onClick={(e) => { onClick={(e) => {
+20 -11
View File
@@ -957,18 +957,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
onMouseMove={wake} onMouseMove={wake}
onClick={wake} onClick={wake}
onWheel={onWheel} onWheel={onWheel}
// Everything in the player is 25% larger for lean-back / HTPC legibility. Laid out at 80vw×80vh // Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid
// and scaled ×1.25 from the top-left so it fills the viewport exactly (no overflow, controls stay // out at 80vw×80vh and scaled ×1.25 from the top-left so it fills the viewport exactly (no
// on-screen). We use `transform: scale` rather than `zoom` deliberately: under `zoom`, event // overflow, controls stay on-screen). We use `transform: scale` rather than `zoom` deliberately:
// clientX stays in CSS pixels while getBoundingClientRect returns zoomed pixels, so the seek/ // under `zoom`, event clientX stays in CSS pixels while getBoundingClientRect returns zoomed
// volume/hover math (clientX − rect.left)/rect.width breaks; with `transform` both are in the same // pixels, so the seek/volume/hover math (clientX − rect.left)/rect.width breaks; with `transform`
// viewport space, so that ratio stays correct. Skip buttons ride this too → +25% like everything. // both are in the same viewport space, so that ratio stays correct. Skip buttons ride this too.
//
// In NATIVE fullscreen we must NOT carry the ×1.25 transform: with the element at 80vw×80vh, the
// scaled layer is composited letterboxed and the video never grows past its windowed size (the UA
// does NOT force this fixed, explicitly-sized element to fill). So in `fs` we size the wrapper to
// the full screen (100vw×100vh) with NO transform — the flex box then centres the video and its
// `max-h-full`/`max-w-full` aspect-fit fills the available height. The % seek-bar left and the
// clientX/rect math stay correct under the identity transform.
style={{ style={{
cursor: uiVisible ? "default" : "none", cursor: uiVisible ? "default" : "none",
width: "80vw", ...(fs
height: "80vh", ? { width: "100vw", height: "100vh" }
transform: "scale(1.25)", : { width: "80vw", height: "80vh", transform: "scale(1.25)", transformOrigin: "top left" }),
transformOrigin: "top left",
}} }}
> >
{/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this {/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this
@@ -979,7 +985,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
)}}`}</style> )}}`}</style>
<video <video
ref={videoRef} ref={videoRef}
className="max-h-full max-w-full w-auto h-auto" // Fill the wrapper (which is sized to the viewport / screen) and letterbox by aspect via
// object-contain. NOT `w-auto h-auto max-*`: that caps the video at its intrinsic resolution
// (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
className="w-full h-full object-contain"
onClick={togglePlay} onClick={togglePlay}
playsInline playsInline
> >
+3 -3
View File
@@ -20,6 +20,7 @@ import { CSS } from "@dnd-kit/utilities";
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api"; import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
import { filtersToParams, shareUrl } from "../lib/urlState"; import { filtersToParams, shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { accountKey, LS, writeJSON } from "../lib/storage"; import { accountKey, LS, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
@@ -108,10 +109,9 @@ export default function SavedViewsWidget({
} }
async function share(v: SavedView) { async function share(v: SavedView) {
try { if (await copyText(shareUrl(asFilters(v.filters)))) {
await navigator.clipboard.writeText(shareUrl(asFilters(v.filters)));
notify({ level: "success", message: t("views.shareCopied") }); notify({ level: "success", message: t("views.shareCopied") });
} catch { } else {
notify({ level: "warning", message: t("sidebar.shareFailed") }); notify({ level: "warning", message: t("sidebar.shareFailed") });
} }
} }
+3 -3
View File
@@ -6,6 +6,7 @@ import clsx from "clsx";
import Modal from "./Modal"; import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { api, type DownloadJob, type ShareLink } from "../lib/api"; import { api, type DownloadJob, type ShareLink } from "../lib/api";
import { inputCls, btnCls } from "./ui/form"; import { inputCls, btnCls } from "./ui/form";
const EXPIRY_DAYS = [null, 1, 7, 30] as const; const EXPIRY_DAYS = [null, 1, 7, 30] as const;
@@ -87,11 +88,10 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
const fullUrl = window.location.origin + link.url; const fullUrl = window.location.origin + link.url;
const copy = async () => { const copy = async () => {
try { if (await copyText(fullUrl)) {
await navigator.clipboard.writeText(fullUrl);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
} catch { } else {
notify({ level: "error", message: t("downloads.share.copyFailed") }); notify({ level: "error", message: t("downloads.share.copyFailed") });
} }
}; };
+3 -3
View File
@@ -5,6 +5,7 @@ import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api"; import { api, type FeedFilters, type Tag } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { shareUrl } from "../lib/urlState"; import { shareUrl } from "../lib/urlState";
import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
@@ -125,10 +126,9 @@ export default function Sidebar({
const active = activeCount > 0; const active = activeCount > 0;
async function shareView() { async function shareView() {
try { if (await copyText(shareUrl(filters))) {
await navigator.clipboard.writeText(shareUrl(filters));
notify({ level: "success", message: t("sidebar.shareCopied") }); notify({ level: "success", message: t("sidebar.shareCopied") });
} catch { } else {
notify({ level: "warning", message: t("sidebar.shareFailed") }); notify({ level: "warning", message: t("sidebar.shareFailed") });
} }
} }
+61
View File
@@ -0,0 +1,61 @@
// Copy text to the clipboard, robust across iOS Safari and non-secure origins.
//
// The synchronous `execCommand("copy")` path (a hidden, selected textarea) is the
// battle-tested, user-gesture-safe primitive: it works on iOS Safari and every
// desktop browser. We run it FIRST, inside the click gesture, before any `await`
// can spend the page's transient user activation — awaiting a rejected async
// `navigator.clipboard.writeText` first is exactly what makes the fallback fail on
// iOS. Only if the sync path fails (some sandboxed / permission-locked contexts
// disable execCommand) do we fall back to the async Clipboard API. Returns true on
// success so callers can toast.
export async function copyText(text: string): Promise<boolean> {
if (legacyCopy(text)) return true;
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// both paths failed
}
}
return false;
}
function legacyCopy(text: string): boolean {
const active = document.activeElement as HTMLElement | null;
// Preserve any text the user had selected on the page — focusing our textarea
// collapses it, so snapshot the range and restore it afterwards.
const sel = document.getSelection();
const savedRange = sel && sel.rangeCount ? sel.getRangeAt(0) : null;
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", ""); // suppress the iOS keyboard
// Hidden but still selectable: iOS won't copy from `display:none`/`opacity:0`, so
// clip it out of view while keeping a live, user-selectable node.
ta.style.position = "fixed";
ta.style.top = "0";
ta.style.left = "0";
ta.style.width = "1px";
ta.style.height = "1px";
ta.style.padding = "0";
ta.style.border = "none";
ta.style.clip = "rect(0, 0, 0, 0)";
ta.style.userSelect = "text";
ta.style.setProperty("-webkit-user-select", "text");
document.body.appendChild(ta);
try {
ta.focus();
ta.select();
ta.setSelectionRange(0, text.length); // iOS needs an explicit range
return document.execCommand("copy");
} catch {
return false;
} finally {
document.body.removeChild(ta);
active?.focus?.(); // restore focus we borrowed for the copy
if (savedRange) {
sel!.removeAllRanges();
sel!.addRange(savedRange);
}
}
}
+10
View File
@@ -14,6 +14,16 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.45.1",
date: "2026-07-19",
summary: "A few player and sharing fixes.",
fixes: [
"Plex: the video now fills the whole screen in fullscreen. It used to stay at its windowed size, small and centred with black bars around it.",
"Copying a share link now works reliably, including on iPad and Safari.",
"The pop-out YouTube player is back to its previous, more compact size.",
],
},
{ {
version: "0.45.0", version: "0.45.0",
date: "2026-07-19", date: "2026-07-19",