Add offline indicator, surprise me button, and continue watching on channel

- Offline banner in nav when backend is unreachable (network error, not 4xx)
- GET /channels/{id}/random — picks random unwatched video, navigates to watch
- GET /channels/{id}/in-progress — videos with >30s progress, not yet watched
- Channel page: 'Surprise me' button (desktop + mobile) navigates to random video
- Channel page: 'Continue watching' row above video list when in-progress videos exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 23:46:58 +02:00
parent e02ea12494
commit 3652038cf5
4 changed files with 120 additions and 2 deletions

View File

@@ -865,6 +865,66 @@ def _search_channel_task(channel_id: int, youtube_channel_id: str, q: str, user_
db.close() db.close()
@router.get("/{channel_id}/random", response_model=VideoOut)
def get_random_channel_video(
channel_id: int,
unwatched_only: bool = True,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_get_channel_or_404(db, channel_id)
unwatched_clause = "AND COALESCE(uv.watched, 0) = 0" if unwatched_only else ""
row = db.execute(
text(f"""
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
v.duration_seconds, v.published_at, v.view_count,
c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.watched, 0) AS is_watched,
COALESCE(uv.queued, 0) AS queued
FROM videos v
JOIN channels c ON v.channel_id = c.id
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE v.channel_id = :channel_id {unwatched_clause}
ORDER BY RANDOM()
LIMIT 1
"""),
{"user_id": current_user.id, "channel_id": channel_id},
).mappings().first()
if not row:
raise HTTPException(status_code=404, detail="No videos found")
return VideoOut(**dict(row))
@router.get("/{channel_id}/in-progress", response_model=list[VideoOut])
def get_channel_in_progress(
channel_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_get_channel_or_404(db, channel_id)
rows = db.execute(
text("""
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
v.duration_seconds, v.published_at, v.view_count,
c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.watched, 0) AS is_watched,
COALESCE(uv.queued, 0) AS queued
FROM videos v
JOIN channels c ON v.channel_id = c.id
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE v.channel_id = :channel_id
AND uv.watch_progress_seconds > 30
AND COALESCE(uv.watched, 0) = 0
ORDER BY uv.watch_progress_seconds DESC
LIMIT 6
"""),
{"user_id": current_user.id, "channel_id": channel_id},
).mappings().all()
return [VideoOut(**dict(r)) for r in rows]
@router.post("/{channel_id}/follow", status_code=status.HTTP_204_NO_CONTENT) @router.post("/{channel_id}/follow", status_code=status.HTTP_204_NO_CONTENT)
def follow_channel( def follow_channel(
channel_id: int, channel_id: int,

View File

@@ -65,6 +65,9 @@ export const addChannelToGroup = (groupId, channelId) => api.post(`/channels/gro
export const removeChannelFromGroup = (groupId, channelId) => api.delete(`/channels/groups/${groupId}/channels/${channelId}`); export const removeChannelFromGroup = (groupId, channelId) => api.delete(`/channels/groups/${groupId}/channels/${channelId}`);
export const bulkChannelAction = (channel_ids, action) => api.post("/channels/bulk-action", { channel_ids, action }); export const bulkChannelAction = (channel_ids, action) => api.post("/channels/bulk-action", { channel_ids, action });
export const getActiveTasks = () => api.get("/channels/tasks"); export const getActiveTasks = () => api.get("/channels/tasks");
export const getRandomChannelVideo = (id, unwatchedOnly = true) =>
api.get(`/channels/${id}/random`, { params: { unwatched_only: unwatchedOnly } });
export const getChannelInProgress = (id) => api.get(`/channels/${id}/in-progress`);
// Videos // Videos
export const homeFeed = (page = 0, limit = 25, mode = "ranked", duration = "") => export const homeFeed = (page = 0, limit = 25, mode = "ranked", duration = "") =>

View File

@@ -2,7 +2,7 @@ import { Outlet, NavLink, Link, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import { getDownloads, getChannels, getActiveTasks } from "../api"; import { getDownloads, getChannels, getActiveTasks, getMe } from "../api";
function BottomNav({ newCount }) { function BottomNav({ newCount }) {
const tabs = [ const tabs = [
@@ -259,12 +259,32 @@ function useNewVideosCount() {
return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0); return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
} }
function useOffline() {
const { isError, error } = useQuery({
queryKey: ["health"],
queryFn: () => getMe().then((r) => r.data),
refetchInterval: 30_000,
retry: 1,
staleTime: 20_000,
});
return isError && !error?.response;
}
export default function Layout() { export default function Layout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const newCount = useNewVideosCount(); const newCount = useNewVideosCount();
const offline = useOffline();
return ( return (
<div className="flex flex-col overflow-hidden" style={{ height: "100dvh" }}> <div className="flex flex-col overflow-hidden" style={{ height: "100dvh" }}>
{offline && (
<div className="shrink-0 bg-amber-500/10 border-b border-amber-500/30 px-4 py-2 flex items-center gap-2">
<svg className="w-4 h-4 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-sm text-amber-300">Server unreachable check that the backend is running.</p>
</div>
)}
{/* Header */} {/* Header */}
<header className="shrink-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800"> <header className="shrink-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800">
<div className="max-w-screen-xl mx-auto px-3 h-12 sm:h-14 flex items-center gap-2 sm:gap-4"> <div className="max-w-screen-xl mx-auto px-3 h-12 sm:h-14 flex items-center gap-2 sm:gap-4">

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useParams } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query";
import { import {
getChannel, getChannelVideos, searchChannelYoutube, getChannel, getChannelVideos, searchChannelYoutube,
followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel, followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel,
getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist, getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist,
getRandomChannelVideo, getChannelInProgress,
} from "../api"; } from "../api";
import VideoCard from "../components/VideoCard"; import VideoCard from "../components/VideoCard";
@@ -32,6 +33,7 @@ function formatSubs(n) {
export default function ChannelPage() { export default function ChannelPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const [tab, setTab] = useState("videos"); const [tab, setTab] = useState("videos");
const [sort, setSort] = useState("newest"); const [sort, setSort] = useState("newest");
@@ -147,6 +149,17 @@ export default function ChannelPage() {
onSuccess: () => setTimeout(() => refetchPlaylistVideos(), 15000), onSuccess: () => setTimeout(() => refetchPlaylistVideos(), 15000),
}); });
const { data: inProgress = [] } = useQuery({
queryKey: ["channel-in-progress", id],
queryFn: () => getChannelInProgress(id).then((r) => r.data),
enabled: !!id,
});
const randomMut = useMutation({
mutationFn: () => getRandomChannelVideo(id).then((r) => r.data),
onSuccess: (video) => navigate(`/watch/${video.youtube_video_id}`),
});
const [dlResult, setDlResult] = useState(null); const [dlResult, setDlResult] = useState(null);
const dlMut = useMutation({ const dlMut = useMutation({
mutationFn: () => downloadChannel(id), mutationFn: () => downloadChannel(id),
@@ -213,6 +226,11 @@ export default function ChannelPage() {
className={`text-sm font-medium px-4 py-1.5 rounded-lg transition-colors ${isFollowed ? "bg-zinc-700/80 text-zinc-300 hover:bg-zinc-600" : "bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700"}`}> className={`text-sm font-medium px-4 py-1.5 rounded-lg transition-colors ${isFollowed ? "bg-zinc-700/80 text-zinc-300 hover:bg-zinc-600" : "bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700"}`}>
{isFollowed ? "Following" : "Follow"} {isFollowed ? "Following" : "Follow"}
</button> </button>
<button onClick={() => randomMut.mutate()} disabled={randomMut.isPending}
title="Play a random unwatched video"
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60">
{randomMut.isPending ? "…" : "Surprise me"}
</button>
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending} <button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60"> className="text-sm font-medium px-4 py-1.5 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
{dlMut.isPending ? "Queuing…" : "Download all"} {dlMut.isPending ? "Queuing…" : "Download all"}
@@ -227,6 +245,10 @@ export default function ChannelPage() {
className="flex-1 text-sm font-medium py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors"> className="flex-1 text-sm font-medium py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors">
{isFollowed ? "Following ✓" : "Follow"} {isFollowed ? "Following ✓" : "Follow"}
</button> </button>
<button onClick={() => randomMut.mutate()} disabled={randomMut.isPending}
className="flex-1 text-sm font-medium py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60">
{randomMut.isPending ? "…" : "Surprise me"}
</button>
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending} <button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
className="flex-1 text-sm font-medium py-2 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60"> className="flex-1 text-sm font-medium py-2 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
{dlMut.isPending ? "Queuing…" : "Download all"} {dlMut.isPending ? "Queuing…" : "Download all"}
@@ -456,6 +478,19 @@ export default function ChannelPage() {
) )
)} )}
{/* Continue watching */}
{tab === "videos" && inProgress.length > 0 && (
<div className="flex flex-col gap-2">
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Continue watching</h2>
<div className="flex flex-col gap-1">
{inProgress.map((v) => (
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} variant="list" />
))}
</div>
<div className="border-t border-zinc-800/60 mt-1" />
</div>
)}
{/* Video list */} {/* Video list */}
{tab !== "playlists" && (loadingVideos ? ( {tab !== "playlists" && (loadingVideos ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">