feat(Media player widget): expands on Direct Play and Transcoding badges (#6489)

This commit is contained in:
Bergasha authored and GitHub committed 2026-08-01 23:21:13 +02:00
1 parent 27ca058088
commit 49314533bf
6 files changed
+126 -21

No files matched your search

@@ -41,7 +41,11 @@ export interface StreamSession {
audioCodec: string | null;
videoCodec: string | null;
};
isVideoDirect: boolean;
isAudioDirect: boolean;
containerChanged: boolean;
};
bitrateKbps: number | null;
} | null;
} | null;
}
@@ -56,6 +56,9 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
const bitrateBps =
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null;
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
@@ -98,7 +101,11 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
videoCodec: sessionInfo.TranscodingInfo?.VideoCodec ?? null,
},
container: sessionInfo.TranscodingInfo?.Container ?? null,
isVideoDirect: sessionInfo.TranscodingInfo?.IsVideoDirect ?? true,
isAudioDirect: sessionInfo.TranscodingInfo?.IsAudioDirect ?? true,
containerChanged: sessionInfo.PlayState?.PlayMethod === "DirectStream",
},
bitrateKbps,
},
};
}
@@ -43,6 +43,12 @@ function parseResolution(
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
}
function isStreamDirect(decision: string | undefined): boolean {
// Plex reports "transcode" when a stream is re-encoded. "copy" (remuxed
// without re-encoding) and an absent decision both count as direct.
return decision !== "transcode";
}
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey");
@@ -87,6 +93,16 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const videoStream = streams.find((stream) => stream.$.streamType === "1");
const audioStream = streams.find((stream) => stream.$.streamType === "2");
const isVideoDirect = isStreamDirect(transcodeElement?.$.videoDecision);
const isAudioDirect = isStreamDirect(transcodeElement?.$.audioDecision);
const containerChanged = Boolean(
transcodeElement?.$.container &&
mediaInfoElement?.$.container &&
transcodeElement.$.container !== mediaInfoElement.$.container,
);
const bitrateKbps =
parseOptionalNumber(sessionElement?.$.bandwidth) ?? parseOptionalNumber(mediaInfoElement?.$.bitrate);
return {
sessionId: sessionElement?.$.id ?? "unknown",
sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
@@ -124,7 +140,11 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
audioCodec: transcodeElement?.$.audioCodec ?? null,
videoCodec: transcodeElement?.$.videoCodec ?? null,
},
isVideoDirect,
isAudioDirect,
containerChanged,
},
bitrateKbps,
},
},
};
+15
View File
@@ -2736,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Show only currently playing",
"description": "Disabling this will not work for plex"
},
"showBitrate": {
"label": "Show bitrate",
"description": "Show the current playback bitrate next to the location"
},
"showLocation": {
"label": "Show location",
"description": "Show whether the stream is on your LAN or WAN"
}
},
"footer": {
"streams": "{count, plural, one {# active stream} other {# active streams}}",
"totalBitrate": "Total: {bitrate}"
},
"items": {
"currentlyPlaying": "Currently playing",
"user": "User",
@@ -2745,6 +2757,9 @@
"id": "Id",
"status": "Status",
"directPlay": "Direct play",
"directStream": "Direct stream",
"transcodeVideo": "Transcoding video",
"transcodeAudio": "Transcoding audio",
"transcoding": "Transcoding",
"paused": "Paused",
"remaining": "{minutes} min left",
+78 -21
View File
@@ -26,6 +26,35 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
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 }: WidgetComponentProps<"mediaServer">) {
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
@@ -122,23 +151,30 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const isTranscoding = Boolean(
currentlyPlaying.metadata?.transcoding.target.videoCodec ??
currentlyPlaying.metadata?.transcoding.target.audioCodec ??
currentlyPlaying.metadata?.transcoding.container,
);
const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding);
const bitrateLabel = formatBitrate(currentlyPlaying.metadata?.bitrateKbps);
return (
<Stack gap={4} align="flex-start">
<Badge size="xs" variant="light" color={isTranscoding ? "orange" : "green"}>
{isTranscoding ? t("items.transcoding") : t("items.directPlay")}
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
{t(`items.${status}` as never)}
</Badge>
{currentlyPlaying.location && (
<Group gap={4} align="center">
{currentlyPlaying.location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />}
<Text size="10px" c="dimmed" tt="uppercase">
{currentlyPlaying.location}
</Text>
{(currentlyPlaying.location ?? bitrateLabel) && (
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
<Group gap={4} align="center">
{currentlyPlaying.location &&
(currentlyPlaying.location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
{currentlyPlaying.location && (
<Text size="10px" c="dimmed" tt="uppercase">
{currentlyPlaying.location}
</Text>
)}
</Group>
{bitrateLabel && (
<Text size="10px" c="dimmed">
{bitrateLabel}
</Text>
)}
</Group>
)}
</Stack>
@@ -238,6 +274,12 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
};
});
const totalBitrateKbps = flatSessions.reduce(
(sum, session) => sum + (session.currentlyPlaying?.metadata?.bitrateKbps ?? 0),
0,
);
const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null;
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
@@ -246,19 +288,34 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
h={30}
px="xs"
pr="md"
justify="flex-end"
justify="space-between"
style={{
borderTop: "1px solid var(--border-color)",
}}
>
{uniqueIntegrations.map((integration) => (
<Group key={integration.integrationKind} gap="xs" align="center">
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
<Text className="media-server-name" size="sm">
{integration.integrationName}
<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 && (
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
</Text>
</Group>
))}
)}
</Group>
<Group gap="xs">
{uniqueIntegrations.map((integration) => (
<Group key={integration.integrationKind} gap="xs" align="center">
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
<Text className="media-server-name" size="sm">
{integration.integrationName}
</Text>
</Group>
))}
</Group>
</Group>
</Stack>
);
@@ -11,6 +11,8 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ
createOptions() {
return optionsBuilder.from((factory) => ({
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
showBitrate: factory.switch({ defaultValue: true, withDescription: true }),
showLocation: factory.switch({ defaultValue: true, withDescription: true }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaService"),