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) => {