"use client"; import type { ReactNode } from "react"; import { useMemo, useState } from "react"; import { Avatar, Badge, Divider, Group, Popover, Progress, SimpleGrid, Stack, Text, UnstyledButton, } 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 } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; import { useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import classes from "./component.module.css"; type TranscodingDecision = 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, width, height, isEditMode, displayMode, }: WidgetComponentProps<"mediaServer">) { const currentStreamsQuery = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ integrationIds, showOnlyPlaying: options.showOnlyPlaying, }); const currentStreamsData = getUsableWidgetQueryData(currentStreamsQuery); const currentStreams = useMemo(() => currentStreamsData ?? [], [currentStreamsData]); const t = useScopedI18n("widget.mediaServer"); const isAdvanced = displayMode === "advanced"; const showLocation = isAdvanced || options.showLocation; const showBitrate = isAdvanced || options.showBitrate; const [selectedRowId, setSelectedRowId] = useState(null); const columns = useMemo[]>(() => { const result: MRT_ColumnDef[] = [ ...(width >= 300 || isAdvanced ? [ { accessorKey: "user.username", header: t("items.user"), size: 160, Cell: ({ row }) => ( {row.original.user?.username ?? t("items.unknownUser")} {row.original.sessionName} ), } satisfies MRT_ColumnDef, ] : []), { 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 }) => { return ( setSelectedRowId(opened ? row.id : null)} > ); }, } satisfies MRT_ColumnDef, ...(width >= 420 || isAdvanced ? [ { 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 location = showLocation ? currentlyPlaying.location : null; const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying.metadata?.bitrateKbps) : null; return ( {t(`items.${status}` as never)} {(location ?? bitrateLabel) && ( {location && (location === "lan" ? : )} {location && ( {location} )} {bitrateLabel && ( {bitrateLabel} )} )} ); }, } satisfies MRT_ColumnDef, ] : []), ]; return result; }, [isAdvanced, selectedRowId, showBitrate, showLocation, t, width]); // 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, integrationId: pair.integrationId, integrationKind: pair.integrationKind, integrationName: pair.integrationName, integrationIcon: getIconUrl(pair.integrationKind), })), ), [currentStreams], ); const table = useTranslatedMantineReactTable({ columns, data: flatSessions, enablePagination: false, enableTopToolbar: isAdvanced, enableBottomToolbar: false, enableSorting: isAdvanced, enableColumnActions: false, enableStickyHeader: false, enableColumnOrdering: false, enableRowSelection: false, enableFullScreenToggle: false, enableGlobalFilter: isAdvanced, enableDensityToggle: false, enableFilters: false, enableHiding: false, enableColumnPinning: false, initialState: { density: "xs", }, mantineTableHeadProps: { fz: "xs", }, mantineTableHeadCellProps: { py: 4, }, mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined, className: classes.tablePaper, }, mantineTableProps: { className: "media-server-widget-table", style: { tableLayout: "fixed", }, }, mantineTableContainerProps: { className: classes.tableContainer, style: { pointerEvents: isEditMode ? "none" : undefined, }, }, mantineTableBodyRowProps: ({ row }) => { const toggleDetails = () => setSelectedRowId((current) => (current === row.id ? null : row.id)); return { className: isEditMode ? undefined : classes.sessionRow, tabIndex: isEditMode ? -1 : 0, "aria-label": row.original.sessionName, "aria-haspopup": isEditMode ? undefined : "dialog", "aria-expanded": isEditMode ? undefined : selectedRowId === row.id, onClick: isEditMode ? undefined : toggleDetails, onKeyDown: isEditMode ? undefined : (event) => { if (event.currentTarget !== event.target) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); toggleDetails(); }, }; }, mantineTableBodyCellProps: () => ({ py: 4, style: { overflowX: "hidden", overflowY: "visible", }, }), }); const uniqueIntegrations = currentStreams.map((stream) => ({ integrationId: stream.integrationId, integrationKind: stream.integrationKind, integrationIcon: getIconUrl(stream.integrationKind), integrationName: stream.integrationName, })); const playingCount = flatSessions.filter( (session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused", ).length; const transcodingCount = flatSessions.filter((session) => { const status = getPlaybackStatus(session.currentlyPlaying?.metadata?.transcoding); return status !== "directPlay" && status !== "directStream"; }).length; const totalBitrateKbps = flatSessions.reduce( (sum, session) => sum + (session.currentlyPlaying?.metadata?.bitrateKbps ?? 0), 0, ); const totalBitrateLabel = showBitrate ? formatBitrate(totalBitrateKbps) : null; const hasFailedIntegrations = currentStreams.some(({ error }) => Boolean(error)); return ( {isAdvanced && ( {t("summary.sessions", { count: flatSessions.length })} {t("summary.playing", { count: playingCount })} {t("summary.transcoding", { count: transcodingCount })} )} {!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && ( )} {(isAdvanced || height >= 144) && ( {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { count: flatSessions.length, })} {totalBitrateLabel && (isAdvanced || width >= 300) && ( {t("footer.totalBitrate", { bitrate: totalBitrateLabel })} )} {uniqueIntegrations.map((integration) => ( {(isAdvanced || width >= 480) && ( {integration.integrationName} )} ))} )} ); } function CurrentlyPlaying({ item }: { item: StreamSession }) { const t = useScopedI18n("widget.mediaServer"); const currentlyPlaying = item.currentlyPlaying; if (!currentlyPlaying) { return ( {item.sessionName} ); } 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")} ) : ( remainingMinutes !== null && ( {t("items.remaining", { minutes: remainingMinutes.toString() })} ) )} {progressPercent !== null && ( )} ); } export function SessionDetailsPopover({ item, opened, onChange, children, }: { item: StreamSession; opened: boolean; onChange: (opened: boolean) => void; children: ReactNode; }) { return ( { event.stopPropagation(); onChange(!opened); }} > {children} ); } function SessionDetails({ item }: { item: StreamSession }) { const t = useScopedI18n("widget.mediaServer.items"); const Icon = item.currentlyPlaying ? mediaTypeIconMap[item.currentlyPlaying.type] : null; const metadata = item.currentlyPlaying?.metadata ? constructMetadata(item.currentlyPlaying.metadata) : null; return ( {Icon && item.currentlyPlaying && ( {item.currentlyPlaying.name} {item.currentlyPlaying.episodeName && ( {[item.currentlyPlaying.episodeName, item.currentlyPlaying.seasonName].filter(Boolean).join(" ยท ")} )} )} {item.user?.username ?? t("unknownUser")} } /> {item.sessionName} } /> {item.sessionId} } /> {metadata ? ( {objectEntries(metadata).map(([key, entries]) => ( {t(`metadata.${key}.title`)} {Object.entries(entries) .filter(([_, entryValue]) => Boolean(entryValue)) .map(([innerKey, entryValue]) => ( {t(`metadata.${key}.${innerKey}` as never)} {entryValue} ))} ))} ) : null} ); } 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(), }, });