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

@@ -1,10 +1,11 @@
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 {
getChannel, getChannelVideos, searchChannelYoutube,
followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel,
getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist,
getRandomChannelVideo, getChannelInProgress,
} from "../api";
import VideoCard from "../components/VideoCard";
@@ -32,6 +33,7 @@ function formatSubs(n) {
export default function ChannelPage() {
const { id } = useParams();
const navigate = useNavigate();
const qc = useQueryClient();
const [tab, setTab] = useState("videos");
const [sort, setSort] = useState("newest");
@@ -147,6 +149,17 @@ export default function ChannelPage() {
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 dlMut = useMutation({
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"}`}>
{isFollowed ? "Following" : "Follow"}
</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}
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"}
@@ -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">
{isFollowed ? "Following ✓" : "Follow"}
</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}
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"}
@@ -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 */}
{tab !== "playlists" && (loadingVideos ? (
<div className="flex items-center justify-center py-8">