Merge feature/s4b-watch-later-queue: Watch later unification + queue playback
S4b: - feat(playlists): unify Saved into a built-in Watch later playlist (migration 0012) - feat(playlists): queue playback in the player (auto-advance + prev/next) - feat(playlists): live cross-tab queue (track by id, refetch on focus) - fix(header): keep the sync-status count live (focus refetch + background poll)
This commit is contained in:
commit
9c28a75787
17 changed files with 379 additions and 80 deletions
65
backend/alembic/versions/0012_watch_later.py
Normal file
65
backend/alembic/versions/0012_watch_later.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""unify Saved into a built-in Watch later playlist
|
||||||
|
|
||||||
|
Revision ID: 0012_watch_later
|
||||||
|
Revises: 0011_playlists
|
||||||
|
Create Date: 2026-06-15
|
||||||
|
|
||||||
|
Migrates the old per-video status='saved' into membership of a built-in 'watch_later'
|
||||||
|
playlist (one per user who had saved videos), then demotes those states to 'new'.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0012_watch_later"
|
||||||
|
down_revision: Union[str, None] = "0011_playlists"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1) A Watch later playlist for each user that has saved videos (and none yet).
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO playlists (user_id, name, kind, source, dirty, position, created_at, updated_at)
|
||||||
|
SELECT DISTINCT vs.user_id, 'Watch later', 'watch_later', 'local', false, 0, now(), now()
|
||||||
|
FROM video_states vs
|
||||||
|
WHERE vs.status = 'saved'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM playlists p
|
||||||
|
WHERE p.user_id = vs.user_id AND p.kind = 'watch_later'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# 2) Add each saved video as an item, ordered (newest-saved first → position 0).
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO playlist_items (playlist_id, video_id, position, added_at)
|
||||||
|
SELECT p.id, vs.video_id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY vs.user_id
|
||||||
|
ORDER BY COALESCE(vs.watched_at, vs.updated_at) DESC
|
||||||
|
) - 1,
|
||||||
|
COALESCE(vs.watched_at, vs.updated_at, now())
|
||||||
|
FROM video_states vs
|
||||||
|
JOIN playlists p ON p.user_id = vs.user_id AND p.kind = 'watch_later'
|
||||||
|
WHERE vs.status = 'saved'
|
||||||
|
ON CONFLICT (playlist_id, video_id) DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# 3) Demote the migrated saved states to the default.
|
||||||
|
op.execute("UPDATE video_states SET status = 'new' WHERE status = 'saved'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Best-effort reverse: restore status='saved' for videos in watch_later lists, then drop them.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE video_states vs SET status = 'saved'
|
||||||
|
FROM playlist_items pi
|
||||||
|
JOIN playlists p ON p.id = pi.playlist_id AND p.kind = 'watch_later'
|
||||||
|
WHERE pi.video_id = vs.video_id AND p.user_id = vs.user_id
|
||||||
|
AND vs.status = 'new'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("DELETE FROM playlists WHERE kind = 'watch_later'")
|
||||||
|
|
@ -7,13 +7,24 @@ from sqlalchemy.orm import Session, aliased
|
||||||
from app import quota
|
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,
|
||||||
|
Playlist,
|
||||||
|
PlaylistItem,
|
||||||
|
Subscription,
|
||||||
|
Tag,
|
||||||
|
User,
|
||||||
|
Video,
|
||||||
|
VideoState,
|
||||||
|
)
|
||||||
from app.sync.videos import parse_iso8601_duration
|
from app.sync.videos import parse_iso8601_duration
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["feed"])
|
router = APIRouter(prefix="/api", tags=["feed"])
|
||||||
|
|
||||||
VALID_STATES = {"new", "watched", "saved", "hidden"}
|
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
|
||||||
|
VALID_STATES = {"new", "watched", "hidden"}
|
||||||
HIDDEN_LIVE = ("live", "upcoming")
|
HIDDEN_LIVE = ("live", "upcoming")
|
||||||
|
|
||||||
# Resume-position thresholds (mirror the client): positions below this are "didn't
|
# Resume-position thresholds (mirror the client): positions below this are "didn't
|
||||||
|
|
@ -29,6 +40,23 @@ def _channel_url(channel_id: str, handle: str | None) -> str:
|
||||||
return f"https://www.youtube.com/channel/{channel_id}"
|
return f"https://www.youtube.com/channel/{channel_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _saved_expr(user: User):
|
||||||
|
"""A correlated EXISTS labelled `saved`: is this video in the user's Watch later list?
|
||||||
|
(Watch later is the built-in playlist that replaced the old per-video 'saved' status.)"""
|
||||||
|
return (
|
||||||
|
select(1)
|
||||||
|
.select_from(PlaylistItem)
|
||||||
|
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
||||||
|
.where(
|
||||||
|
Playlist.user_id == user.id,
|
||||||
|
Playlist.kind == "watch_later",
|
||||||
|
PlaylistItem.video_id == Video.id,
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
.label("saved")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _serialize(row) -> dict:
|
def _serialize(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
|
|
@ -45,6 +73,7 @@ def _serialize(row) -> dict:
|
||||||
"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,
|
"position_seconds": row.position_seconds or 0,
|
||||||
|
"saved": bool(getattr(row, "saved", False)),
|
||||||
"watch_url": f"https://www.youtube.com/watch?v={row.id}",
|
"watch_url": f"https://www.youtube.com/watch?v={row.id}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,6 +125,7 @@ def _filtered_query(
|
||||||
Video.live_status,
|
Video.live_status,
|
||||||
status_expr.label("status"),
|
status_expr.label("status"),
|
||||||
position_expr.label("position_seconds"),
|
position_expr.label("position_seconds"),
|
||||||
|
_saved_expr(user),
|
||||||
).join(Channel, Channel.id == Video.channel_id)
|
).join(Channel, Channel.id == Video.channel_id)
|
||||||
|
|
||||||
if scope == "all":
|
if scope == "all":
|
||||||
|
|
@ -176,7 +206,7 @@ def _filtered_query(
|
||||||
|
|
||||||
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
||||||
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
|
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
|
||||||
explicit_view = show in ("watched", "saved", "hidden")
|
explicit_view = show in ("watched", "hidden")
|
||||||
if not explicit_view:
|
if not explicit_view:
|
||||||
type_clauses = []
|
type_clauses = []
|
||||||
if show_normal:
|
if show_normal:
|
||||||
|
|
@ -198,8 +228,6 @@ def _filtered_query(
|
||||||
)
|
)
|
||||||
elif show == "watched":
|
elif show == "watched":
|
||||||
query = query.where(status_expr == "watched")
|
query = query.where(status_expr == "watched")
|
||||||
elif show == "saved":
|
|
||||||
query = query.where(status_expr == "saved")
|
|
||||||
elif show == "hidden":
|
elif show == "hidden":
|
||||||
query = query.where(status_expr == "hidden")
|
query = query.where(status_expr == "hidden")
|
||||||
else: # all
|
else: # all
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, aliased
|
||||||
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, Playlist, PlaylistItem, User, Video, VideoState
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||||
from app.routes.feed import _serialize
|
from app.routes.feed import _saved_expr, _serialize
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||||
|
|
||||||
|
|
@ -110,6 +110,83 @@ def create_playlist(
|
||||||
return _summary(db, pl, 0)
|
return _summary(db, pl, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _watch_later(db: Session, user: User) -> Playlist:
|
||||||
|
"""The user's built-in Watch later playlist, created on first use."""
|
||||||
|
pl = db.scalar(
|
||||||
|
select(Playlist).where(
|
||||||
|
Playlist.user_id == user.id, Playlist.kind == "watch_later"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if pl is None:
|
||||||
|
next_pos = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
||||||
|
Playlist.user_id == user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
pl = Playlist(
|
||||||
|
user_id=user.id, name="Watch later", kind="watch_later", position=next_pos
|
||||||
|
)
|
||||||
|
db.add(pl)
|
||||||
|
db.commit()
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/watch-later")
|
||||||
|
def add_watch_later(
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Bookmark shortcut: add a video to the built-in Watch later playlist."""
|
||||||
|
video_id = payload.get("video_id")
|
||||||
|
if not video_id or db.get(Video, video_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown video")
|
||||||
|
pl = _watch_later(db, user)
|
||||||
|
exists = db.scalar(
|
||||||
|
select(PlaylistItem).where(
|
||||||
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if exists is None:
|
||||||
|
next_pos = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
||||||
|
PlaylistItem.playlist_id == pl.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos))
|
||||||
|
db.commit()
|
||||||
|
return {"saved": True, "playlist_id": pl.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/watch-later/{video_id}")
|
||||||
|
def remove_watch_later(
|
||||||
|
video_id: str,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
pl = db.scalar(
|
||||||
|
select(Playlist).where(
|
||||||
|
Playlist.user_id == user.id, Playlist.kind == "watch_later"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if pl is not None:
|
||||||
|
item = db.scalar(
|
||||||
|
select(PlaylistItem).where(
|
||||||
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if item is not None:
|
||||||
|
db.delete(item)
|
||||||
|
db.commit()
|
||||||
|
return {"saved": False}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{playlist_id}")
|
@router.get("/{playlist_id}")
|
||||||
def get_playlist(
|
def get_playlist(
|
||||||
playlist_id: int,
|
playlist_id: int,
|
||||||
|
|
@ -134,6 +211,7 @@ def get_playlist(
|
||||||
Video.live_status,
|
Video.live_status,
|
||||||
func.coalesce(state.status, "new").label("status"),
|
func.coalesce(state.status, "new").label("status"),
|
||||||
func.coalesce(state.position_seconds, 0).label("position_seconds"),
|
func.coalesce(state.position_seconds, 0).label("position_seconds"),
|
||||||
|
_saved_expr(user),
|
||||||
)
|
)
|
||||||
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||||
.join(Channel, Channel.id == Video.channel_id)
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,9 @@ export default function AddToPlaylist({
|
||||||
>
|
>
|
||||||
{pl.has_video && <Check className="w-3 h-3" />}
|
{pl.has_video && <Check className="w-3 h-3" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{pl.name}</span>
|
<span className="truncate">
|
||||||
|
{pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ function matchesView(status: string, show: string): boolean {
|
||||||
return status === "hidden";
|
return status === "hidden";
|
||||||
case "watched":
|
case "watched":
|
||||||
return status === "watched";
|
return status === "watched";
|
||||||
case "saved":
|
|
||||||
return status === "saved";
|
|
||||||
case "unwatched":
|
case "unwatched":
|
||||||
case "in_progress":
|
case "in_progress":
|
||||||
// (in_progress is further narrowed server-side by resume position; here we only
|
// (in_progress is further narrowed server-side by resume position; here we only
|
||||||
|
|
@ -42,6 +40,7 @@ export default function Feed({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||||
|
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
||||||
// The open player: which video and where to start (null = resume from saved position).
|
// The open player: which video and where to start (null = resume from saved position).
|
||||||
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||||
null
|
null
|
||||||
|
|
@ -64,8 +63,14 @@ export default function Feed({
|
||||||
// arrives — after a refetch the server is authoritative, so a stale override
|
// 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
|
// (e.g. a video reverted to "new" from the notification center) won't keep it
|
||||||
// filtered out of the current view.
|
// filtered out of the current view.
|
||||||
useEffect(() => setOverrides({}), [filters]);
|
useEffect(() => {
|
||||||
useEffect(() => setOverrides({}), [query.dataUpdatedAt]);
|
setOverrides({});
|
||||||
|
setSavedOverrides({});
|
||||||
|
}, [filters]);
|
||||||
|
useEffect(() => {
|
||||||
|
setOverrides({});
|
||||||
|
setSavedOverrides({});
|
||||||
|
}, [query.dataUpdatedAt]);
|
||||||
|
|
||||||
const countQuery = useQuery({
|
const countQuery = useQuery({
|
||||||
queryKey: ["feed-count", filters],
|
queryKey: ["feed-count", filters],
|
||||||
|
|
@ -139,10 +144,24 @@ export default function Feed({
|
||||||
[filters, setFilters]
|
[filters, setFilters]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onToggleSave = useCallback(
|
||||||
|
(id: string, saved: boolean) => {
|
||||||
|
setSavedOverrides((o) => ({ ...o, [id]: saved }));
|
||||||
|
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
||||||
|
.then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlist"] });
|
||||||
|
})
|
||||||
|
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
|
||||||
|
},
|
||||||
|
[qc]
|
||||||
|
);
|
||||||
|
|
||||||
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
||||||
loadedRef.current = loaded;
|
loadedRef.current = loaded;
|
||||||
const items: Video[] = loaded
|
const items: Video[] = loaded
|
||||||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
||||||
|
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v))
|
||||||
.filter((v) => matchesView(v.status, filters.show));
|
.filter((v) => matchesView(v.status, filters.show));
|
||||||
|
|
||||||
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
||||||
|
|
@ -192,6 +211,7 @@ export default function Feed({
|
||||||
video={v}
|
video={v}
|
||||||
view="grid"
|
view="grid"
|
||||||
onState={onState}
|
onState={onState}
|
||||||
|
onToggleSave={onToggleSave}
|
||||||
onChannelFilter={onChannelFilter}
|
onChannelFilter={onChannelFilter}
|
||||||
onOpen={openVideo}
|
onOpen={openVideo}
|
||||||
/>
|
/>
|
||||||
|
|
@ -205,6 +225,7 @@ export default function Feed({
|
||||||
video={v}
|
video={v}
|
||||||
view="list"
|
view="list"
|
||||||
onState={onState}
|
onState={onState}
|
||||||
|
onToggleSave={onToggleSave}
|
||||||
onChannelFilter={onChannelFilter}
|
onChannelFilter={onChannelFilter}
|
||||||
onOpen={openVideo}
|
onOpen={openVideo}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
|
import { ArrowLeft, Check, CheckCheck, SkipBack, SkipForward, X } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
|
|
@ -184,6 +184,8 @@ function loadYouTubeApi(): Promise<any> {
|
||||||
export default function PlayerModal({
|
export default function PlayerModal({
|
||||||
video,
|
video,
|
||||||
startAt,
|
startAt,
|
||||||
|
queue,
|
||||||
|
startIndex,
|
||||||
onClose,
|
onClose,
|
||||||
onState,
|
onState,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -191,30 +193,47 @@ export default function PlayerModal({
|
||||||
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
|
// 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.
|
// to resume from the server-saved position carried on the video.
|
||||||
startAt?: number | null;
|
startAt?: number | null;
|
||||||
|
// Optional playback queue (e.g. a playlist). When present, the player advances through
|
||||||
|
// it (auto-advance on end + prev/next), recreating per item to reuse the single-video
|
||||||
|
// resume / auto-watch / progress logic.
|
||||||
|
queue?: Video[];
|
||||||
|
startIndex?: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
// Resume point for the video we opened with.
|
// Track the playing item by id (not a frozen index) so it survives the queue changing
|
||||||
const resumeAt = startAt != null ? startAt : video.position_seconds || 0;
|
// under us — e.g. an item removed in another tab. The index is derived from the *live*
|
||||||
|
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting
|
||||||
|
// playback of the current video.
|
||||||
|
const [playingId, setPlayingId] = useState<string>(
|
||||||
|
queue?.[startIndex ?? 0]?.id ?? video.id
|
||||||
|
);
|
||||||
|
let index = queue ? queue.findIndex((v) => v.id === playingId) : 0;
|
||||||
|
if (index < 0) index = 0;
|
||||||
|
const active = queue && queue[index] ? queue[index] : video;
|
||||||
|
const hasQueue = !!queue && queue.length > 1;
|
||||||
|
// startAt only applies to the item we opened with; advanced items resume their own pos.
|
||||||
|
const resumeAt =
|
||||||
|
startAt != null && active.id === video.id ? startAt : active.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);
|
||||||
|
|
||||||
// The player can navigate to other videos (YouTube links in a description). The
|
// 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.
|
// currently-playing id may differ from the active item.
|
||||||
const [currentVideoId, setCurrentVideoId] = useState(video.id);
|
const [currentVideoId, setCurrentVideoId] = useState(active.id);
|
||||||
const currentIdRef = useRef(video.id);
|
const currentIdRef = useRef(active.id);
|
||||||
const navigated = currentVideoId !== video.id;
|
const navigated = currentVideoId !== active.id;
|
||||||
// Title/author of a navigated-to video, read from the player (free, no API call).
|
// 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 [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
|
||||||
|
|
||||||
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;
|
||||||
// Explicit start wins; otherwise resume the opened video, start others at 0.
|
// Explicit start wins; otherwise resume the active item, start others at 0.
|
||||||
const startSeconds = start != null ? start : id === video.id ? resumeAt : 0;
|
const startSeconds = start != null ? start : id === active.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);
|
||||||
|
|
@ -223,14 +242,22 @@ export default function PlayerModal({
|
||||||
|
|
||||||
// Local mirror of watch status so the toggle reacts instantly; changes are
|
// Local mirror of watch status so the toggle reacts instantly; changes are
|
||||||
// propagated to the feed (and server) via onState.
|
// propagated to the feed (and server) via onState.
|
||||||
const [status, setStatus] = useState(video.status);
|
const [status, setStatus] = useState(active.status);
|
||||||
const watched = status === "watched";
|
const watched = status === "watched";
|
||||||
const setWatched = (on: boolean) => {
|
const setWatched = (on: boolean) => {
|
||||||
const next = on ? "watched" : "new";
|
const next = on ? "watched" : "new";
|
||||||
setStatus(next);
|
setStatus(next);
|
||||||
onState(video.id, next);
|
onState(active.id, next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// On queue advance (active changes), sync local state to the new item.
|
||||||
|
useEffect(() => {
|
||||||
|
setStatus(active.status);
|
||||||
|
setCurrentVideoId(active.id);
|
||||||
|
currentIdRef.current = active.id;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [active.id]);
|
||||||
|
|
||||||
const seekTo = (seconds: number) => {
|
const seekTo = (seconds: number) => {
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
if (p && typeof p.seekTo === "function") {
|
if (p && typeof p.seekTo === "function") {
|
||||||
|
|
@ -292,10 +319,11 @@ export default function PlayerModal({
|
||||||
// auto-mark watched once playback reaches the end.
|
// auto-mark watched once playback reaches the end.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const id = video.id;
|
const id = active.id;
|
||||||
|
autoMarkedRef.current = false;
|
||||||
|
|
||||||
// Auto-watch only applies to the feed video we opened with — not to other
|
// Auto-watch only applies to the active item — not to other videos the player
|
||||||
// videos the player navigated to via description links.
|
// navigated to via description links.
|
||||||
const maybeAutoWatch = (current: number, duration: number) => {
|
const maybeAutoWatch = (current: number, duration: number) => {
|
||||||
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return;
|
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return;
|
||||||
if (current > duration - FINISH_MARGIN) {
|
if (current > duration - FINISH_MARGIN) {
|
||||||
|
|
@ -342,10 +370,13 @@ export default function PlayerModal({
|
||||||
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||||||
if (d) setLiveData({ title: d.title, author: d.author });
|
if (d) setLiveData({ title: d.title, author: d.author });
|
||||||
}
|
}
|
||||||
// 0 === ended → mark watched (guarded to the feed video inside maybeAutoWatch).
|
// 0 === ended → mark watched, then advance to the next queue item if any.
|
||||||
if (e?.data === 0 && !autoMarkedRef.current && currentIdRef.current === id) {
|
if (e?.data === 0 && currentIdRef.current === id) {
|
||||||
autoMarkedRef.current = true;
|
if (!autoMarkedRef.current) {
|
||||||
setWatched(true);
|
autoMarkedRef.current = true;
|
||||||
|
setWatched(true);
|
||||||
|
}
|
||||||
|
if (queue && index < queue.length - 1) setPlayingId(queue[index + 1].id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -358,10 +389,11 @@ export default function PlayerModal({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
// Final flush, then refresh the feed so the card's resume bar reflects this session.
|
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
|
||||||
Promise.resolve(persist()).finally(() =>
|
Promise.resolve(persist()).finally(() => {
|
||||||
qc.invalidateQueries({ queryKey: ["feed"] })
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
);
|
qc.invalidateQueries({ queryKey: ["playlist"] });
|
||||||
|
});
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
if (p && typeof p.destroy === "function") {
|
if (p && typeof p.destroy === "function") {
|
||||||
try {
|
try {
|
||||||
|
|
@ -373,7 +405,7 @@ export default function PlayerModal({
|
||||||
playerRef.current = null;
|
playerRef.current = null;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [video.id]);
|
}, [active.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -390,6 +422,28 @@ export default function PlayerModal({
|
||||||
<div ref={mountRef} className="w-full h-full" />
|
<div ref={mountRef} className="w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasQueue && (
|
||||||
|
<div className="flex items-center justify-center gap-5 px-4 py-2 border-b border-border text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => index > 0 && setPlayingId(queue![index - 1].id)}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
|
||||||
|
>
|
||||||
|
<SkipBack className="w-4 h-4" /> {t("player.previous")}
|
||||||
|
</button>
|
||||||
|
<span className="text-muted tabular-nums">
|
||||||
|
{index + 1} / {queue!.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => index < queue!.length - 1 && setPlayingId(queue![index + 1].id)}
|
||||||
|
disabled={index === queue!.length - 1}
|
||||||
|
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
|
||||||
|
>
|
||||||
|
{t("player.next")} <SkipForward className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-4 sm:p-5">
|
<div className="p-4 sm:p-5">
|
||||||
{/* Title row — title (with hover description) on the left, Close on the right. */}
|
{/* Title row — title (with hover description) on the left, Close on the right. */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
|
@ -401,12 +455,12 @@ export default function PlayerModal({
|
||||||
onMouseEnter={openDesc}
|
onMouseEnter={openDesc}
|
||||||
onMouseLeave={scheduleCloseDesc}
|
onMouseLeave={scheduleCloseDesc}
|
||||||
>
|
>
|
||||||
{navigated ? liveData?.title ?? t("player.loading") : video.title}
|
{navigated ? liveData?.title ?? t("player.loading") : active.title}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
{navigated && (
|
{navigated && (
|
||||||
<button
|
<button
|
||||||
onClick={() => loadVideo(video.id, null)}
|
onClick={() => loadVideo(active.id, null)}
|
||||||
title={t("player.backToOriginal")}
|
title={t("player.backToOriginal")}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|
@ -463,8 +517,8 @@ export default function PlayerModal({
|
||||||
<div className="flex items-center gap-3 mt-3">
|
<div className="flex items-center gap-3 mt-3">
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<Avatar
|
<Avatar
|
||||||
src={video.channel_thumbnail}
|
src={active.channel_thumbnail}
|
||||||
fallback={video.channel_title ?? ""}
|
fallback={active.channel_title ?? ""}
|
||||||
className="w-9 h-9 rounded-full shrink-0"
|
className="w-9 h-9 rounded-full shrink-0"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -483,24 +537,24 @@ export default function PlayerModal({
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
href={video.channel_url}
|
href={active.channel_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-medium hover:text-accent shrink-0"
|
className="font-medium hover:text-accent shrink-0"
|
||||||
>
|
>
|
||||||
{video.channel_title}
|
{active.channel_title}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{!navigated ? (
|
{!navigated ? (
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
|
<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 && (
|
{active.view_count != null && (
|
||||||
<span>· {t("player.views", { count: video.view_count, formattedCount: formatViews(video.view_count) })}</span>
|
<span>· {t("player.views", { count: active.view_count, formattedCount: formatViews(active.view_count) })}</span>
|
||||||
)}
|
)}
|
||||||
<span>· {relativeTime(video.published_at)}</span>
|
<span>· {relativeTime(active.published_at)}</span>
|
||||||
{video.duration_seconds != null && (
|
{active.duration_seconds != null && (
|
||||||
<span>· {formatDuration(video.duration_seconds)}</span>
|
<span>· {formatDuration(active.duration_seconds)}</span>
|
||||||
)}
|
)}
|
||||||
{video.live_status === "was_live" && (
|
{active.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">
|
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
|
||||||
{t("player.stream")}
|
{t("player.stream")}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -521,7 +575,7 @@ export default function PlayerModal({
|
||||||
|
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<AddToPlaylist
|
<AddToPlaylist
|
||||||
videoId={video.id}
|
videoId={active.id}
|
||||||
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -105,9 +105,14 @@ export default function Playlists() {
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
const [items, setItems] = useState<Video[]>([]);
|
const [items, setItems] = useState<Video[]>([]);
|
||||||
const [active, setActive] = useState<{ video: Video; startAt: number | null } | null>(null);
|
// The open player, as an index into `items` (the queue); null = closed.
|
||||||
|
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const listQuery = useQuery({ queryKey: ["playlists"], queryFn: () => api.playlists() });
|
const listQuery = useQuery({
|
||||||
|
queryKey: ["playlists"],
|
||||||
|
queryFn: () => api.playlists(),
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
const playlists = listQuery.data ?? [];
|
const playlists = listQuery.data ?? [];
|
||||||
|
|
||||||
// Keep the selection valid: default to the first playlist, and if the selected one
|
// Keep the selection valid: default to the first playlist, and if the selected one
|
||||||
|
|
@ -126,6 +131,9 @@ export default function Playlists() {
|
||||||
queryKey: ["playlist", selectedId],
|
queryKey: ["playlist", selectedId],
|
||||||
queryFn: () => api.playlist(selectedId as number),
|
queryFn: () => api.playlist(selectedId as number),
|
||||||
enabled: selectedId != null,
|
enabled: selectedId != null,
|
||||||
|
// Refetch when returning to the tab so a change made elsewhere (another tab/device)
|
||||||
|
// shows up — including the player's live N / M count, which reads the queue length.
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
|
|
||||||
|
|
@ -137,6 +145,11 @@ export default function Playlists() {
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Watch later is a built-in playlist: show a localized name and no rename/delete.
|
||||||
|
const plName = (p: { kind: string; name: string }) =>
|
||||||
|
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
||||||
|
const builtin = detail?.kind === "watch_later";
|
||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: (name: string) => api.createPlaylist(name),
|
mutationFn: (name: string) => api.createPlaylist(name),
|
||||||
onSuccess: (pl: Playlist) => {
|
onSuccess: (pl: Playlist) => {
|
||||||
|
|
@ -230,7 +243,7 @@ export default function Playlists() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[13px] text-fg truncate">{pl.name}</div>
|
<div className="text-[13px] text-fg truncate">{plName(pl)}</div>
|
||||||
<div className="text-[11px] text-muted">
|
<div className="text-[11px] text-muted">
|
||||||
{t("playlists.itemCount", { count: pl.item_count })}
|
{t("playlists.itemCount", { count: pl.item_count })}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -287,17 +300,19 @@ export default function Playlists() {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold truncate">{detail.name}</h2>
|
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||||
<button
|
{!builtin && (
|
||||||
onClick={() => {
|
<button
|
||||||
setRenameValue(detail.name);
|
onClick={() => {
|
||||||
setRenaming(true);
|
setRenameValue(detail.name);
|
||||||
}}
|
setRenaming(true);
|
||||||
title={t("playlists.rename")}
|
}}
|
||||||
className="text-muted hover:text-fg"
|
title={t("playlists.rename")}
|
||||||
>
|
className="text-muted hover:text-fg"
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
>
|
||||||
</button>
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-muted mt-0.5">
|
<div className="text-xs text-muted mt-0.5">
|
||||||
|
|
@ -305,18 +320,20 @@ export default function Playlists() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mt-3 flex-wrap">
|
<div className="flex gap-2 mt-3 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => items[0] && setActive({ video: items[0], startAt: null })}
|
onClick={() => items.length && setPlayingIndex(0)}
|
||||||
disabled={!items.length}
|
disabled={!items.length}
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition"
|
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition"
|
||||||
>
|
>
|
||||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!builtin && (
|
||||||
onClick={deletePlaylist}
|
<button
|
||||||
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
|
onClick={deletePlaylist}
|
||||||
>
|
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
|
||||||
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
|
>
|
||||||
</button>
|
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -344,7 +361,7 @@ export default function Playlists() {
|
||||||
key={v.id}
|
key={v.id}
|
||||||
video={v}
|
video={v}
|
||||||
index={i}
|
index={i}
|
||||||
onPlay={() => setActive({ video: v, startAt: null })}
|
onPlay={() => setPlayingIndex(i)}
|
||||||
onRemove={() => removeItem(v.id)}
|
onRemove={() => removeItem(v.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -356,11 +373,13 @@ export default function Playlists() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{active && (
|
{playingIndex != null && items[playingIndex] && (
|
||||||
<PlayerModal
|
<PlayerModal
|
||||||
video={active.video}
|
video={items[playingIndex]}
|
||||||
startAt={active.startAt}
|
queue={items}
|
||||||
onClose={() => setActive(null)}
|
startIndex={playingIndex}
|
||||||
|
startAt={null}
|
||||||
|
onClose={() => setPlayingIndex(null)}
|
||||||
onState={playerState}
|
onState={playerState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ const SORT_IDS = [
|
||||||
"shuffle",
|
"shuffle",
|
||||||
];
|
];
|
||||||
|
|
||||||
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"];
|
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
|
||||||
|
|
||||||
// Fresh shuffle token for the "surprise me" sort.
|
// Fresh shuffle token for the "surprise me" sort.
|
||||||
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,12 @@ export default function SyncStatus({
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["my-status"],
|
queryKey: ["my-status"],
|
||||||
queryFn: api.myStatus,
|
queryFn: api.myStatus,
|
||||||
|
// Track the scheduler near-real-time: poll even when the tab is backgrounded, and
|
||||||
|
// refresh immediately on tab focus (the global default disables focus refetch), so the
|
||||||
|
// "N without full history" count keeps ticking down without a manual reload.
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
staleTime: 25_000,
|
staleTime: 25_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,12 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
function Actions({
|
function Actions({
|
||||||
video,
|
video,
|
||||||
onState,
|
onState,
|
||||||
|
onToggleSave,
|
||||||
onChannelFilter,
|
onChannelFilter,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -31,6 +33,11 @@ function Actions({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onState(video.id, video.status === status ? "new" : status);
|
onState(video.id, video.status === status ? "new" : status);
|
||||||
};
|
};
|
||||||
|
const toggleSave = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleSave(video.id, !video.saved);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<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
|
||||||
|
|
@ -48,11 +55,11 @@ function Actions({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={act("saved")}
|
onClick={toggleSave}
|
||||||
title={video.status === "saved" ? t("card.savedRemove") : t("card.saveForLater")}
|
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
|
||||||
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" && "fill-current text-accent"
|
video.saved && "fill-current text-accent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Bookmark className="w-4 h-4" />
|
<Bookmark className="w-4 h-4" />
|
||||||
|
|
@ -157,7 +164,7 @@ function Thumb({
|
||||||
{t("card.stream")}
|
{t("card.stream")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{video.status === "saved" && (
|
{video.saved && (
|
||||||
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
|
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
|
||||||
<Bookmark className="w-3.5 h-3.5" />
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -209,12 +216,14 @@ function VideoCard({
|
||||||
video,
|
video,
|
||||||
view,
|
view,
|
||||||
onState,
|
onState,
|
||||||
|
onToggleSave,
|
||||||
onChannelFilter,
|
onChannelFilter,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -264,7 +273,7 @@ function VideoCard({
|
||||||
</a>
|
</a>
|
||||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||||
</div>
|
</div>
|
||||||
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
|
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -295,7 +304,7 @@ function VideoCard({
|
||||||
{video.channel_title}
|
{video.channel_title}
|
||||||
</a>
|
</a>
|
||||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||||
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
|
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
"loading": "Wird geladen…",
|
"loading": "Wird geladen…",
|
||||||
"backToOriginal": "Zurück zum ursprünglichen Video",
|
"backToOriginal": "Zurück zum ursprünglichen Video",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
|
"previous": "Vorheriges",
|
||||||
|
"next": "Nächstes",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"closeEsc": "Schließen (Esc)",
|
"closeEsc": "Schließen (Esc)",
|
||||||
"description": "Beschreibung",
|
"description": "Beschreibung",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"title": "Wiedergabelisten",
|
"title": "Wiedergabelisten",
|
||||||
|
"watchLater": "Später ansehen",
|
||||||
"noneYet": "Noch keine Wiedergabelisten",
|
"noneYet": "Noch keine Wiedergabelisten",
|
||||||
"newPlaylist": "Neue Liste…",
|
"newPlaylist": "Neue Liste…",
|
||||||
"itemCount_one": "{{count}} Video",
|
"itemCount_one": "{{count}} Video",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"backToOriginal": "Back to the original video",
|
"backToOriginal": "Back to the original video",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"closeEsc": "Close (Esc)",
|
"closeEsc": "Close (Esc)",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"title": "Playlists",
|
"title": "Playlists",
|
||||||
|
"watchLater": "Watch later",
|
||||||
"noneYet": "No playlists yet",
|
"noneYet": "No playlists yet",
|
||||||
"newPlaylist": "New playlist…",
|
"newPlaylist": "New playlist…",
|
||||||
"itemCount_one": "{{count}} video",
|
"itemCount_one": "{{count}} video",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"backToOriginal": "Vissza az eredeti videóhoz",
|
"backToOriginal": "Vissza az eredeti videóhoz",
|
||||||
"back": "Vissza",
|
"back": "Vissza",
|
||||||
|
"previous": "Előző",
|
||||||
|
"next": "Következő",
|
||||||
"close": "Bezárás",
|
"close": "Bezárás",
|
||||||
"closeEsc": "Bezárás (Esc)",
|
"closeEsc": "Bezárás (Esc)",
|
||||||
"description": "Leírás",
|
"description": "Leírás",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"title": "Lejátszási listák",
|
"title": "Lejátszási listák",
|
||||||
|
"watchLater": "Megnézem később",
|
||||||
"noneYet": "Még nincs lejátszási lista",
|
"noneYet": "Még nincs lejátszási lista",
|
||||||
"newPlaylist": "Új lista…",
|
"newPlaylist": "Új lista…",
|
||||||
"itemCount_one": "{{count}} videó",
|
"itemCount_one": "{{count}} videó",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export interface Video {
|
||||||
live_status: string;
|
live_status: string;
|
||||||
status: string;
|
status: string;
|
||||||
position_seconds: number;
|
position_seconds: number;
|
||||||
|
saved: boolean; // is the video in the user's built-in Watch later playlist
|
||||||
watch_url: string;
|
watch_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,6 +336,14 @@ export const api = {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ video_ids: videoIds }),
|
body: JSON.stringify({ video_ids: videoIds }),
|
||||||
}),
|
}),
|
||||||
|
// Bookmark shortcut for the built-in Watch later playlist.
|
||||||
|
watchLaterAdd: (videoId: string) =>
|
||||||
|
req("/api/playlists/watch-later", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ video_id: videoId }),
|
||||||
|
}),
|
||||||
|
watchLaterRemove: (videoId: string) =>
|
||||||
|
req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
|
||||||
|
|
||||||
// --- quota usage ---
|
// --- quota usage ---
|
||||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue