feat(feed): resume progress bar, play/continue/restart, in-progress filter
Video cards show a resume progress bar for started-but-unfinished videos and a hover overlay: Play on every card, Continue + Restart on in-progress ones. The in-app player now resumes from (and checkpoints to) the server position instead of localStorage, accepts an explicit startAt (Restart -> 0), and refreshes the feed on close so the card bar reflects the session. Sidebar gains an 'In progress' show filter.
This commit is contained in:
parent
686c40cbb9
commit
04c971f623
5 changed files with 114 additions and 30 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import { api, type Video } from "../lib/api";
|
||||
|
|
@ -176,31 +176,22 @@ function loadYouTubeApi(): Promise<any> {
|
|||
return apiPromise;
|
||||
}
|
||||
|
||||
// --- Per-video resume position, persisted in localStorage so it survives a refresh ---
|
||||
const posKey = (id: string) => `subfeed:player-pos:${id}`;
|
||||
function savePos(id: string, seconds: number, duration: number): void {
|
||||
// Don't store trivially-early or near-finished positions (start fresh next time).
|
||||
if (!Number.isFinite(seconds) || seconds < 5 || (duration > 0 && seconds > duration - FINISH_MARGIN)) {
|
||||
localStorage.removeItem(posKey(id));
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(posKey(id), String(Math.floor(seconds)));
|
||||
}
|
||||
function loadPos(id: string): number {
|
||||
const v = localStorage.getItem(posKey(id));
|
||||
const n = v ? parseInt(v, 10) : 0;
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
}
|
||||
|
||||
export default function PlayerModal({
|
||||
video,
|
||||
startAt,
|
||||
onClose,
|
||||
onState,
|
||||
}: {
|
||||
video: Video;
|
||||
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
|
||||
// to resume from the server-saved position carried on the video.
|
||||
startAt?: number | null;
|
||||
onClose: () => void;
|
||||
onState: (id: string, status: string) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
// Resume point for the video we opened with.
|
||||
const resumeAt = startAt != null ? startAt : video.position_seconds || 0;
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const playerRef = useRef<any>(null);
|
||||
const autoMarkedRef = useRef(false);
|
||||
|
|
@ -216,7 +207,8 @@ export default function PlayerModal({
|
|||
const loadVideo = (id: string, start: number | null) => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.loadVideoById !== "function") return;
|
||||
const startSeconds = start != null ? start : loadPos(id);
|
||||
// Explicit start wins; otherwise resume the opened video, start others at 0.
|
||||
const startSeconds = start != null ? start : id === video.id ? resumeAt : 0;
|
||||
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
|
||||
currentIdRef.current = id;
|
||||
setCurrentVideoId(id);
|
||||
|
|
@ -305,14 +297,18 @@ export default function PlayerModal({
|
|||
setWatched(true);
|
||||
}
|
||||
};
|
||||
const persist = () => {
|
||||
const persist = (): Promise<unknown> | void => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||
try {
|
||||
const cur = p.getCurrentTime();
|
||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||
maybeAutoWatch(cur, dur);
|
||||
savePos(currentIdRef.current, cur, dur); // key progress by what's actually playing
|
||||
// Only checkpoint the feed video we opened with — a navigated-to video may not
|
||||
// be in this user's library, so the server would 404 on it.
|
||||
if (currentIdRef.current === id) {
|
||||
return api.saveProgress(id, cur, dur).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
/* player may be tearing down */
|
||||
}
|
||||
|
|
@ -326,7 +322,7 @@ export default function PlayerModal({
|
|||
videoId: id,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
start: loadPos(id) || undefined,
|
||||
start: resumeAt || undefined,
|
||||
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||||
enablejsapi: 1,
|
||||
origin: window.location.origin,
|
||||
|
|
@ -356,7 +352,10 @@ export default function PlayerModal({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
persist();
|
||||
// Final flush, then refresh the feed so the card's resume bar reflects this session.
|
||||
Promise.resolve(persist()).finally(() =>
|
||||
qc.invalidateQueries({ queryKey: ["feed"] })
|
||||
);
|
||||
const p = playerRef.current;
|
||||
if (p && typeof p.destroy === "function") {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue