Files
homarr/packages/widgets/src/media-server/component.tsx
T

574 lines
20 KiB
TypeScript

"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<NonNullable<StreamSession["currentlyPlaying"]>["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<PlaybackStatus, string>;
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<string | null>(null);
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(() => {
const result: MRT_ColumnDef<StreamSession>[] = [
...(width >= 300 || isAdvanced
? [
{
accessorKey: "user.username",
header: t("items.user"),
size: 160,
Cell: ({ row }) => (
<Group gap="xs" wrap="nowrap">
<Avatar size={28} src={row.original.user?.profilePictureUrl} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.original.user?.username ?? t("items.unknownUser")}
</Text>
<Text
size="10px"
c="dimmed"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{row.original.sessionName}
</Text>
</Stack>
</Group>
),
} satisfies MRT_ColumnDef<StreamSession>,
]
: []),
{
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 (
<SessionDetailsPopover
item={row.original}
opened={selectedRowId === row.id}
onChange={(opened) => setSelectedRowId(opened ? row.id : null)}
>
<CurrentlyPlaying item={row.original} />
</SessionDetailsPopover>
);
},
} satisfies MRT_ColumnDef<StreamSession>,
...(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 (
<Stack gap={4} align="flex-start">
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
{t(`items.${status}` as never)}
</Badge>
{(location ?? bitrateLabel) && (
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
<Group gap={4} align="center">
{location && (location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
{location && (
<Text size="10px" c="dimmed" tt="uppercase">
{location}
</Text>
)}
</Group>
{bitrateLabel && (
<Text size="10px" c="dimmed">
{bitrateLabel}
</Text>
)}
</Group>
)}
</Stack>
);
},
} satisfies MRT_ColumnDef<StreamSession>,
]
: []),
];
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 (
<Stack className={classes.root} gap={0} h="100%" display="flex">
{isAdvanced && (
<Group px="xs" py={4} gap="xs">
<Badge variant="light">{t("summary.sessions", { count: flatSessions.length })}</Badge>
<Badge variant="light" color="green">
{t("summary.playing", { count: playingCount })}
</Badge>
<Badge variant="light" color="orange">
{t("summary.transcoding", { count: transcodingCount })}
</Badge>
<Group ml="auto">
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
</Group>
)}
{!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && (
<Group px="xs" justify="flex-end">
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
<MantineReactTable table={table} />
{(isAdvanced || height >= 144) && (
<Group
gap="xs"
h={30}
px="xs"
pr="md"
justify="space-between"
style={{
borderTop: "1px solid var(--border-color)",
}}
>
<Group gap={4} wrap="nowrap">
<IconVideo size={16} style={{ flexShrink: 0 }} />
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length,
})}
</Text>
{totalBitrateLabel && (isAdvanced || width >= 300) && (
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
</Text>
)}
</Group>
<Group gap="xs">
{uniqueIntegrations.map((integration) => (
<Group key={integration.integrationId} gap="xs" align="center">
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
{(isAdvanced || width >= 480) && (
<Text className="media-server-name" size="sm" truncate="end">
{integration.integrationName}
</Text>
)}
</Group>
))}
</Group>
</Group>
)}
</Stack>
);
}
function CurrentlyPlaying({ item }: { item: StreamSession }) {
const t = useScopedI18n("widget.mediaServer");
const currentlyPlaying = item.currentlyPlaying;
if (!currentlyPlaying) {
return (
<Text size="xs" c="dimmed">
{item.sessionName}
</Text>
);
}
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 (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
<Icon size={16} color={isPaused ? "var(--mantine-color-yellow-6)" : undefined} style={{ flexShrink: 0 }} />
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
{currentlyPlaying.name}
</Text>
{isPaused ? (
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
{t("items.paused")}
</Text>
) : (
remainingMinutes !== null && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{t("items.remaining", { minutes: remainingMinutes.toString() })}
</Text>
)
)}
</Group>
{progressPercent !== null && (
<Progress
value={progressPercent}
size={4}
color={isPaused ? "yellow" : "green"}
style={{ backgroundColor: "var(--mantine-color-default-border)" }}
/>
)}
</Stack>
);
}
export function SessionDetailsPopover({
item,
opened,
onChange,
children,
}: {
item: StreamSession;
opened: boolean;
onChange: (opened: boolean) => void;
children: ReactNode;
}) {
return (
<Popover
opened={opened}
onChange={onChange}
position="bottom-start"
width="min(26.25rem, calc(100vw - 1.5rem))"
shadow="md"
withArrow
withinPortal
returnFocus
>
<Popover.Target>
<UnstyledButton
className={classes.detailsTarget}
aria-label={item.sessionName}
onClick={(event) => {
event.stopPropagation();
onChange(!opened);
}}
>
{children}
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<SessionDetails item={item} />
</Popover.Dropdown>
</Popover>
);
}
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 (
<Stack gap="sm" style={{ minWidth: 0 }}>
{Icon && item.currentlyPlaying && (
<Group gap="sm" align="flex-start" wrap="nowrap">
<Icon size={22} style={{ flexShrink: 0 }} />
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fw={600} lineClamp={2}>
{item.currentlyPlaying.name}
</Text>
{item.currentlyPlaying.episodeName && (
<Text size="xs" c="dimmed" lineClamp={2}>
{[item.currentlyPlaying.episodeName, item.currentlyPlaying.seasonName].filter(Boolean).join(" · ")}
</Text>
)}
</Stack>
</Group>
)}
<NormalizedLine
itemKey={t("user")}
value={
<Group gap="xs" align="center" wrap="nowrap">
<Avatar size="xs" src={item.user?.profilePictureUrl} />
<Text size="sm" truncate>
{item.user?.username ?? t("unknownUser")}
</Text>
</Group>
}
/>
<NormalizedLine
itemKey={t("name")}
value={
<Text size="sm" truncate>
{item.sessionName}
</Text>
}
/>
<NormalizedLine
itemKey={t("id")}
value={
<Text size="xs" ff="monospace" truncate>
{item.sessionId}
</Text>
}
/>
{metadata ? (
<Stack gap="xs">
<Divider label={t("metadata.title")} labelPosition="left" />
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm" verticalSpacing="xs">
{objectEntries(metadata).map(([key, entries]) => (
<Stack key={key} gap={4} style={{ minWidth: 0 }}>
<Text fw={600} size="xs">
{t(`metadata.${key}.title`)}
</Text>
{Object.entries(entries)
.filter(([_, entryValue]) => Boolean(entryValue))
.map(([innerKey, entryValue]) => (
<Group justify="space-between" gap="xs" key={innerKey} wrap="nowrap">
<Text size="xs" c="dimmed">
{t(`metadata.${key}.${innerKey}` as never)}
</Text>
<Text size="xs" ta="right" truncate>
{entryValue}
</Text>
</Group>
))}
</Stack>
))}
</SimpleGrid>
</Stack>
) : null}
</Stack>
);
}
const NormalizedLine = ({ itemKey, value }: { itemKey: string; value: ReactNode }) => {
return (
<Group w="100%" gap="md" align="flex-start" justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{itemKey}:
</Text>
<Stack gap={0} align="flex-end" style={{ minWidth: 0 }}>
{value}
</Stack>
</Group>
);
};
const mediaTypeIconMap = {
movie: IconMovie,
tv: IconDeviceTv,
video: IconVideo,
audio: IconHeadphones,
} satisfies Record<Exclude<StreamSession["currentlyPlaying"], null>["type"], TablerIcon>;
const constructMetadata = (metadata: Exclude<Exclude<StreamSession["currentlyPlaying"], null>["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(),
},
});