"use client"; import type { ReactNode } from "react"; import { Fragment, useMemo } from "react"; import { Avatar, Divider, Flex, Group, Stack, Text, Title } from "@mantine/core"; import { IconDeviceTv, IconHeadphones, IconMovie, IconVideo } 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"; export default function MediaServerWidget({ options, integrationIds, isEditMode, }: WidgetComponentProps<"mediaServer">) { const [currentStreams] = clientApi.widget.mediaServer.getCurrentStreams.useSuspenseQuery( { integrationIds, showOnlyPlaying: options.showOnlyPlaying, }, { refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, }, ); const utils = clientApi.useUtils(); const t = useScopedI18n("widget.mediaServer"); const columns = useMemo[]>( () => [ { accessorKey: "sessionName", header: t("items.name"), Cell: ({ row }) => ( {row.original.sessionName} ), }, { accessorKey: "user.username", header: t("items.user"), Cell: ({ row }) => ( {row.original.user.username} ), }, { 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 }) => { if (!row.original.currentlyPlaying) return null; const Icon = mediaTypeIconMap[row.original.currentlyPlaying.type]; return ( {row.original.currentlyPlaying.name} ); }, }, ], [t], ); clientApi.widget.mediaServer.subscribeToCurrentStreams.useSubscription( { integrationIds, showOnlyPlaying: options.showOnlyPlaying, }, { enabled: !isEditMode, onData(data) { utils.widget.mediaServer.getCurrentStreams.setData( { integrationIds, showOnlyPlaying: options.showOnlyPlaying }, (previousData) => { return previousData?.map((pair) => { if (pair.integrationId === data.integrationId) { return { ...pair, sessions: data.data, }; } return pair; }); }, ); }, }, ); // 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"], }, }, 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, }), }); 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, }; }); return ( {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(), }, });