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 { 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") });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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") });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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") });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[] = [
|
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user