Merge inline modal YouTube player
In-app modal player (YouTube IFrame API) on card click as an alternative to opening youtube.com in a new tab: resume position, watched controls + auto-watch, a lazily-fetched description popover with linkified timestamps/emails/hashtags/ links, and YouTube links that play inline. Also: a 'watched' notification with an Unwatch action, and a feed override fix so reverting status re-shows the video. Built on branch experiment/inline-player, reshaped into logical commits on feat/inline-player.
This commit is contained in:
commit
c5124841b7
7 changed files with 687 additions and 16 deletions
|
|
@ -4,9 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import Select, and_, false, func, or_, select
|
from sqlalchemy import Select, and_, false, func, or_, select
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app import quota
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState
|
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState
|
||||||
|
from app.sync.videos import parse_iso8601_duration
|
||||||
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["feed"])
|
router = APIRouter(prefix="/api", tags=["feed"])
|
||||||
|
|
||||||
|
|
@ -287,3 +290,57 @@ def set_video_state(
|
||||||
row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at
|
row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"video_id": video_id, "status": status}
|
return {"video_id": video_id, "status": status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/videos/{video_id}")
|
||||||
|
def get_video_detail(
|
||||||
|
video_id: str,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""On-demand detail (description, like count) — kept out of the feed list payload
|
||||||
|
so the feed stays lean; fetched lazily, e.g. for the title hover popover.
|
||||||
|
|
||||||
|
Videos we already store are served from the DB for free. A video that isn't in
|
||||||
|
our DB (e.g. a YouTube link inside another video's description that the in-app
|
||||||
|
player navigated to) is resolved via the YouTube API (videos.list, 1 unit,
|
||||||
|
attributed to the requesting user)."""
|
||||||
|
v = db.get(Video, video_id)
|
||||||
|
if v is not None:
|
||||||
|
return {
|
||||||
|
"id": v.id,
|
||||||
|
"description": v.description,
|
||||||
|
"like_count": v.like_count,
|
||||||
|
"in_db": True,
|
||||||
|
"channel_id": v.channel_id,
|
||||||
|
"channel_title": v.channel.title if v.channel else None,
|
||||||
|
"published_at": v.published_at.isoformat() if v.published_at else None,
|
||||||
|
"view_count": v.view_count,
|
||||||
|
"duration_seconds": v.duration_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with quota.attribute(user.id, "video_lookup"), YouTubeClient(db, user) as yt:
|
||||||
|
items = yt.get_videos([video_id])
|
||||||
|
except YouTubeError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"YouTube lookup failed: {exc}")
|
||||||
|
if not items:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown video")
|
||||||
|
|
||||||
|
snippet = items[0].get("snippet", {})
|
||||||
|
stats = items[0].get("statistics", {})
|
||||||
|
likes = stats.get("likeCount")
|
||||||
|
views = stats.get("viewCount")
|
||||||
|
return {
|
||||||
|
"id": video_id,
|
||||||
|
"description": snippet.get("description"),
|
||||||
|
"like_count": int(likes) if likes is not None else None,
|
||||||
|
"in_db": False,
|
||||||
|
"channel_id": snippet.get("channelId"),
|
||||||
|
"channel_title": snippet.get("channelTitle"),
|
||||||
|
"published_at": snippet.get("publishedAt"),
|
||||||
|
"view_count": int(views) if views is not None else None,
|
||||||
|
"duration_seconds": parse_iso8601_duration(
|
||||||
|
items[0].get("contentDetails", {}).get("duration")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-quer
|
||||||
import { api, type FeedFilters, type Video } from "../lib/api";
|
import { api, type FeedFilters, type Video } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard from "./VideoCard";
|
||||||
|
import PlayerModal from "./PlayerModal";
|
||||||
|
|
||||||
const PAGE = 60;
|
const PAGE = 60;
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ export default function Feed({
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
}) {
|
}) {
|
||||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||||
|
const [activeVideo, setActiveVideo] = useState<Video | null>(null);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
|
|
@ -40,7 +42,12 @@ export default function Feed({
|
||||||
getNextPageParam: (last) => (last.has_more ? last.offset + last.limit : undefined),
|
getNextPageParam: (last) => (last.has_more ? last.offset + last.limit : undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drop optimistic status overrides when filters change OR fresh server data
|
||||||
|
// arrives — after a refetch the server is authoritative, so a stale override
|
||||||
|
// (e.g. a video reverted to "new" from the notification center) won't keep it
|
||||||
|
// filtered out of the current view.
|
||||||
useEffect(() => setOverrides({}), [filters]);
|
useEffect(() => setOverrides({}), [filters]);
|
||||||
|
useEffect(() => setOverrides({}), [query.dataUpdatedAt]);
|
||||||
|
|
||||||
const countQuery = useQuery({
|
const countQuery = useQuery({
|
||||||
queryKey: ["feed-count", filters],
|
queryKey: ["feed-count", filters],
|
||||||
|
|
@ -85,6 +92,13 @@ export default function Feed({
|
||||||
channelName: v?.channel_title ?? "",
|
channelName: v?.channel_title ?? "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else if (status === "watched") {
|
||||||
|
const v = (query.data?.pages ?? []).flatMap((p) => p.items).find((x) => x.id === id);
|
||||||
|
notify({
|
||||||
|
message: v?.title ? `Marked watched “${v.title}”` : "Marked watched",
|
||||||
|
action: { label: "Unwatch", onClick: () => onState(id, "new") },
|
||||||
|
meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,6 +132,7 @@ export default function Feed({
|
||||||
view="grid"
|
view="grid"
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onChannelFilter={onChannelFilter}
|
onChannelFilter={onChannelFilter}
|
||||||
|
onOpen={setActiveVideo}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -130,10 +145,18 @@ export default function Feed({
|
||||||
view="list"
|
view="list"
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onChannelFilter={onChannelFilter}
|
onChannelFilter={onChannelFilter}
|
||||||
|
onOpen={setActiveVideo}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{activeVideo && (
|
||||||
|
<PlayerModal
|
||||||
|
video={activeVideo}
|
||||||
|
onClose={() => setActiveVideo(null)}
|
||||||
|
onState={onState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div ref={sentinel} className="h-10" />
|
<div ref={sentinel} className="h-10" />
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<div className="text-center text-muted py-4">Loading more…</div>
|
<div className="text-center text-muted py-4">Loading more…</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Bell, Eye, Search, Trash2 } from "lucide-react";
|
import { Bell, Eye, RotateCcw, Search, Trash2 } from "lucide-react";
|
||||||
import { api, type FeedFilters } from "../lib/api";
|
import { api, type FeedFilters } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
clearAll,
|
clearAll,
|
||||||
|
|
@ -10,7 +10,8 @@ import {
|
||||||
markAllRead,
|
markAllRead,
|
||||||
notify,
|
notify,
|
||||||
subscribe,
|
subscribe,
|
||||||
type NotifMeta,
|
type VideoHiddenMeta,
|
||||||
|
type VideoWatchedMeta,
|
||||||
} from "../lib/notifications";
|
} from "../lib/notifications";
|
||||||
import { LEVEL_STYLE } from "./Toaster";
|
import { LEVEL_STYLE } from "./Toaster";
|
||||||
|
|
||||||
|
|
@ -53,7 +54,7 @@ export default function NotificationCenter({
|
||||||
if (open) markAllRead();
|
if (open) markAllRead();
|
||||||
}, [open, notifications.length]);
|
}, [open, notifications.length]);
|
||||||
|
|
||||||
function locateHidden(meta: NotifMeta) {
|
function locateHidden(meta: VideoHiddenMeta) {
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
show: "hidden",
|
show: "hidden",
|
||||||
|
|
@ -63,13 +64,13 @@ export default function NotificationCenter({
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unhide(meta: NotifMeta) {
|
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta, verb: string) {
|
||||||
api
|
api
|
||||||
.setState(meta.videoId, "new")
|
.setState(meta.videoId, "new")
|
||||||
.then(() => {
|
.then(() => {
|
||||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
qc.invalidateQueries({ queryKey: ["feed-count"] });
|
qc.invalidateQueries({ queryKey: ["feed-count"] });
|
||||||
notify({ level: "success", message: `Unhidden “${meta.title}”` });
|
notify({ level: "success", message: `${verb} “${meta.title}”` });
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +114,7 @@ export default function NotificationCenter({
|
||||||
const { icon: Icon, color } = LEVEL_STYLE[n.level];
|
const { icon: Icon, color } = LEVEL_STYLE[n.level];
|
||||||
const needsAction = n.requiresInteraction && !n.dismissed;
|
const needsAction = n.requiresInteraction && !n.dismissed;
|
||||||
const hidden = n.meta?.kind === "video-hidden" ? n.meta : null;
|
const hidden = n.meta?.kind === "video-hidden" ? n.meta : null;
|
||||||
|
const watched = n.meta?.kind === "video-watched" ? n.meta : null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={n.id}
|
key={n.id}
|
||||||
|
|
@ -143,13 +145,23 @@ export default function NotificationCenter({
|
||||||
Find in feed
|
Find in feed
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => unhide(hidden)}
|
onClick={() => revertState(hidden, "Unhidden")}
|
||||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||||
>
|
>
|
||||||
<Eye className="w-3.5 h-3.5" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
Unhide
|
Unhide
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : watched ? (
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => revertState(watched, "Unwatched")}
|
||||||
|
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
Unwatch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
n.action &&
|
n.action &&
|
||||||
!n.dismissed && (
|
!n.dismissed && (
|
||||||
|
|
|
||||||
533
frontend/src/components/PlayerModal.tsx
Normal file
533
frontend/src/components/PlayerModal.tsx
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
|
||||||
|
import { api, type Video } from "../lib/api";
|
||||||
|
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
||||||
|
// Turn a description into clickable nodes:
|
||||||
|
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
|
||||||
|
// - a YouTube link to the *current* video → seek (to its t= if any)
|
||||||
|
// - a YouTube link to *another* video → load it in the inline player
|
||||||
|
// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab
|
||||||
|
const URL_RE = /(https?:\/\/[^\s<>]+)/g;
|
||||||
|
// One pass over plain text for the three inline token kinds (email | hashtag | timestamp).
|
||||||
|
const INLINE_RE =
|
||||||
|
/([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu;
|
||||||
|
|
||||||
|
function tsToSeconds(ts: string): number {
|
||||||
|
const parts = ts.split(":").map((n) => parseInt(n, 10));
|
||||||
|
return parts.length === 3
|
||||||
|
? parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||||
|
: parts[0] * 60 + parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s".
|
||||||
|
function parseStart(t: string | null): number | null {
|
||||||
|
if (!t) return null;
|
||||||
|
if (/^\d+$/.test(t)) return parseInt(t, 10) || null;
|
||||||
|
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const total =
|
||||||
|
parseInt(m[1] || "0", 10) * 3600 +
|
||||||
|
parseInt(m[2] || "0", 10) * 60 +
|
||||||
|
parseInt(m[3] || "0", 10);
|
||||||
|
return total || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract a video id (+ optional start) from a YouTube URL, else null.
|
||||||
|
function parseYouTube(url: string): { id: string; start: number | null } | null {
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const host = u.hostname.replace(/^www\./, "");
|
||||||
|
let id: string | null = null;
|
||||||
|
if (host === "youtu.be") {
|
||||||
|
id = u.pathname.slice(1) || null;
|
||||||
|
} else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
|
||||||
|
if (u.pathname === "/watch") id = u.searchParams.get("v");
|
||||||
|
else {
|
||||||
|
const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/);
|
||||||
|
if (m) id = m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!id) return null;
|
||||||
|
return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDescription(
|
||||||
|
text: string,
|
||||||
|
opts: {
|
||||||
|
currentId: string;
|
||||||
|
onSeek: (seconds: number) => void;
|
||||||
|
onLoadVideo: (id: string, start: number | null) => void;
|
||||||
|
}
|
||||||
|
): ReactNode[] {
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
const linkCls = "text-accent hover:underline break-all";
|
||||||
|
// Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely
|
||||||
|
// (they're just noise in the popover), keeping single line breaks.
|
||||||
|
const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim();
|
||||||
|
for (const chunk of clean.split(URL_RE)) {
|
||||||
|
if (/^https?:\/\//.test(chunk)) {
|
||||||
|
const href = chunk.replace(/[.,;:!?)\]]+$/, "");
|
||||||
|
const yt = parseYouTube(href);
|
||||||
|
if (yt) {
|
||||||
|
const sameVideo = yt.id === opts.currentId;
|
||||||
|
out.push(
|
||||||
|
<button
|
||||||
|
key={key++}
|
||||||
|
onClick={() =>
|
||||||
|
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
|
||||||
|
}
|
||||||
|
className={linkCls}
|
||||||
|
title={sameVideo ? "Jump to this time" : "Play in the in-app player"}
|
||||||
|
>
|
||||||
|
{href}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
out.push(
|
||||||
|
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
|
||||||
|
{href}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Plain text: pull out emails, hashtags and bare timestamps.
|
||||||
|
INLINE_RE.lastIndex = 0;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = INLINE_RE.exec(chunk))) {
|
||||||
|
if (m.index > last) out.push(<span key={key++}>{chunk.slice(last, m.index)}</span>);
|
||||||
|
if (m[1]) {
|
||||||
|
// Email — trim trailing punctuation the domain rule may have swallowed.
|
||||||
|
const email = m[1].replace(/[.,;:]+$/, "");
|
||||||
|
out.push(
|
||||||
|
<a key={key++} href={`mailto:${email}`} className={linkCls}>
|
||||||
|
{email}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
if (m[1].length > email.length) out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
|
||||||
|
} else if (m[2]) {
|
||||||
|
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
|
||||||
|
const tag = m[2].slice(1);
|
||||||
|
out.push(
|
||||||
|
<a
|
||||||
|
key={key++}
|
||||||
|
href={`https://www.youtube.com/hashtag/${encodeURIComponent(tag)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={linkCls}
|
||||||
|
>
|
||||||
|
{m[2]}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const ts = m[3];
|
||||||
|
out.push(
|
||||||
|
<button
|
||||||
|
key={key++}
|
||||||
|
onClick={() => opts.onSeek(tsToSeconds(ts))}
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{ts}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < chunk.length) out.push(<span key={key++}>{chunk.slice(last)}</span>);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Experiment (branch experiment/inline-player): play the video in-app via the
|
||||||
|
// YouTube IFrame Player API (not a bare embed) so we can read playback position
|
||||||
|
// and resume where the user left off. The modal closes via the in-card Close
|
||||||
|
// button, the backdrop, or ESC (ESC only while focus is on our page, not inside
|
||||||
|
// the cross-origin player iframe — a browser security boundary we can't cross).
|
||||||
|
|
||||||
|
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||||||
|
const FINISH_MARGIN = 10;
|
||||||
|
|
||||||
|
// --- IFrame Player API loader (singleton) ---
|
||||||
|
let apiPromise: Promise<any> | null = null;
|
||||||
|
function loadYouTubeApi(): Promise<any> {
|
||||||
|
if (apiPromise) return apiPromise;
|
||||||
|
apiPromise = new Promise((resolve) => {
|
||||||
|
const w = window as any;
|
||||||
|
if (w.YT && w.YT.Player) return resolve(w.YT);
|
||||||
|
const prev = w.onYouTubeIframeAPIReady;
|
||||||
|
w.onYouTubeIframeAPIReady = () => {
|
||||||
|
if (typeof prev === "function") prev();
|
||||||
|
resolve(w.YT);
|
||||||
|
};
|
||||||
|
const tag = document.createElement("script");
|
||||||
|
tag.src = "https://www.youtube.com/iframe_api";
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
onClose,
|
||||||
|
onState,
|
||||||
|
}: {
|
||||||
|
video: Video;
|
||||||
|
onClose: () => void;
|
||||||
|
onState: (id: string, status: string) => void;
|
||||||
|
}) {
|
||||||
|
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const playerRef = useRef<any>(null);
|
||||||
|
const autoMarkedRef = useRef(false);
|
||||||
|
|
||||||
|
// The player can navigate to other videos (YouTube links in a description). The
|
||||||
|
// currently-playing id may differ from the feed video we opened with.
|
||||||
|
const [currentVideoId, setCurrentVideoId] = useState(video.id);
|
||||||
|
const currentIdRef = useRef(video.id);
|
||||||
|
const navigated = currentVideoId !== video.id;
|
||||||
|
// Title/author of a navigated-to video, read from the player (free, no API call).
|
||||||
|
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
|
||||||
|
|
||||||
|
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);
|
||||||
|
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
|
||||||
|
currentIdRef.current = id;
|
||||||
|
setCurrentVideoId(id);
|
||||||
|
setLiveData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local mirror of watch status so the toggle reacts instantly; changes are
|
||||||
|
// propagated to the feed (and server) via onState.
|
||||||
|
const [status, setStatus] = useState(video.status);
|
||||||
|
const watched = status === "watched";
|
||||||
|
const setWatched = (on: boolean) => {
|
||||||
|
const next = on ? "watched" : "new";
|
||||||
|
setStatus(next);
|
||||||
|
onState(video.id, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekTo = (seconds: number) => {
|
||||||
|
const p = playerRef.current;
|
||||||
|
if (p && typeof p.seekTo === "function") {
|
||||||
|
p.seekTo(seconds, true);
|
||||||
|
p.playVideo?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lazy description (fetched only when the title is hovered). The popover is
|
||||||
|
// portaled to <body> with fixed positioning so the modal card's overflow-y-auto
|
||||||
|
// can't clip it. A small close grace lets the mouse travel title → popover.
|
||||||
|
const [showDesc, setShowDesc] = useState(false);
|
||||||
|
// `bottom` anchors the popover just above the title so it grows upward (over the
|
||||||
|
// player) instead of downward off-screen / behind the OS taskbar.
|
||||||
|
const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const titleRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const closeTimer = useRef<number | undefined>(undefined);
|
||||||
|
const openDesc = () => {
|
||||||
|
window.clearTimeout(closeTimer.current);
|
||||||
|
const el = titleRef.current;
|
||||||
|
if (el) {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const width = Math.min(560, window.innerWidth - 32);
|
||||||
|
const left = Math.max(16, Math.min(r.left, window.innerWidth - width - 16));
|
||||||
|
setDescRect({ left, bottom: window.innerHeight - r.top + 8, width });
|
||||||
|
}
|
||||||
|
setShowDesc(true);
|
||||||
|
};
|
||||||
|
const scheduleCloseDesc = () => {
|
||||||
|
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
|
||||||
|
};
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: ["video-detail", currentVideoId],
|
||||||
|
queryFn: () => api.videoDetail(currentVideoId),
|
||||||
|
// On hover (for the description) and eagerly when navigated, so we can link the
|
||||||
|
// linked video's channel. Both share the one cached result.
|
||||||
|
enabled: showDesc || navigated,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ESC + background scroll lock.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
window.clearTimeout(closeTimer.current);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Create the player, resume from the saved position, persist progress, and
|
||||||
|
// auto-mark watched once playback reaches the end.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const id = video.id;
|
||||||
|
|
||||||
|
// Auto-watch only applies to the feed video we opened with — not to other
|
||||||
|
// videos the player navigated to via description links.
|
||||||
|
const maybeAutoWatch = (current: number, duration: number) => {
|
||||||
|
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return;
|
||||||
|
if (current > duration - FINISH_MARGIN) {
|
||||||
|
autoMarkedRef.current = true;
|
||||||
|
setWatched(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const persist = () => {
|
||||||
|
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
|
||||||
|
} catch {
|
||||||
|
/* player may be tearing down */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadYouTubeApi().then((YT) => {
|
||||||
|
if (cancelled || !mountRef.current) return;
|
||||||
|
playerRef.current = new YT.Player(mountRef.current, {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
videoId: id,
|
||||||
|
playerVars: {
|
||||||
|
autoplay: 1,
|
||||||
|
start: loadPos(id) || undefined,
|
||||||
|
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||||||
|
enablejsapi: 1,
|
||||||
|
origin: window.location.origin,
|
||||||
|
playsinline: 1,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
onStateChange: (e: any) => {
|
||||||
|
// 1 === playing → sync the navigated-to video's title/author for display.
|
||||||
|
if (e?.data === 1) {
|
||||||
|
const p = playerRef.current;
|
||||||
|
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||||||
|
if (d) setLiveData({ title: d.title, author: d.author });
|
||||||
|
}
|
||||||
|
// 0 === ended → mark watched (guarded to the feed video inside maybeAutoWatch).
|
||||||
|
if (e?.data === 0 && !autoMarkedRef.current && currentIdRef.current === id) {
|
||||||
|
autoMarkedRef.current = true;
|
||||||
|
setWatched(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
|
||||||
|
const timer = window.setInterval(persist, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
persist();
|
||||||
|
const p = playerRef.current;
|
||||||
|
if (p && typeof p.destroy === "function") {
|
||||||
|
try {
|
||||||
|
p.destroy();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playerRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [video.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="glass-card relative w-full max-w-4xl max-h-full overflow-y-auto rounded-2xl shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="aspect-video w-full bg-black rounded-t-2xl overflow-hidden">
|
||||||
|
<div ref={mountRef} className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 sm:p-5">
|
||||||
|
{/* Title row — title (with hover description) on the left, Close on the right. */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||||
|
{/* Hover target is the text itself (inline), not the whole row. */}
|
||||||
|
<span
|
||||||
|
ref={titleRef}
|
||||||
|
className="cursor-default"
|
||||||
|
onMouseEnter={openDesc}
|
||||||
|
onMouseLeave={scheduleCloseDesc}
|
||||||
|
>
|
||||||
|
{navigated ? liveData?.title ?? "Loading…" : video.title}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{navigated && (
|
||||||
|
<button
|
||||||
|
onClick={() => loadVideo(video.id, null)}
|
||||||
|
title="Back to the original video"
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showDesc &&
|
||||||
|
descRect &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[60] bg-surface border border-border rounded-xl shadow-2xl p-4"
|
||||||
|
style={{
|
||||||
|
left: descRect.left,
|
||||||
|
bottom: descRect.bottom,
|
||||||
|
width: descRect.width,
|
||||||
|
}}
|
||||||
|
onMouseEnter={openDesc}
|
||||||
|
onMouseLeave={scheduleCloseDesc}
|
||||||
|
>
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
||||||
|
Description
|
||||||
|
</div>
|
||||||
|
{detail.isLoading ? (
|
||||||
|
<div className="text-sm text-muted">Loading…</div>
|
||||||
|
) : detail.data?.description ? (
|
||||||
|
<div className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto leading-relaxed">
|
||||||
|
{renderDescription(detail.data.description, {
|
||||||
|
currentId: currentVideoId,
|
||||||
|
onSeek: seekTo,
|
||||||
|
onLoadVideo: loadVideo,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">No description.</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close (Esc)"
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel + meta on one line, with the watched toggle pushed to the right.
|
||||||
|
When navigated to a linked video we only have its author (from the player),
|
||||||
|
so we show that as plain text and hide feed-video-specific bits. */}
|
||||||
|
<div className="flex items-center gap-3 mt-3">
|
||||||
|
{!navigated && video.channel_thumbnail && (
|
||||||
|
<img
|
||||||
|
src={video.channel_thumbnail}
|
||||||
|
alt=""
|
||||||
|
className="w-9 h-9 rounded-full shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{navigated ? (
|
||||||
|
detail.data?.channel_id ? (
|
||||||
|
<a
|
||||||
|
href={`https://www.youtube.com/channel/${detail.data.channel_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-medium hover:text-accent shrink-0"
|
||||||
|
>
|
||||||
|
{liveData?.author ?? detail.data.channel_title ?? "Channel"}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={video.channel_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-medium hover:text-accent shrink-0"
|
||||||
|
>
|
||||||
|
{video.channel_title}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{!navigated ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
|
||||||
|
{video.view_count != null && <span>· {formatViews(video.view_count)} views</span>}
|
||||||
|
<span>· {relativeTime(video.published_at)}</span>
|
||||||
|
{video.duration_seconds != null && (
|
||||||
|
<span>· {formatDuration(video.duration_seconds)}</span>
|
||||||
|
)}
|
||||||
|
{video.live_status === "was_live" && (
|
||||||
|
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
|
||||||
|
stream
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Linked video's stats come from the (already-fetched) video detail.
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
|
||||||
|
{detail.data?.view_count != null && (
|
||||||
|
<span>· {formatViews(detail.data.view_count)} views</span>
|
||||||
|
)}
|
||||||
|
{detail.data?.published_at && <span>· {relativeTime(detail.data.published_at)}</span>}
|
||||||
|
{detail.data?.duration_seconds != null && (
|
||||||
|
<span>· {formatDuration(detail.data.duration_seconds)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!navigated && (
|
||||||
|
<button
|
||||||
|
onClick={() => setWatched(!watched)}
|
||||||
|
title={watched ? "Watched — click to unmark" : "Mark watched"}
|
||||||
|
className={
|
||||||
|
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
||||||
|
(watched
|
||||||
|
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
|
||||||
|
: "text-muted hover:text-fg hover:bg-surface border border-border")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
|
||||||
|
{watched ? "Watched" : "Mark watched"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Bookmark, Check, Eye, EyeOff, ListFilter } from "lucide-react";
|
import { Bookmark, Check, CheckCheck, Eye, EyeOff, ListFilter } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
@ -21,20 +21,24 @@ function Actions({
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||||
<button
|
<button
|
||||||
onClick={act("watched")}
|
onClick={act("watched")}
|
||||||
title="Mark watched"
|
title={video.status === "watched" ? "Watched — click to unmark" : "Mark watched"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||||
video.status === "watched" && "text-accent"
|
video.status === "watched" && "text-accent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{video.status === "watched" ? (
|
||||||
|
<CheckCheck className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
<Check className="w-4 h-4" />
|
<Check className="w-4 h-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={act("saved")}
|
onClick={act("saved")}
|
||||||
title="Save for later"
|
title={video.status === "saved" ? "Saved — click to remove" : "Save for later"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||||
video.status === "saved" && "text-accent"
|
video.status === "saved" && "fill-current text-accent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Bookmark className="w-4 h-4" />
|
<Bookmark className="w-4 h-4" />
|
||||||
|
|
@ -70,13 +74,33 @@ function Actions({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Thumb({ video, className }: { video: Video; className?: string }) {
|
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
|
||||||
|
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
|
||||||
|
function openInApp(
|
||||||
|
e: React.MouseEvent,
|
||||||
|
video: Video,
|
||||||
|
onOpen?: (v: Video) => void
|
||||||
|
): void {
|
||||||
|
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen(video);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Thumb({
|
||||||
|
video,
|
||||||
|
className,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
video: Video;
|
||||||
|
className?: string;
|
||||||
|
onOpen?: (v: Video) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={video.watch_url}
|
href={video.watch_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
onClick={() => video.status === "new" && undefined}
|
onClick={(e) => openInApp(e, video, onOpen)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
|
"block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
|
||||||
className
|
className
|
||||||
|
|
@ -116,11 +140,13 @@ export default function VideoCard({
|
||||||
view,
|
view,
|
||||||
onState,
|
onState,
|
||||||
onChannelFilter,
|
onChannelFilter,
|
||||||
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
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;
|
||||||
}) {
|
}) {
|
||||||
const watched = video.status === "watched";
|
const watched = video.status === "watched";
|
||||||
const meta = (
|
const meta = (
|
||||||
|
|
@ -134,6 +160,7 @@ export default function VideoCard({
|
||||||
href={video.watch_url}
|
href={video.watch_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
onClick={(e) => openInApp(e, video, onOpen)}
|
||||||
className="font-medium leading-snug line-clamp-2 hover:text-accent"
|
className="font-medium leading-snug line-clamp-2 hover:text-accent"
|
||||||
>
|
>
|
||||||
{video.title}
|
{video.title}
|
||||||
|
|
@ -148,7 +175,7 @@ export default function VideoCard({
|
||||||
watched && "opacity-55"
|
watched && "opacity-55"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Thumb video={video} className="w-44 aspect-video shrink-0" />
|
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{title}
|
{title}
|
||||||
<a
|
<a
|
||||||
|
|
@ -174,7 +201,7 @@ export default function VideoCard({
|
||||||
watched && "opacity-55"
|
watched && "opacity-55"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Thumb video={video} className="aspect-video" />
|
<Thumb video={video} className="aspect-video" onOpen={onOpen} />
|
||||||
<div className="flex gap-3 mt-2.5 px-0.5 pb-0.5">
|
<div className="flex gap-3 mt-2.5 px-0.5 pb-0.5">
|
||||||
{video.channel_thumbnail && (
|
{video.channel_thumbnail && (
|
||||||
<img
|
<img
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,18 @@ export interface Video {
|
||||||
watch_url: string;
|
watch_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VideoDetail {
|
||||||
|
id: string;
|
||||||
|
description: string | null;
|
||||||
|
like_count: number | null;
|
||||||
|
in_db: boolean;
|
||||||
|
channel_id: string | null;
|
||||||
|
channel_title: string | null;
|
||||||
|
published_at: string | null;
|
||||||
|
view_count: number | null;
|
||||||
|
duration_seconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeedResponse {
|
export interface FeedResponse {
|
||||||
items: Video[];
|
items: Video[];
|
||||||
has_more: boolean;
|
has_more: boolean;
|
||||||
|
|
@ -214,6 +226,7 @@ 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 }) }),
|
||||||
|
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" }),
|
||||||
savePrefs: (p: Record<string, any>) =>
|
savePrefs: (p: Record<string, any>) =>
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,19 @@ export interface NotifAction {
|
||||||
|
|
||||||
// Structured payload that lets the Notification Center offer in-app actions even
|
// Structured payload that lets the Notification Center offer in-app actions even
|
||||||
// after a reload (when live `action` callbacks are gone).
|
// after a reload (when live `action` callbacks are gone).
|
||||||
export type NotifMeta = {
|
export type VideoHiddenMeta = {
|
||||||
kind: "video-hidden";
|
kind: "video-hidden";
|
||||||
videoId: string;
|
videoId: string;
|
||||||
title: string;
|
title: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
};
|
};
|
||||||
|
export type VideoWatchedMeta = {
|
||||||
|
kind: "video-watched";
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
export type NotifMeta = VideoHiddenMeta | VideoWatchedMeta;
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue