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:
@@ -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,
|
||||||
|
|||||||
@@ -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 = "") =>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user