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 { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
import { inputCls } from "./ui/form";
@@ -129,10 +130,9 @@ function displayUrl(url: string): string {
function SourceRef({ url }: { url: string }) {
const { t } = useTranslation();
const copy = async () => {
try {
await navigator.clipboard.writeText(url);
if (await copyText(url)) {
notify({ level: "success", message: t("downloads.source.copied") });
} catch {
} else {
notify({ level: "error", message: t("downloads.source.copyFailed") });
}
};
+4 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import Overlay from "./Overlay";
import { copyText } from "../lib/clipboard";
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 */";
}, [vars, palette, themeTick]);
function copy() {
navigator.clipboard?.writeText(cssOut).then(() => {
async function copy() {
if (await copyText(cssOut)) {
setCopied(true);
setTimeout(() => setCopied(false), 1200);
});
}
}
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
the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens
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 && (
<button
onClick={(e) => {
+20 -11
View File
@@ -957,18 +957,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
onMouseMove={wake}
onClick={wake}
onWheel={onWheel}
// Everything in the player is 25% larger for lean-back / HTPC legibility. Laid out at 80vw×80vh
// and scaled ×1.25 from the top-left so it fills the viewport exactly (no overflow, controls stay
// on-screen). We use `transform: scale` rather than `zoom` deliberately: under `zoom`, event
// clientX stays in CSS pixels while getBoundingClientRect returns zoomed pixels, so the seek/
// volume/hover math (clientX rect.left)/rect.width breaks; with `transform` both are in the same
// viewport space, so that ratio stays correct. Skip buttons ride this too → +25% like everything.
// Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid
// out at 80vw×80vh and scaled ×1.25 from the top-left so it fills the viewport exactly (no
// overflow, controls stay on-screen). We use `transform: scale` rather than `zoom` deliberately:
// under `zoom`, event clientX stays in CSS pixels while getBoundingClientRect returns zoomed
// pixels, so the seek/volume/hover math (clientX rect.left)/rect.width breaks; with `transform`
// 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={{
cursor: uiVisible ? "default" : "none",
width: "80vw",
height: "80vh",
transform: "scale(1.25)",
transformOrigin: "top left",
...(fs
? { width: "100vw", height: "100vh" }
: { width: "80vw", height: "80vh", transform: "scale(1.25)", transformOrigin: "top left" }),
}}
>
{/* 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>
<video
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}
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 { filtersToParams, shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { accountKey, LS, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
@@ -108,10 +109,9 @@ export default function SavedViewsWidget({
}
async function share(v: SavedView) {
try {
await navigator.clipboard.writeText(shareUrl(asFilters(v.filters)));
if (await copyText(shareUrl(asFilters(v.filters)))) {
notify({ level: "success", message: t("views.shareCopied") });
} catch {
} else {
notify({ level: "warning", message: t("sidebar.shareFailed") });
}
}
+3 -3
View File
@@ -6,6 +6,7 @@ import clsx from "clsx";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { api, type DownloadJob, type ShareLink } from "../lib/api";
import { inputCls, btnCls } from "./ui/form";
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 copy = async () => {
try {
await navigator.clipboard.writeText(fullUrl);
if (await copyText(fullUrl)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
} else {
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 { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { shareUrl } from "../lib/urlState";
import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
@@ -125,10 +126,9 @@ export default function Sidebar({
const active = activeCount > 0;
async function shareView() {
try {
await navigator.clipboard.writeText(shareUrl(filters));
if (await copyText(shareUrl(filters))) {
notify({ level: "success", message: t("sidebar.shareCopied") });
} catch {
} else {
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[] = [
{
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",
date: "2026-07-19",