"use client"; import type { ReactNode } from "react"; import { useMemo, useState } from "react"; import { Avatar, Badge, Divider, Group, Popover, Progress, SimpleGrid, Stack, Table, Text, TextInput, UnstyledButton, } from "@mantine/core"; import { IconArrowsSort, IconChevronDown, IconChevronUp, IconDeviceTv, IconHeadphones, IconMovie, IconPlayerPause, IconSearch, IconVideo, IconWifi, IconWorld, } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { objectEntries } from "@homarr/common"; import { getIconUrl } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; import { useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import classes from "./component.module.css"; type TranscodingDecision = NonNullable["metadata"]>["transcoding"]; type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transcodeAudio" | "transcoding"; type SortColumn = "user" | "currentlyPlaying" | "status"; type SortState = { column: SortColumn; descending: boolean } | null; export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({ user: isAdvanced || width >= 300, // The user (26%) and status (22%) columns together need a floor here, otherwise the // currentlyPlaying column - the primary content - gets squeezed to almost nothing. status: isAdvanced || width >= 540, }); 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`; } const RESOLUTION_TIER_HEIGHTS = [4320, 2160, 1440, 1080, 720, 480, 360, 240] as const; export function getResolutionLabel(width: number, height: number): string { // Classify off the short axis (height for landscape, width for portrait) so orientation // doesn't flip which dimension drives the label. Letterboxed/cinemascope sources are cropped // shorter than their nominal resolution class (a 1920x804 encode is still "1080p"), so derive // a floor for the short axis from the long axis assuming 16:9 - matches how Plex/Jellyfin label these themselves. const isPortrait = height > width; const shortAxis = isPortrait ? width : height; const longAxis = isPortrait ? height : width; const effectiveShortAxis = Math.max(shortAxis, Math.round((longAxis * 9) / 16)); const tier = RESOLUTION_TIER_HEIGHTS.find((candidate) => effectiveShortAxis >= candidate * 0.9); return `${tier ?? effectiveShortAxis}p`; } export function getSeasonEpisodeParams( seasonNumber: number | null | undefined, episodeNumber: number | null | undefined, ): { season: string; episode: string } | null { if (seasonNumber === null || seasonNumber === undefined || episodeNumber === null || episodeNumber === undefined) { return null; } return { season: String(seasonNumber).padStart(2, "0"), episode: String(episodeNumber).padStart(2, "0") }; } const getSessionSortValue = (session: StreamSession, column: SortColumn) => { if (column === "user") return session.user?.username ?? session.sessionName; if (column === "currentlyPlaying") return session.currentlyPlaying?.name ?? session.sessionName; return getPlaybackStatus(session.currentlyPlaying?.metadata?.transcoding); }; const filterAndSortSessions = (sessions: T[], query: string, sort: SortState): T[] => { const normalizedQuery = query.trim().toLocaleLowerCase(); const filtered = normalizedQuery ? sessions.filter((session) => [session.user?.username, session.sessionName, session.currentlyPlaying?.name] .filter(Boolean) .some((value) => value?.toLocaleLowerCase().includes(normalizedQuery)), ) : sessions; if (!sort) return filtered; return filtered.toSorted((left, right) => { const comparison = getSessionSortValue(left, sort.column).localeCompare(getSessionSortValue(right, sort.column)); return sort.descending ? -comparison : comparison; }); }; function StreamTableHeader({ column, label, sortable, sort, onSort, width, }: { column: SortColumn; label: string; sortable: boolean; sort: SortState; onSort: (column: SortColumn) => void; width?: number | string; }) { const active = sort?.column === column; const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp; return ( {sortable ? ( onSort(column)}> {label} ) : ( {label} )} ); } 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 = useI18n("widget.mediaServer"); const tSearch = useI18n("search"); const isAdvanced = displayMode === "advanced"; const showLocation = isAdvanced || options.showLocation; const showBitrate = isAdvanced || options.showBitrate; const [selectedRowId, setSelectedRowId] = useState(null); const columnVisibility = getMediaServerColumnVisibility(width, isAdvanced); const [search, setSearch] = useState(""); const [sort, setSort] = useState(null); // 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 visibleSessions = useMemo( () => filterAndSortSessions(flatSessions, isAdvanced ? search : "", isAdvanced ? sort : null), [flatSessions, isAdvanced, search, sort], ); const toggleSort = (column: SortColumn) => setSort((current) => current?.column === column ? { column, descending: !current.descending } : { column, descending: false }, ); const uniqueIntegrations = currentStreams .filter((stream) => stream.sessions.length > 0) .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 })} setSearch(event.currentTarget.value)} placeholder={tSearch("placeholder")} aria-label={tSearch("placeholder")} leftSection={} /> )} {!isAdvanced && hasFailedIntegrations && ( )}
{columnVisibility.user && ( )} {columnVisibility.status && ( )} {visibleSessions.map((session) => { const rowId = `${session.integrationId}:${session.sessionId}`; const currentlyPlaying = session.currentlyPlaying; const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding); const location = showLocation ? currentlyPlaying?.location : null; const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : null; // The transcoding resolution reflects what's actually being streamed - fall back to the // source's own resolution when it isn't set (direct play, or a transcode that only // touches audio/container and leaves the video resolution untouched). const resolution = currentlyPlaying?.metadata?.transcoding.resolution ?? currentlyPlaying?.metadata?.video.resolution; const resolutionLabel = resolution ? getResolutionLabel(resolution.width, resolution.height) : null; const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId)); return ( { if (event.currentTarget !== event.target) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); toggleDetails(); } } > {columnVisibility.user && ( {session.user?.username ?? t("items.unknownUser")} {session.sessionName} )} setSelectedRowId(opened ? rowId : null)} > {columnVisibility.status && ( {currentlyPlaying && ( {t(`items.${status}` as never)} {location && ( {location === "lan" ? ( ) : ( )} {location} )} {(resolutionLabel ?? bitrateLabel) && ( {resolutionLabel && ( {resolutionLabel} )} {bitrateLabel && ( {bitrateLabel} )} )} )} )} ); })}
{(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 = useI18n("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; const seasonEpisodeParams = getSeasonEpisodeParams(currentlyPlaying.seasonNumber, currentlyPlaying.episodeNumber); const seasonEpisodeLabel = seasonEpisodeParams ? t("items.seasonEpisode", seasonEpisodeParams) : null; return ( {currentlyPlaying.name} {seasonEpisodeLabel && ( {seasonEpisodeLabel} )} {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 = useI18n("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(), }, });