Merge: watch progress (resume bar, continue/restart, in-progress filter)
This commit is contained in:
commit
59295430de
8 changed files with 240 additions and 31 deletions
44
backend/alembic/versions/0010_watch_progress.py
Normal file
44
backend/alembic/versions/0010_watch_progress.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""watch progress: resume position on video_states
|
||||||
|
|
||||||
|
Revision ID: 0010_watch_progress
|
||||||
|
Revises: 0009_quota_events
|
||||||
|
Create Date: 2026-06-14
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0010_watch_progress"
|
||||||
|
down_revision: Union[str, None] = "0009_quota_events"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"video_states",
|
||||||
|
sa.Column(
|
||||||
|
"position_seconds",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"video_states",
|
||||||
|
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
# Partial index to keep the "in progress" feed filter cheap (only started videos).
|
||||||
|
op.create_index(
|
||||||
|
"ix_video_states_in_progress",
|
||||||
|
"video_states",
|
||||||
|
["user_id"],
|
||||||
|
postgresql_where=sa.text("position_seconds > 0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_video_states_in_progress", table_name="video_states")
|
||||||
|
op.drop_column("video_states", "progress_updated_at")
|
||||||
|
op.drop_column("video_states", "position_seconds")
|
||||||
|
|
@ -250,6 +250,14 @@ class VideoState(Base):
|
||||||
# new | watched | saved | hidden
|
# new | watched | saved | hidden
|
||||||
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
||||||
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Resume position (seconds into the video) for the in-app player. Orthogonal to
|
||||||
|
# status: a partially-watched video keeps status "new" but a non-zero position, so
|
||||||
|
# it can render a progress bar and match the "in progress" feed filter. 0 = not
|
||||||
|
# started / finished (trivially-early and near-finished positions are not stored).
|
||||||
|
position_seconds: Mapped[int] = mapped_column(
|
||||||
|
Integer, default=0, server_default="0", nullable=False
|
||||||
|
)
|
||||||
|
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ router = APIRouter(prefix="/api", tags=["feed"])
|
||||||
VALID_STATES = {"new", "watched", "saved", "hidden"}
|
VALID_STATES = {"new", "watched", "saved", "hidden"}
|
||||||
HIDDEN_LIVE = ("live", "upcoming")
|
HIDDEN_LIVE = ("live", "upcoming")
|
||||||
|
|
||||||
|
# Resume-position thresholds (mirror the client): positions below this are "didn't
|
||||||
|
# really start" and within this of the end are "basically finished" — neither is worth
|
||||||
|
# storing, so we clear the position instead (the video shows no progress bar).
|
||||||
|
PROGRESS_MIN_SECONDS = 5
|
||||||
|
FINISH_MARGIN_SECONDS = 10
|
||||||
|
|
||||||
|
|
||||||
def _channel_url(channel_id: str, handle: str | None) -> str:
|
def _channel_url(channel_id: str, handle: str | None) -> str:
|
||||||
if handle and handle.startswith("@"):
|
if handle and handle.startswith("@"):
|
||||||
|
|
@ -38,6 +44,7 @@ def _serialize(row) -> dict:
|
||||||
"is_short": row.is_short,
|
"is_short": row.is_short,
|
||||||
"live_status": row.live_status,
|
"live_status": row.live_status,
|
||||||
"status": row.status or "new",
|
"status": row.status or "new",
|
||||||
|
"position_seconds": row.position_seconds or 0,
|
||||||
"watch_url": f"https://www.youtube.com/watch?v={row.id}",
|
"watch_url": f"https://www.youtube.com/watch?v={row.id}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,6 +71,7 @@ def _filtered_query(
|
||||||
Returns the column-bearing select plus the watch-status expression for sorting."""
|
Returns the column-bearing select plus the watch-status expression for sorting."""
|
||||||
state = aliased(VideoState)
|
state = aliased(VideoState)
|
||||||
status_expr = func.coalesce(state.status, "new")
|
status_expr = func.coalesce(state.status, "new")
|
||||||
|
position_expr = func.coalesce(state.position_seconds, 0)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
|
|
@ -80,6 +88,7 @@ def _filtered_query(
|
||||||
Video.is_short,
|
Video.is_short,
|
||||||
Video.live_status,
|
Video.live_status,
|
||||||
status_expr.label("status"),
|
status_expr.label("status"),
|
||||||
|
position_expr.label("position_seconds"),
|
||||||
)
|
)
|
||||||
.join(Channel, Channel.id == Video.channel_id)
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
# Only channels this user is subscribed to (and hasn't hidden).
|
# Only channels this user is subscribed to (and hasn't hidden).
|
||||||
|
|
@ -158,6 +167,11 @@ def _filtered_query(
|
||||||
|
|
||||||
if show == "unwatched":
|
if show == "unwatched":
|
||||||
query = query.where(status_expr.notin_(("watched", "hidden")))
|
query = query.where(status_expr.notin_(("watched", "hidden")))
|
||||||
|
elif show == "in_progress":
|
||||||
|
# Started but not finished: a stored resume position, not yet watched/hidden.
|
||||||
|
query = query.where(
|
||||||
|
and_(position_expr > 0, status_expr.notin_(("watched", "hidden")))
|
||||||
|
)
|
||||||
elif show == "watched":
|
elif show == "watched":
|
||||||
query = query.where(status_expr == "watched")
|
query = query.where(status_expr == "watched")
|
||||||
elif show == "saved":
|
elif show == "saved":
|
||||||
|
|
@ -279,7 +293,13 @@ def set_video_state(
|
||||||
|
|
||||||
if status == "new":
|
if status == "new":
|
||||||
if row is not None:
|
if row is not None:
|
||||||
db.delete(row)
|
# Keep the row if it still holds a resume position (un-marking "watched"
|
||||||
|
# should restore the in-progress state, not wipe where the user left off).
|
||||||
|
if row.position_seconds:
|
||||||
|
row.status = "new"
|
||||||
|
row.watched_at = None
|
||||||
|
else:
|
||||||
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"video_id": video_id, "status": "new"}
|
return {"video_id": video_id, "status": "new"}
|
||||||
|
|
||||||
|
|
@ -292,6 +312,59 @@ def set_video_state(
|
||||||
return {"video_id": video_id, "status": status}
|
return {"video_id": video_id, "status": status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/videos/{video_id}/progress")
|
||||||
|
def set_video_progress(
|
||||||
|
video_id: str,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Checkpoint the in-app player's resume position (called periodically while playing).
|
||||||
|
Stores a per-user position on the video_states row without touching watch status, so a
|
||||||
|
partially-watched video can render a progress bar and match the "in progress" filter.
|
||||||
|
Trivially-early and near-finished positions clear the position rather than store it."""
|
||||||
|
try:
|
||||||
|
position = int(payload.get("position_seconds") or 0)
|
||||||
|
duration = int(payload.get("duration_seconds") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="position_seconds must be an integer")
|
||||||
|
if position < 0:
|
||||||
|
position = 0
|
||||||
|
|
||||||
|
if db.get(Video, video_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown video")
|
||||||
|
|
||||||
|
# Decide whether this position is worth keeping (else clear it).
|
||||||
|
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
|
||||||
|
keep = position >= PROGRESS_MIN_SECONDS and not near_end
|
||||||
|
|
||||||
|
row = db.execute(
|
||||||
|
select(VideoState).where(
|
||||||
|
VideoState.user_id == user.id, VideoState.video_id == video_id
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not keep:
|
||||||
|
# Nothing meaningful to store: drop a status-less row entirely, otherwise just
|
||||||
|
# zero the position (a saved/hidden video keeps its status).
|
||||||
|
if row is not None:
|
||||||
|
if row.status == "new":
|
||||||
|
db.delete(row)
|
||||||
|
else:
|
||||||
|
row.position_seconds = 0
|
||||||
|
row.progress_updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return {"video_id": video_id, "position_seconds": 0}
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
row = VideoState(user_id=user.id, video_id=video_id)
|
||||||
|
db.add(row)
|
||||||
|
row.position_seconds = position
|
||||||
|
row.progress_updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return {"video_id": video_id, "position_seconds": position}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/videos/{video_id}")
|
@router.get("/videos/{video_id}")
|
||||||
def get_video_detail(
|
def get_video_detail(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ function matchesView(status: string, show: string): boolean {
|
||||||
case "saved":
|
case "saved":
|
||||||
return status === "saved";
|
return status === "saved";
|
||||||
case "unwatched":
|
case "unwatched":
|
||||||
|
case "in_progress":
|
||||||
|
// (in_progress is further narrowed server-side by resume position; here we only
|
||||||
|
// need to drop a card once it's optimistically marked watched/hidden.)
|
||||||
return status !== "watched" && status !== "hidden";
|
return status !== "watched" && status !== "hidden";
|
||||||
default:
|
default:
|
||||||
return status !== "hidden"; // all
|
return status !== "hidden"; // all
|
||||||
|
|
@ -36,9 +39,17 @@ export default function Feed({
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||||
const [activeVideo, setActiveVideo] = useState<Video | null>(null);
|
// The open player: which video and where to start (null = resume from saved position).
|
||||||
|
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const openVideo = useCallback(
|
||||||
|
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: ["feed", filters],
|
queryKey: ["feed", filters],
|
||||||
queryFn: ({ pageParam }) => api.feed(filters, pageParam as number, PAGE),
|
queryFn: ({ pageParam }) => api.feed(filters, pageParam as number, PAGE),
|
||||||
|
|
@ -166,7 +177,7 @@ export default function Feed({
|
||||||
view="grid"
|
view="grid"
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onChannelFilter={onChannelFilter}
|
onChannelFilter={onChannelFilter}
|
||||||
onOpen={setActiveVideo}
|
onOpen={openVideo}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -179,14 +190,15 @@ export default function Feed({
|
||||||
view="list"
|
view="list"
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onChannelFilter={onChannelFilter}
|
onChannelFilter={onChannelFilter}
|
||||||
onOpen={setActiveVideo}
|
onOpen={openVideo}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeVideo && (
|
{activeVideo && (
|
||||||
<PlayerModal
|
<PlayerModal
|
||||||
video={activeVideo}
|
video={activeVideo.video}
|
||||||
|
startAt={activeVideo.startAt}
|
||||||
onClose={() => setActiveVideo(null)}
|
onClose={() => setActiveVideo(null)}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
|
|
@ -176,31 +176,22 @@ function loadYouTubeApi(): Promise<any> {
|
||||||
return apiPromise;
|
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({
|
export default function PlayerModal({
|
||||||
video,
|
video,
|
||||||
|
startAt,
|
||||||
onClose,
|
onClose,
|
||||||
onState,
|
onState,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
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;
|
onClose: () => void;
|
||||||
onState: (id: string, status: string) => 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 mountRef = useRef<HTMLDivElement | null>(null);
|
||||||
const playerRef = useRef<any>(null);
|
const playerRef = useRef<any>(null);
|
||||||
const autoMarkedRef = useRef(false);
|
const autoMarkedRef = useRef(false);
|
||||||
|
|
@ -216,7 +207,8 @@ export default function PlayerModal({
|
||||||
const loadVideo = (id: string, start: number | null) => {
|
const loadVideo = (id: string, start: number | null) => {
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
if (!p || typeof p.loadVideoById !== "function") return;
|
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 });
|
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
|
||||||
currentIdRef.current = id;
|
currentIdRef.current = id;
|
||||||
setCurrentVideoId(id);
|
setCurrentVideoId(id);
|
||||||
|
|
@ -305,14 +297,18 @@ export default function PlayerModal({
|
||||||
setWatched(true);
|
setWatched(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const persist = () => {
|
const persist = (): Promise<unknown> | void => {
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
if (!p || typeof p.getCurrentTime !== "function") return;
|
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||||
try {
|
try {
|
||||||
const cur = p.getCurrentTime();
|
const cur = p.getCurrentTime();
|
||||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||||
maybeAutoWatch(cur, dur);
|
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 {
|
} catch {
|
||||||
/* player may be tearing down */
|
/* player may be tearing down */
|
||||||
}
|
}
|
||||||
|
|
@ -326,7 +322,7 @@ export default function PlayerModal({
|
||||||
videoId: id,
|
videoId: id,
|
||||||
playerVars: {
|
playerVars: {
|
||||||
autoplay: 1,
|
autoplay: 1,
|
||||||
start: loadPos(id) || undefined,
|
start: resumeAt || undefined,
|
||||||
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||||||
enablejsapi: 1,
|
enablejsapi: 1,
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
|
|
@ -356,7 +352,10 @@ export default function PlayerModal({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(timer);
|
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;
|
const p = playerRef.current;
|
||||||
if (p && typeof p.destroy === "function") {
|
if (p && typeof p.destroy === "function") {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ const SORTS = [
|
||||||
|
|
||||||
const SHOWS = [
|
const SHOWS = [
|
||||||
{ id: "unwatched", label: "Unwatched" },
|
{ id: "unwatched", label: "Unwatched" },
|
||||||
|
{ id: "in_progress", label: "In progress" },
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All" },
|
||||||
{ id: "watched", label: "Watched" },
|
{ id: "watched", label: "Watched" },
|
||||||
{ id: "saved", label: "Saved" },
|
{ id: "saved", label: "Saved" },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Bookmark, Check, CheckCheck, Eye, EyeOff, ListFilter } from "lucide-react";
|
import {
|
||||||
|
Bookmark,
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
ListFilter,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
} from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
|
|
@ -81,7 +90,7 @@ function Actions({
|
||||||
function openInApp(
|
function openInApp(
|
||||||
e: React.MouseEvent,
|
e: React.MouseEvent,
|
||||||
video: Video,
|
video: Video,
|
||||||
onOpen?: (v: Video) => void
|
onOpen?: (v: Video, startAt?: number | null) => void
|
||||||
): void {
|
): void {
|
||||||
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -95,8 +104,23 @@ function Thumb({
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
className?: string;
|
className?: string;
|
||||||
onOpen?: (v: Video) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// A non-zero saved position on an unfinished video = "in progress": show a resume
|
||||||
|
// progress bar and offer Continue / Restart instead of a plain Play.
|
||||||
|
const inProgress = video.position_seconds > 0 && video.status !== "watched";
|
||||||
|
const pct =
|
||||||
|
inProgress && video.duration_seconds
|
||||||
|
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Open in the in-app player at an explicit position (null = resume from saved).
|
||||||
|
const open = (startAt: number | null) => (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpen?.(video, startAt);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={video.watch_url}
|
href={video.watch_url}
|
||||||
|
|
@ -133,6 +157,45 @@ function Thumb({
|
||||||
<Bookmark className="w-3.5 h-3.5" />
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */}
|
||||||
|
{onOpen && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{inProgress ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={open(null)}
|
||||||
|
title="Continue where you left off"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 fill-current" />
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={open(0)}
|
||||||
|
title="Play from the beginning"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={open(null)}
|
||||||
|
title="Play"
|
||||||
|
className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition"
|
||||||
|
>
|
||||||
|
<Play className="w-5 h-5 fill-current translate-x-[1px]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
|
||||||
|
{pct > 0 && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
|
||||||
|
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +211,7 @@ function VideoCard({
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||||
onOpen?: (v: Video) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const watched = video.status === "watched";
|
const watched = video.status === "watched";
|
||||||
const meta = (
|
const meta = (
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export interface Video {
|
||||||
is_short: boolean;
|
is_short: boolean;
|
||||||
live_status: string;
|
live_status: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
position_seconds: number;
|
||||||
watch_url: string;
|
watch_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,6 +228,14 @@ export const api = {
|
||||||
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
|
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
|
||||||
setState: (id: string, status: string) =>
|
setState: (id: string, status: string) =>
|
||||||
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
|
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
|
||||||
|
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
|
||||||
|
req(`/api/videos/${id}/progress`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
position_seconds: Math.floor(positionSeconds),
|
||||||
|
duration_seconds: Math.floor(durationSeconds),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
|
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
|
||||||
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
||||||
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue