1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap). 1B Extract the manager's press-and-hold priority stepper into a shared PriorityStepper and add it to the channel detail header (subscribed, non-demo); backend channel_detail now returns the per-subscription `priority`. 2 Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same glass-card 3D depth + accent hover border as the feed cards (shadow-md is a transparent no-op here, so it's glass-card that provides the depth); clamp Plex card titles to a fixed 2-line height so 1- vs 2-line titles align. 3 Nav rail is now always a slim icon-only column, flush to the left edge; each icon reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the navCollapsed pref plumbing. 4 Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole full-height slot next to the rail), so approaching the nav no longer pops the filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out grace period + split hover/focus handler groups. 5 Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the transparent app) instead of only the inset <main> box. 6 Plex season binge continues across seasons: the season view's play queue now spans the whole show (allKeys), so a season's last episode rolls into the next season's first (seasonKeys stays for "add season to playlist"). 7 When a Plex video/episode ends with nothing queued next, the player returns to the previous page (onEnded → onClose) instead of stranding a black screen.
141 lines
5.1 KiB
TypeScript
141 lines
5.1 KiB
TypeScript
// Channel manager (subscribed + discovery), the channel About/detail page, ephemeral exploration
|
|
// of un-subscribed channels, and the per-user channel blocklist. Only touches core's req.
|
|
import { req } from "./core";
|
|
|
|
interface ChannelLink {
|
|
title: string | null;
|
|
url: string;
|
|
}
|
|
|
|
export interface ChannelDetail {
|
|
id: string;
|
|
title: string | null;
|
|
handle: string | null;
|
|
description: string | null;
|
|
thumbnail_url: string | null;
|
|
banner_url: string | null;
|
|
subscriber_count: number | null;
|
|
video_count: number | null;
|
|
total_view_count: number | null;
|
|
published_at: string | null;
|
|
external_links: ChannelLink[];
|
|
country: string | null;
|
|
default_language: string | null;
|
|
topic_categories: string[];
|
|
keywords: string | null;
|
|
subscribed: boolean;
|
|
explored: boolean;
|
|
blocked: boolean;
|
|
stored_videos: number;
|
|
deep_requested: boolean;
|
|
priority: number;
|
|
backfill_done: boolean;
|
|
from_explore: boolean;
|
|
details_synced: boolean;
|
|
tag_ids: number[];
|
|
}
|
|
|
|
export interface ExploreResult {
|
|
channel_id: string;
|
|
ingested: number;
|
|
next_page_token: string | null;
|
|
}
|
|
|
|
export interface MyStatus {
|
|
channels_total: number;
|
|
channels_details_synced: number;
|
|
channels_recent_synced: number;
|
|
channels_deep_done: number;
|
|
channels_recent_pending: number;
|
|
channels_deep_pending: number;
|
|
channels_deep_requested: number;
|
|
deep_pending_count: number;
|
|
deep_eta_seconds: number;
|
|
my_videos: number;
|
|
total_videos: number;
|
|
quota_used_today: number;
|
|
quota_remaining_today: number;
|
|
paused: boolean;
|
|
sync_active: boolean; // a channel-sync job (backfill/rss) is running right now
|
|
}
|
|
|
|
export interface ManagedChannel {
|
|
id: string;
|
|
title: string | null;
|
|
handle: string | null;
|
|
thumbnail_url: string | null;
|
|
subscriber_count: number | null;
|
|
video_count: number | null;
|
|
description: string | null;
|
|
stored_videos: number;
|
|
last_video_at: string | null;
|
|
total_duration_seconds: number;
|
|
count_normal: number;
|
|
count_short: number;
|
|
count_live: number;
|
|
priority: number;
|
|
hidden: boolean;
|
|
deep_requested: boolean;
|
|
deep_in_queue: boolean;
|
|
tag_ids: number[];
|
|
details_synced: boolean;
|
|
recent_synced: boolean;
|
|
backfill_done: boolean;
|
|
}
|
|
|
|
// A channel that shows up in the user's playlists but that they don't subscribe to —
|
|
// surfaced in the Channel manager's Discovery tab so they can subscribe in one click.
|
|
export interface DiscoveredChannel {
|
|
id: string;
|
|
title: string | null;
|
|
handle: string | null;
|
|
thumbnail_url: string | null;
|
|
subscriber_count: number | null;
|
|
video_count: number | null;
|
|
description: string | null;
|
|
playlist_video_count: number; // how many of the user's playlist videos are from here
|
|
playlist_count: number; // across how many of their playlists
|
|
details_synced: boolean;
|
|
}
|
|
|
|
export const channelsApi = {
|
|
myStatus: (): Promise<MyStatus> => req("/api/sync/my-status"),
|
|
channels: (): Promise<ManagedChannel[]> => req("/api/channels"),
|
|
updateChannel: (
|
|
id: string,
|
|
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean },
|
|
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
|
resetChannelBackfill: (id: string) =>
|
|
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
|
|
deepAll: (on = true): Promise<{ updated: number; deep_requested: boolean }> =>
|
|
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
|
attachChannelTag: (id: string, tagId: number) =>
|
|
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
|
|
detachChannelTag: (id: string, tagId: number) =>
|
|
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
|
|
unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
|
discoveredChannels: (): Promise<DiscoveredChannel[]> => req("/api/channels/discovery"),
|
|
subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }),
|
|
// --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
|
|
channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
|
|
// Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first,
|
|
// marked via_explore) + record the exploration. `pageToken` continues into older uploads
|
|
// ("load more"). `quiet` so a quota-reserve 429 / load error renders inline on the page.
|
|
exploreChannel: (id: string, pageToken?: string | null): Promise<ExploreResult> => {
|
|
const p = new URLSearchParams();
|
|
if (pageToken) p.set("page_token", pageToken);
|
|
const qs = p.toString();
|
|
return req(
|
|
`/api/channels/${id}/explore${qs ? `?${qs}` : ""}`,
|
|
{ method: "POST" },
|
|
{ quiet: true },
|
|
);
|
|
},
|
|
// --- per-user channel blocklist (excluded from live search + feed/explore) ---
|
|
blockedChannels: (): Promise<
|
|
{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]
|
|
> => req("/api/channels/blocked/list"),
|
|
blockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "POST" }),
|
|
unblockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "DELETE" }),
|
|
};
|