Merge: promote dev to prod
This commit is contained in:
@@ -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") });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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") });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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") });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user