"use client"; import type { ReactNode } from "react"; import { Fragment, useMemo } from "react"; import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core"; import { IconDeviceTv, IconHeadphones, IconMovie, IconPlayerPause, IconVideo, IconWifi, IconWorld, } from "@tabler/icons-react"; import type { MRT_ColumnDef } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table"; import { clientApi } from "@homarr/api/client"; import { objectEntries } from "@homarr/common"; import { getIconUrl, integrationDefs } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; import { createModal, useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import type { WidgetComponentProps } from "../definition"; type TranscodingDecision = NonNullable< NonNullable["metadata"] >["transcoding"]; type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transcodeAudio" | "transcoding"; function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus { if (!transcoding) return "directPlay"; const { isVideoDirect, isAudioDirect, containerChanged } = transcoding; if (isVideoDirect && isAudioDirect) return containerChanged ? "directStream" : "directPlay"; if (!isVideoDirect && isAudioDirect) return "transcodeVideo"; if (isVideoDirect && !isAudioDirect) return "transcodeAudio"; return "transcoding"; } const playbackStatusColorMap = { directPlay: "green", directStream: "teal", transcodeVideo: "orange", transcodeAudio: "yellow", transcoding: "red", } satisfies Record; function formatBitrate(bitrateKbps: number | null | undefined): string | null { if (!bitrateKbps || bitrateKbps <= 0) return null; return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`; } export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) { const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ integrationIds, showOnlyPlaying: options.showOnlyPlaying, }); const t = useScopedI18n("widget.mediaServer"); const columns = useMemo[]>( () => [ { accessorKey: "user.username", header: t("items.user"), size: 160, Cell: ({ row }) => ( {row.original.user.username} {row.original.sessionName} ), }, { accessorKey: "currentlyPlaying", // currentlyPlaying.name can be undefined which results in a warning. This is why we use currentlyPlaying instead of currentlyPlaying.name header: t("items.currentlyPlaying"), Cell: ({ row }) => { const currentlyPlaying = row.original.currentlyPlaying; if (!currentlyPlaying) return null; const playback = currentlyPlaying.playback; const isPaused = playback?.state === "paused"; const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type]; const positionMs = playback?.positionMs ?? null; const durationMs = playback?.durationMs ?? null; const progressPercent = positionMs !== null && durationMs !== null && durationMs > 0 ? Math.min(100, Math.round((positionMs / durationMs) * 100)) : null; const remainingMinutes = positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null; return ( {currentlyPlaying.name} {isPaused && ( {t("items.paused")} )} {!isPaused && remainingMinutes !== null && ( {t("items.remaining", { minutes: remainingMinutes.toString() })} )} {progressPercent !== null && ( )} ); }, }, { id: "status", header: t("items.status"), size: 110, Cell: ({ row }) => { const currentlyPlaying = row.original.currentlyPlaying; if (!currentlyPlaying) return null; const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding); const bitrateLabel = formatBitrate(currentlyPlaying.metadata?.bitrateKbps); return ( {t(`items.${status}` as never)} {(currentlyPlaying.location ?? bitrateLabel) && ( {currentlyPlaying.location && (currentlyPlaying.location === "lan" ? : )} {currentlyPlaying.location && ( {currentlyPlaying.location} )} {bitrateLabel && ( {bitrateLabel} )} )} ); }, }, ], [t], ); // Only render the flat list of sessions when the currentStreams change // Otherwise it will always create a new array reference and cause the table to re-render const flatSessions = useMemo( () => currentStreams.flatMap((pair) => pair.sessions.map((session) => ({ ...session, integrationKind: pair.integrationKind, integrationName: integrationDefs[pair.integrationKind].name, integrationIcon: getIconUrl(pair.integrationKind), })), ), [currentStreams], ); const { openModal } = useModalAction(ItemInfoModal); const table = useTranslatedMantineReactTable({ columns, data: flatSessions, enablePagination: false, enableTopToolbar: false, enableBottomToolbar: false, enableSorting: false, enableColumnActions: false, enableStickyHeader: false, enableColumnOrdering: false, enableRowSelection: false, enableFullScreenToggle: false, enableGlobalFilter: false, enableDensityToggle: false, enableFilters: false, enableHiding: false, enableColumnPinning: true, initialState: { density: "xs", columnPinning: { right: ["currentlyPlaying", "status"], }, }, mantineTableHeadProps: { fz: "xs", }, mantineTableHeadCellProps: { py: 4, }, mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined, }, mantineTableProps: { className: "media-server-widget-table", style: { tableLayout: "fixed", }, }, mantineTableContainerProps: { style: { height: "100%", }, }, mantineTableBodyCellProps: ({ row }) => ({ onClick: () => { openModal( { item: row.original, }, { title: row.original.sessionName, }, ); }, py: 4, style: { overflowX: "hidden", overflowY: "visible", }, }), }); const uniqueIntegrations = Array.from(new Set(flatSessions.map((session) => session.integrationKind))).map((kind) => { const session = flatSessions.find((session) => session.integrationKind === kind); return { integrationKind: kind, integrationIcon: session?.integrationIcon, integrationName: session?.integrationName, }; }); const totalBitrateKbps = flatSessions.reduce( (sum, session) => sum + (session.currentlyPlaying?.metadata?.bitrateKbps ?? 0), 0, ); const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null; return ( {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { count: flatSessions.length, })} {totalBitrateLabel && ( {t("footer.totalBitrate", { bitrate: totalBitrateLabel })} )} {uniqueIntegrations.map((integration) => ( {integration.integrationName} ))} ); } const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => { const t = useScopedI18n("widget.mediaServer.items"); const Icon = innerProps.item.currentlyPlaying ? mediaTypeIconMap[innerProps.item.currentlyPlaying.type] : null; const metadata = useMemo(() => { return innerProps.item.currentlyPlaying?.metadata ? constructMetadata(innerProps.item.currentlyPlaying.metadata) : null; }, [innerProps.item.currentlyPlaying?.metadata]); return ( {Icon && innerProps.item.currentlyPlaying !== null && ( {innerProps.item.currentlyPlaying.name} )} {innerProps.item.currentlyPlaying?.episodeName && ( {innerProps.item.currentlyPlaying.episodeName} {innerProps.item.currentlyPlaying.seasonName && ( <> {" - "} {innerProps.item.currentlyPlaying.seasonName} )} )} {" "} {innerProps.item.user.username} } /> {innerProps.item.sessionName}} /> {innerProps.item.sessionId}} /> {metadata ? ( {objectEntries(metadata).map(([key, value], index) => ( {index !== 0 && } {t(`metadata.${key}.title`)} {Object.entries(value) .filter(([_, value]) => Boolean(value)) .map(([innerKey, value]) => ( {t(`metadata.${key}.${innerKey}` as never)} {value} ))} ))} ) : null} ); }).withOptions({ defaultTitle() { return ""; }, size: "lg", centered: true, }); const NormalizedLine = ({ itemKey, value }: { itemKey: string; value: ReactNode }) => { return ( {itemKey}: {value} ); }; const mediaTypeIconMap = { movie: IconMovie, tv: IconDeviceTv, video: IconVideo, audio: IconHeadphones, } satisfies Record["type"], TablerIcon>; const constructMetadata = (metadata: Exclude["metadata"], null>) => ({ video: { resolution: metadata.video.resolution ? `${metadata.video.resolution.width}x${metadata.video.resolution.height}` : null, frameRate: metadata.video.frameRate, }, audio: { channelCount: metadata.audio.channelCount, codec: metadata.audio.codec, }, transcoding: { container: metadata.transcoding.container, resolution: metadata.transcoding.resolution ? `${metadata.transcoding.resolution.width}x${metadata.transcoding.resolution.height}` : null, target: `${metadata.transcoding.target.videoCodec} ${metadata.transcoding.target.audioCodec}`.trim(), }, });