fix: share, upload, crop, auto-skip, tag chips and See all hold what they claim
- Share: a link that expired is never copied (the dialog and one-click Share pick a live one, else make one), says it expired, and the copy-on-open claim clears on the next copy. - A password-protected file link asks to open a file, not to watch a video. - Upload: Cancel, the close button or the backdrop while naming a folder makes no folder; a failed refetch keeps the target; a folder being made says so. - Crop: an infinite number is dropped, not a 500; an unprobed clip's size follows a shown-size crop; the edit_spec docs name frame_w/frame_h; the join and rotated paths are tested. - Auto-skip: the wait gives way on a marker too short for it, so the skip still fires. - Tag chips count the followed channels (subscribed_count) and a toggled chip keeps the focus. - Continue watching's See all sorts by last watched when in progress was already on.
This commit is contained in:
@@ -7,7 +7,11 @@ export interface Tag {
|
||||
color: string | null;
|
||||
category: string;
|
||||
system: boolean;
|
||||
/** Every channel link it has, a channel the user no longer follows included (an unsubscribe keeps
|
||||
* the link) — what deleting the tag removes. */
|
||||
channel_count: number;
|
||||
/** The user's own links on channels they follow: the rows the Channels page's filter shows. */
|
||||
subscribed_count: number;
|
||||
}
|
||||
|
||||
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern.
|
||||
|
||||
@@ -94,6 +94,22 @@ describe("quickShareLink", () => {
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// An expired link stays listed for its owner, but its recipient would land on "link gone".
|
||||
it("passes over an expired link, and mints one when every link has expired", async () => {
|
||||
const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString();
|
||||
const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString();
|
||||
downloadLinks.mockResolvedValue([
|
||||
{ id: 5, url: "/watch/gone", expires_at: lastWeek },
|
||||
{ id: 3, url: "/watch/live", expires_at: nextWeek },
|
||||
]);
|
||||
expect(await quickShareLink(qc(), { kind: "file", id: 1 })).toBe(`${origin}/watch/live`);
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
|
||||
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/gone", expires_at: lastWeek }]);
|
||||
createDownloadLink.mockResolvedValue({ id: 9, url: "/watch/fresh" });
|
||||
expect(await quickShareLink(qc(), { kind: "file", id: 2 })).toBe(`${origin}/watch/fresh`);
|
||||
});
|
||||
|
||||
it("mints a new link when none it has can be shown, keeping the old ones listed", async () => {
|
||||
const hidden = { id: 4, url: null };
|
||||
downloadLinks.mockResolvedValue([hidden]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api, type NewShareLink } from "./api";
|
||||
import { api, type NewShareLink, type ShareLink } from "./api";
|
||||
import { copyText } from "./clipboard";
|
||||
import { msg, notify } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
@@ -38,6 +38,16 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
|
||||
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
|
||||
}
|
||||
|
||||
/** Has a link stopped opening? The list still holds an expired link (its owner may want to see or
|
||||
* revoke it), but it is no link to hand out. */
|
||||
export const linkExpired = (link: ShareLink, now = Date.now()) =>
|
||||
link.expires_at !== null && Date.parse(link.expires_at) <= now;
|
||||
|
||||
/** The link to hand out of a list (newest first): the newest that still opens and whose address can
|
||||
* be shown — an expired one sends the recipient to "link gone", one without an address is no use
|
||||
* to copy. None: make one. */
|
||||
export const liveLink = (links: ShareLink[]) => links.find((l) => l.url && !linkExpired(l));
|
||||
|
||||
// One-click share: the row's Share button turns "share this" into a single action.
|
||||
// Ensure the file or folder has a public link (minting one on demand — omitting `allow_download` so
|
||||
// the backend default makes it downloadable), copy it to the clipboard, and toast with an "open in
|
||||
@@ -51,8 +61,7 @@ export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promi
|
||||
queryKey: linksKey(target),
|
||||
queryFn: () => fetchLinks(target),
|
||||
});
|
||||
// The newest link whose address can still be shown; one that cannot is no use to copy.
|
||||
path = existing.find((l) => l.url)?.url;
|
||||
path = liveLink(existing)?.url;
|
||||
if (!path) {
|
||||
const made = await makeLink(target, {}); // omit allow_download → backend default (true)
|
||||
qc.setQueryData(linksKey(target), [made, ...existing]);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// An auto-skip's countdown starts below zero: the wait before it, while the Skip button already
|
||||
// shows. The bar it draws must read empty through the wait, not a negative width.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { skipCountdown } from "./plexPlayerHooks";
|
||||
|
||||
describe("skipCountdown", () => {
|
||||
it("stays empty through the wait, runs over the delay, and stops at full", () => {
|
||||
expect(skipCountdown(-3, 5)).toBe(0);
|
||||
expect(skipCountdown(-0.1, 5)).toBe(0);
|
||||
expect(skipCountdown(2.5, 5)).toBe(0.5);
|
||||
expect(skipCountdown(7, 5)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -434,8 +434,18 @@ export function usePlayerVolume(opts: {
|
||||
return { setVol, nudgeVolume, toggleMute, onWheel };
|
||||
}
|
||||
|
||||
// How long a marker plays before an auto-skip's countdown starts (seconds).
|
||||
// How long a marker plays before an auto-skip's countdown starts (seconds), at most: on a marker
|
||||
// too short for both, the wait gives way, so the countdown still ends inside it (`useAutoSkip`).
|
||||
const AUTO_SKIP_WAIT_S = 3;
|
||||
// What the countdown keeps in hand before the marker's end: it ticks on wall time, the playhead on
|
||||
// the media's, and the marker stops counting a second before its end.
|
||||
const AUTO_SKIP_SLACK_S = 0.5;
|
||||
|
||||
/** How far an auto-skip's countdown has run (0→1) after `elapsed` playing seconds of it. Through
|
||||
* the wait `elapsed` is below zero, and the bar it draws stays empty: a negative width is invalid
|
||||
* CSS, which the browser drops and keeps whatever it drew before. */
|
||||
export const skipCountdown = (elapsed: number, delay: number) =>
|
||||
Math.max(0, Math.min(1, elapsed / delay));
|
||||
|
||||
// Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress
|
||||
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — to the marker end, or
|
||||
@@ -484,6 +494,7 @@ export function useAutoSkip(opts: {
|
||||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||
);
|
||||
const activeMarkerRef = useLatest(activeMarker);
|
||||
const absRef = useLatest(abs);
|
||||
// A boolean, not the list, goes into the countdown's deps: a refetch hands back an equal-but-new
|
||||
// list and must not restart a countdown under way (see below); doSkip reads the list from a ref.
|
||||
const activeAuto = activeMarker != null && markerSkip(activeMarker, markers ?? []).auto;
|
||||
@@ -543,14 +554,17 @@ export function useAutoSkip(opts: {
|
||||
if (autoSkipDelay <= 0) return doSkip(m); // immediate
|
||||
// Below zero is the wait: time to see where the marker has landed and press the button, which
|
||||
// shows from the start, before anything counts down to leaving. A delay of 0 asked for none.
|
||||
let elapsed = -AUTO_SKIP_WAIT_S;
|
||||
// It is cut to what the marker has left over the countdown (a short intro, or one resumed near
|
||||
// its end): a wait the countdown cannot finish after is an auto-skip that never fires.
|
||||
const left = m.end_s - 1 - absRef.current - autoSkipDelay - AUTO_SKIP_SLACK_S;
|
||||
let elapsed = -Math.max(0, Math.min(AUTO_SKIP_WAIT_S, left));
|
||||
setSkipProgress(0);
|
||||
const iv = window.setInterval(() => {
|
||||
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
|
||||
// deliberately-paused video. It resumes from where it stopped when playback resumes.
|
||||
if (videoRef.current?.paused) return;
|
||||
elapsed += 0.1;
|
||||
const p = Math.max(0, Math.min(1, elapsed / autoSkipDelay));
|
||||
const p = skipCountdown(elapsed, autoSkipDelay);
|
||||
setSkipProgress(p);
|
||||
if (p >= 1) {
|
||||
window.clearInterval(iv);
|
||||
@@ -573,6 +587,7 @@ export function useAutoSkip(opts: {
|
||||
doSkip,
|
||||
videoRef,
|
||||
activeMarkerRef,
|
||||
absRef,
|
||||
activeAuto,
|
||||
]);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const tag = (id: number, name: string, system: boolean): Tag => ({
|
||||
color: null,
|
||||
category: "topic",
|
||||
channel_count: 1,
|
||||
subscribed_count: 1,
|
||||
});
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
|
||||
Reference in New Issue
Block a user