From a0d550ca1bc34e39773f3889295d6e315e43804d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Apr 2026 10:18:27 +0200 Subject: [PATCH] Add stream admin page --- api/src/controllers/streams.ts | 1 + front/public/translations/en.json | 16 + front/src/app/(app)/(tabs)/admin/_layout.tsx | 6 + front/src/app/(app)/(tabs)/admin/streams.tsx | 5 + front/src/primitives/container.tsx | 16 +- front/src/primitives/svg.d.ts | 1 + front/src/ui/admin/index.tsx | 1 + front/src/ui/admin/streams.tsx | 426 +++++++++++++++++++ front/src/ui/navbar.tsx | 6 + front/src/utils.ts | 4 + 10 files changed, 472 insertions(+), 10 deletions(-) create mode 100644 front/src/app/(app)/(tabs)/admin/streams.tsx create mode 100644 front/src/ui/admin/streams.tsx diff --git a/api/src/controllers/streams.ts b/api/src/controllers/streams.ts index 773633b6..ad5ccc5d 100644 --- a/api/src/controllers/streams.ts +++ b/api/src/controllers/streams.ts @@ -80,6 +80,7 @@ const RunningStream = t.Object({ export const streamsH = new Elysia({ tags: ["videos"] }).use(auth).get( "videos/streams", + // @ts-expect-error idk async ({ headers: { authorization, "accept-language": langs }, jwt: { sub, settings }, diff --git a/front/public/translations/en.json b/front/public/translations/en.json index ba92ce8b..17585e9d 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -388,6 +388,22 @@ "progress-pending": "{{count}} pending", "progress-failed": "{{count}} failed" }, + "streams": { + "title": "Streams", + "subtitle": "Currently playing videos and active transcodes.", + "empty": "No stream is running right now.", + "guest": "Guest", + "viewers": "Viewers", + "noActiveViewer": "No active viewer", + "watching": "Watching {{quality}}", + "none": "None", + "runningVideoTranscodes": "Video transcodes", + "runningAudioTranscodes": "Audio transcodes", + "progress": { + "available": "Available", + "transcoding": "Transcoding" + } + }, "add": { "title": "Add to library", "searchPlaceholder": "Search for a movie or series...", diff --git a/front/src/app/(app)/(tabs)/admin/_layout.tsx b/front/src/app/(app)/(tabs)/admin/_layout.tsx index 7943079b..3bf2b4a0 100644 --- a/front/src/app/(app)/(tabs)/admin/_layout.tsx +++ b/front/src/app/(app)/(tabs)/admin/_layout.tsx @@ -72,6 +72,12 @@ export default function AdminTabsLayout() { tabBarLabel: t("admin.unmatched.label"), }} /> + { - return ( - - ); + return ; }; + +Container.className = cn( + "flex w-full self-center px-4", + "sm:w-xl md:w-3xl lg:w-5xl xl:w-7xl", +); diff --git a/front/src/primitives/svg.d.ts b/front/src/primitives/svg.d.ts index 6f73d5fc..500e371f 100644 --- a/front/src/primitives/svg.d.ts +++ b/front/src/primitives/svg.d.ts @@ -1,6 +1,7 @@ declare module "*.svg" { import type React from "react"; import type { SvgProps } from "react-native-svg"; + const content: React.FC; export default content; } diff --git a/front/src/ui/admin/index.tsx b/front/src/ui/admin/index.tsx index 0abc8f6f..abd65760 100644 --- a/front/src/ui/admin/index.tsx +++ b/front/src/ui/admin/index.tsx @@ -1,3 +1,4 @@ export * from "./remap"; +export * from "./streams"; export * from "./users"; export * from "./videos-modal"; diff --git a/front/src/ui/admin/streams.tsx b/front/src/ui/admin/streams.tsx new file mode 100644 index 00000000..4a5b718e --- /dev/null +++ b/front/src/ui/admin/streams.tsx @@ -0,0 +1,426 @@ +import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; +import { useTranslation } from "react-i18next"; +import { FlatList, View } from "react-native"; +import { z } from "zod/v4"; +import { entryDisplayNumber } from "~/components/entries"; +import { + Episode, + type KImage, + MovieEntry, + Show, + Special, + User, +} from "~/models"; +import { + Avatar, + Container, + H2, + Heading, + HR, + IconButton, + Image, + Link, + P, + Skeleton, + SubP, + useBreakpointValue, +} from "~/primitives"; +import { type QueryIdentifier, useFetch } from "~/query"; +import { cn, uniq } from "~/utils"; +import { EmptyView } from "../empty-view"; +import { toTimerString } from "../player/controls/progress"; + +const Track = z.object({ + index: z.number(), + quality: z.string(), + heads: z.array( + z.object({ + start: z.number(), + end: z.number(), + startHead: z.number(), + endHead: z.number(), + isRunning: z.boolean(), + }), + ), +}); +type Track = z.infer; + +const ViewerTrack = z.object({ + index: z.number(), + quality: z.string(), + head: z.number(), +}); +type ViewerTrack = z.infer; + +const Stream = z.object({ + id: z.string(), + path: z.string(), + duration: z.number(), + show: Show.nullable(), + entries: z.array( + z.discriminatedUnion("kind", [ + Episode.omit({ progress: true, videos: true }), + MovieEntry.omit({ progress: true, videos: true }), + Special.omit({ progress: true, videos: true }), + ]), + ), + viewers: z.array( + z.object({ + user: User.nullable(), + progress: z.number().nullable(), + video: ViewerTrack.nullable(), + audio: ViewerTrack.nullable(), + }), + ), + videos: z.array(Track), + audios: z.array(Track), +}); +type Stream = z.infer; + +const StreamViewer = ({ + username, + logo, + progress, + duration, + video, + audio, +}: { + username: string; + logo?: string; + progress: number | null; + duration: number; + video: ViewerTrack | null; + audio: ViewerTrack | null; +}) => { + const { t } = useTranslation(); + + return ( + + + +

+ {username} +

+ + {t("admin.streams.watching", { + quality: uniq([video?.quality, audio?.quality]) + .filter((x) => x) + .join(" / "), + })} + +
+ {progress && ( + + {`${toTimerString(progress, duration)}/${toTimerString(duration)}`} + + )} + + ); +}; + +const StreamProgressBar = ({ + index, + quality, + duration, + heads, + viewers, +}: { + index: number; + quality: string; + duration: number; + heads: Track["heads"]; + viewers: { id: string; username: string; logo?: string; progress: number }[]; +}) => { + return ( + + + #{index} {quality} + + + {heads.map((head, headIndex) => ( + + ))} + {viewers.map((viewer) => ( + + + + ))} + + + ); +}; + +const StreamCard = ({ + id, + path, + name, + thumbnail, + duration, + viewers, + videos, + audios, +}: { + id: string; + path: string; + name: string | null; + thumbnail: KImage | null; + duration: number; + viewers: Stream["viewers"]; + videos: Stream["videos"]; + audios: Stream["videos"]; +}) => { + const { t } = useTranslation(); + + return ( + + + + + {name} + +

+ {path} +

+
+ + + {t("admin.streams.viewers")} + + {viewers.length === 0 ? ( + {t("admin.streams.noActiveViewer")} + ) : ( + viewers.map((x, i) => ( + + )) + )} + +
+ + + {t("admin.streams.runningVideoTranscodes")} + + + {videos.length === 0 ? ( + {t("admin.streams.none")} + ) : ( + videos.map((video) => ( + + x.progress && + x.video?.quality === video.quality && + x.video?.index === video.index, + ) + .map((x, i) => ({ + id: x.user?.id ?? i.toString(), + username: x.user?.username ?? t("admin.streams.guest"), + logo: x.user?.logo, + progress: x.progress!, + }))} + /> + )) + )} + + + {t("admin.streams.runningAudioTranscodes")} + + + {audios.length === 0 ? ( + {t("admin.streams.none")} + ) : ( + audios.map((audio) => ( + + x.progress && + x.audio?.quality === audio.quality && + x.audio?.index === audio.index, + ) + .map((x, i) => ({ + id: x.user?.id ?? i.toString(), + username: x.user?.username ?? t("admin.streams.guest"), + logo: x.user?.logo, + progress: x.progress!, + }))} + /> + )) + )} + + + + + {t("admin.streams.progress.available")} + + + + {t("admin.streams.progress.transcoding")} + + + + + ); +}; + +StreamCard.Loader = () => { + return ( + + + + +
+ + + + + + + +
+ ); +}; + +export const AdminStreamsPage = () => { + const { t } = useTranslation(); + const { data } = useFetch(AdminStreamsPage.query()); + const columns = useBreakpointValue({ xs: 1, md: 2, xl: 3 }); + + if (!data) { + return ( + +

{t("admin.streams.title")}

+ {t("admin.streams.subtitle")} + + {Array.from({ length: 6 }).map((_, index) => ( + 2 && "xl:w-1/3", + )} + > + + + ))} + +
+ ); + } + + if (data.length === 0) { + return ( + +

{t("admin.streams.title")}

+ {t("admin.streams.subtitle")} + +
+ ); + } + + return ( + +

{t("admin.streams.title")}

+ {t("admin.streams.subtitle")} +
+ } + contentContainerClassName={Container.className} + keyExtractor={(item) => item.id} + renderItem={({ item }) => ( + + + + )} + /> + ); +}; + +AdminStreamsPage.query = (): QueryIdentifier => ({ + parser: z.array(Stream), + path: ["api", "videos", "streams"], + refetchInterval: 5000, +}); diff --git a/front/src/ui/navbar.tsx b/front/src/ui/navbar.tsx index ec089f93..3ede1fde 100644 --- a/front/src/ui/navbar.tsx +++ b/front/src/ui/navbar.tsx @@ -5,6 +5,7 @@ import Close from "@material-symbols/svg-400/rounded/close.svg"; import Login from "@material-symbols/svg-400/rounded/login.svg"; import Logout from "@material-symbols/svg-400/rounded/logout.svg"; import Person from "@material-symbols/svg-400/rounded/person-fill.svg"; +import Play from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; import Search from "@material-symbols/svg-400/rounded/search-fill.svg"; import Settings from "@material-symbols/svg-400/rounded/settings.svg"; import { useIsFocused } from "@react-navigation/native"; @@ -71,6 +72,11 @@ export const NavbarLeft = () => { icon={Search} href="/admin/unmatched" /> + )} diff --git a/front/src/utils.ts b/front/src/utils.ts index 77d18d3d..1f13454e 100644 --- a/front/src/utils.ts +++ b/front/src/utils.ts @@ -81,6 +81,10 @@ export function shuffle(array: T[]): T[] { return array; } +export function uniq(a: T[]): T[] { + return uniqBy(a, (x) => x as string); +} + export function uniqBy(a: T[], key: (val: T) => string | number): T[] { const seen: Record = {}; return a.filter((item) => {