Merge pull request #6627 from homarr-labs/fix/v2-media-server-widget
fix(v2 media widget): updated media server widget
This commit is contained in:
8 files changed
+254
-50
No files matched your search
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
|||||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||||
|
|
||||||
The media server widget shows active sessions across your media servers.
|
The media server widget shows active sessions across your media servers.
|
||||||
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||||
|
|
||||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||||
|
|||||||
@@ -112,13 +112,15 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||||
|
|
||||||
if (sessionInfo.NowPlayingItem) {
|
if (sessionInfo.NowPlayingItem) {
|
||||||
|
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
|
||||||
|
|
||||||
currentlyPlaying = {
|
currentlyPlaying = {
|
||||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||||
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
|
||||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
|
||||||
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
|
||||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||||
playback: {
|
playback: {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { extractHost, parseLocation } from "./jellyfin-integration";
|
||||||
|
|
||||||
|
describe("extractHost", () => {
|
||||||
|
test("strips the port from an IPv4 endpoint", () => {
|
||||||
|
expect(extractHost("192.168.1.100:54321")).toBe("192.168.1.100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns an IPv4 address unchanged when there's no port", () => {
|
||||||
|
expect(extractHost("8.8.8.8")).toBe("8.8.8.8");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strips brackets and the port from a bracketed IPv6 endpoint", () => {
|
||||||
|
expect(extractHost("[::1]:8096")).toBe("::1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a bare IPv6 address unchanged when there's no port", () => {
|
||||||
|
expect(extractHost("::1")).toBe("::1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseLocation", () => {
|
||||||
|
test("returns null when there's no endpoint to classify", () => {
|
||||||
|
expect(parseLocation(null)).toBeNull();
|
||||||
|
expect(parseLocation(undefined)).toBeNull();
|
||||||
|
expect(parseLocation("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for a value that isn't an IP", () => {
|
||||||
|
expect(parseLocation("not-an-ip:1234")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("classifies private IPv4 ranges as lan", () => {
|
||||||
|
expect(parseLocation("192.168.1.100:54321")).toBe("lan");
|
||||||
|
expect(parseLocation("10.0.0.5:1234")).toBe("lan");
|
||||||
|
expect(parseLocation("172.16.5.5:1234")).toBe("lan");
|
||||||
|
expect(parseLocation("127.0.0.1:8096")).toBe("lan");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("classifies a public IPv4 address as wan", () => {
|
||||||
|
expect(parseLocation("8.8.8.8:1234")).toBe("wan");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("classifies IPv6 loopback and unique-local addresses as lan", () => {
|
||||||
|
expect(parseLocation("[::1]:8096")).toBe("lan");
|
||||||
|
expect(parseLocation("[fd12:3456:789a::1]:8096")).toBe("lan");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("classifies a public IPv6 address as wan", () => {
|
||||||
|
expect(parseLocation("[2606:4700:4700::1111]:8096")).toBe("wan");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unwraps an IPv4-mapped IPv6 address before classifying it", () => {
|
||||||
|
expect(parseLocation("[::ffff:192.168.1.5]:8096")).toBe("lan");
|
||||||
|
expect(parseLocation("[::ffff:8.8.8.8]:8096")).toBe("wan");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("classifies link-local addresses as lan", () => {
|
||||||
|
// Link-local traffic can't traverse a router, so it's always LAN even though
|
||||||
|
// it isn't a private range in the traditional (RFC 1918 / ULA) sense.
|
||||||
|
expect(parseLocation("169.254.1.5:8096")).toBe("lan");
|
||||||
|
expect(parseLocation("[fe80::1]:8096")).toBe("lan");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { BlockList, isIP } from "node:net";
|
||||||
import { Jellyfin } from "@jellyfin/sdk";
|
import { Jellyfin } from "@jellyfin/sdk";
|
||||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
@@ -21,6 +22,42 @@ function ticksToMs(ticks: number | null | undefined): number | null {
|
|||||||
return ticks ? Math.round(ticks / 10_000) : null;
|
return ticks ? Math.round(ticks / 10_000) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unlike Plex, Jellyfin doesn't report a session's network location directly - only the
|
||||||
|
// client's RemoteEndPoint (an IP, optionally with a port). Classify it ourselves.
|
||||||
|
const privateAddresses = new BlockList();
|
||||||
|
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
||||||
|
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
||||||
|
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
||||||
|
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
||||||
|
privateAddresses.addSubnet("169.254.0.0", 16, "ipv4");
|
||||||
|
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
||||||
|
privateAddresses.addSubnet("fe80::", 10, "ipv6");
|
||||||
|
privateAddresses.addAddress("::1", "ipv6");
|
||||||
|
|
||||||
|
export function extractHost(remoteEndPoint: string): string {
|
||||||
|
if (remoteEndPoint.startsWith("[")) {
|
||||||
|
const end = remoteEndPoint.indexOf("]");
|
||||||
|
return end === -1 ? remoteEndPoint : remoteEndPoint.slice(1, end);
|
||||||
|
}
|
||||||
|
// A bare IPv6 address (no brackets, no port) has more than one colon.
|
||||||
|
const isIPv4WithPort = remoteEndPoint.split(":").length === 2;
|
||||||
|
return isIPv4WithPort ? remoteEndPoint.slice(0, remoteEndPoint.lastIndexOf(":")) : remoteEndPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLocation(remoteEndPoint: string | null | undefined): "lan" | "wan" | null {
|
||||||
|
if (!remoteEndPoint) return null;
|
||||||
|
let host = extractHost(remoteEndPoint);
|
||||||
|
let family = isIP(host);
|
||||||
|
// A dual-stack server can report an IPv4 client as an IPv4-mapped IPv6 address
|
||||||
|
// (e.g. "::ffff:192.168.1.5") - unwrap it so the IPv4 private ranges still apply.
|
||||||
|
if (family === 6 && host.toLowerCase().startsWith("::ffff:") && isIP(host.slice(7)) === 4) {
|
||||||
|
host = host.slice(7);
|
||||||
|
family = 4;
|
||||||
|
}
|
||||||
|
if (!family) return null;
|
||||||
|
return privateAddresses.check(host, family === 4 ? "ipv4" : "ipv6") ? "lan" : "wan";
|
||||||
|
}
|
||||||
|
|
||||||
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
||||||
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||||
@@ -56,17 +93,27 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
|||||||
if (sessionInfo.NowPlayingItem) {
|
if (sessionInfo.NowPlayingItem) {
|
||||||
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
|
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
|
||||||
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
|
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
|
||||||
|
// Jellyfin only reliably fills in MediaSources[].Bitrate for a transcoding session -
|
||||||
|
// /Sessions has no `fields` selector to force it, so fall back to summing the individual
|
||||||
|
// media streams' own bitrates (populated from the source file's metadata) for direct play.
|
||||||
|
const mediaStreamsBitrateBps = sessionInfo.NowPlayingItem.MediaStreams?.reduce(
|
||||||
|
(sum, stream) => sum + (stream.BitRate ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
const bitrateBps =
|
const bitrateBps =
|
||||||
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null;
|
sessionInfo.TranscodingInfo?.Bitrate ??
|
||||||
|
sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ??
|
||||||
|
(mediaStreamsBitrateBps || null);
|
||||||
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
|
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
|
||||||
|
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
|
||||||
|
|
||||||
currentlyPlaying = {
|
currentlyPlaying = {
|
||||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||||
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
|
||||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
|
||||||
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
|
||||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||||
playback: {
|
playback: {
|
||||||
@@ -74,7 +121,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
|||||||
positionMs,
|
positionMs,
|
||||||
durationMs,
|
durationMs,
|
||||||
},
|
},
|
||||||
location: null,
|
location: parseLocation(sessionInfo.RemoteEndPoint),
|
||||||
metadata: {
|
metadata: {
|
||||||
video: {
|
video: {
|
||||||
resolution:
|
resolution:
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
|
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
|
||||||
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
|
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
|
||||||
seasonName: mediaElement.$.parentTitle,
|
seasonName: mediaElement.$.parentTitle,
|
||||||
seasonNumber: parseOptionalNumber(mediaElement.$.parentIndex),
|
seasonNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.parentIndex) : null,
|
||||||
episodeName: mediaElement.$.title ?? null,
|
episodeName: mediaElement.$.type === "episode" ? (mediaElement.$.title ?? null) : null,
|
||||||
episodeNumber: parseOptionalNumber(mediaElement.$.index),
|
episodeNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.index) : null,
|
||||||
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
||||||
episodeCount: parseOptionalNumber(mediaElement.$.index),
|
episodeCount: parseOptionalNumber(mediaElement.$.index),
|
||||||
playback: {
|
playback: {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
|
|
||||||
.tableViewport :global(th) {
|
.tableViewport :global(th) {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sortButton {
|
.sortButton {
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
|
|
||||||
import type { StreamSession } from "@homarr/integrations";
|
import type { StreamSession } from "@homarr/integrations";
|
||||||
|
|
||||||
import { getMediaServerColumnVisibility, getSeasonEpisodeParams, SessionDetailsPopover } from "./component";
|
import {
|
||||||
|
getMediaServerColumnVisibility,
|
||||||
|
getResolutionLabel,
|
||||||
|
getSeasonEpisodeParams,
|
||||||
|
SessionDetailsPopover,
|
||||||
|
} from "./component";
|
||||||
|
|
||||||
vi.mock("@homarr/translation/client", () => ({
|
vi.mock("@homarr/translation/client", () => ({
|
||||||
useScopedI18n: () => (key: string) => key,
|
useScopedI18n: () => (key: string) => key,
|
||||||
@@ -104,7 +109,8 @@ describe("getMediaServerColumnVisibility", () => {
|
|||||||
test("keeps a stable responsive column model from the zero-sized first render", () => {
|
test("keeps a stable responsive column model from the zero-sized first render", () => {
|
||||||
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
|
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
|
||||||
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
|
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
|
||||||
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true });
|
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false });
|
||||||
|
expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true });
|
||||||
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
|
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -124,3 +130,30 @@ describe("getSeasonEpisodeParams", () => {
|
|||||||
expect(getSeasonEpisodeParams(null, undefined)).toBeNull();
|
expect(getSeasonEpisodeParams(null, undefined)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getResolutionLabel", () => {
|
||||||
|
test("labels a standard 16:9 resolution by its exact height", () => {
|
||||||
|
expect(getResolutionLabel(1920, 1080)).toBe("1080p");
|
||||||
|
expect(getResolutionLabel(1280, 720)).toBe("720p");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("classifies a cinemascope source by its width, not its cropped height", () => {
|
||||||
|
// A 1920x804 encode (2.35:1, cropped top and bottom) is still a 1080p source.
|
||||||
|
expect(getResolutionLabel(1920, 804)).toBe("1080p");
|
||||||
|
expect(getResolutionLabel(1280, 640)).toBe("720p");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rounds a near-standard resolution up to its nearest tier", () => {
|
||||||
|
expect(getResolutionLabel(1912, 796)).toBe("1080p");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the raw height when it doesn't clear any known tier", () => {
|
||||||
|
expect(getResolutionLabel(100, 100)).toBe("100p");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("classifies a portrait video by its width, not its long-axis height", () => {
|
||||||
|
// A 1080x1920 portrait phone recording is still a 1080p source, not a 1440p one.
|
||||||
|
expect(getResolutionLabel(1080, 1920)).toBe("1080p");
|
||||||
|
expect(getResolutionLabel(720, 1280)).toBe("720p");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,7 +51,9 @@ type SortState = { column: SortColumn; descending: boolean } | null;
|
|||||||
|
|
||||||
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
|
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
|
||||||
user: isAdvanced || width >= 300,
|
user: isAdvanced || width >= 300,
|
||||||
status: isAdvanced || width >= 420,
|
// The user (200px) and status (190px) 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 {
|
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
|
||||||
@@ -77,6 +79,21 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null {
|
|||||||
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
|
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(
|
export function getSeasonEpisodeParams(
|
||||||
seasonNumber: number | null | undefined,
|
seasonNumber: number | null | undefined,
|
||||||
episodeNumber: number | null | undefined,
|
episodeNumber: number | null | undefined,
|
||||||
@@ -117,27 +134,33 @@ function StreamTableHeader({
|
|||||||
sortable,
|
sortable,
|
||||||
sort,
|
sort,
|
||||||
onSort,
|
onSort,
|
||||||
|
width,
|
||||||
}: {
|
}: {
|
||||||
column: SortColumn;
|
column: SortColumn;
|
||||||
label: string;
|
label: string;
|
||||||
sortable: boolean;
|
sortable: boolean;
|
||||||
sort: SortState;
|
sort: SortState;
|
||||||
onSort: (column: SortColumn) => void;
|
onSort: (column: SortColumn) => void;
|
||||||
|
width?: number;
|
||||||
}) {
|
}) {
|
||||||
const active = sort?.column === column;
|
const active = sort?.column === column;
|
||||||
const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp;
|
const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Th aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
|
<Table.Th w={width} aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
|
||||||
{sortable ? (
|
{sortable ? (
|
||||||
<UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}>
|
<UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}>
|
||||||
<Text component="span" size="xs" fw={600} truncate>
|
<Text component="span" size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<SortIcon size="var(--mantine-font-size-xs)" aria-hidden />
|
<SortIcon
|
||||||
|
size="var(--mantine-font-size-xs)"
|
||||||
|
aria-hidden
|
||||||
|
color={active ? undefined : "var(--mantine-color-dimmed)"}
|
||||||
|
/>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
) : (
|
) : (
|
||||||
<Text size="xs" fw={600} truncate>
|
<Text size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -195,12 +218,14 @@ export default function MediaServerWidget({
|
|||||||
current?.column === column ? { column, descending: !current.descending } : { column, descending: false },
|
current?.column === column ? { column, descending: !current.descending } : { column, descending: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const uniqueIntegrations = currentStreams.map((stream) => ({
|
const uniqueIntegrations = currentStreams
|
||||||
integrationId: stream.integrationId,
|
.filter((stream) => stream.sessions.length > 0)
|
||||||
integrationKind: stream.integrationKind,
|
.map((stream) => ({
|
||||||
integrationIcon: getIconUrl(stream.integrationKind),
|
integrationId: stream.integrationId,
|
||||||
integrationName: stream.integrationName,
|
integrationKind: stream.integrationKind,
|
||||||
}));
|
integrationIcon: getIconUrl(stream.integrationKind),
|
||||||
|
integrationName: stream.integrationName,
|
||||||
|
}));
|
||||||
|
|
||||||
const playingCount = flatSessions.filter(
|
const playingCount = flatSessions.filter(
|
||||||
(session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
|
(session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
|
||||||
@@ -263,6 +288,7 @@ export default function MediaServerWidget({
|
|||||||
sortable={isAdvanced}
|
sortable={isAdvanced}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSort={toggleSort}
|
onSort={toggleSort}
|
||||||
|
width={200}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<StreamTableHeader
|
<StreamTableHeader
|
||||||
@@ -279,6 +305,7 @@ export default function MediaServerWidget({
|
|||||||
sortable={isAdvanced}
|
sortable={isAdvanced}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSort={toggleSort}
|
onSort={toggleSort}
|
||||||
|
width={190}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -290,6 +317,12 @@ export default function MediaServerWidget({
|
|||||||
const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding);
|
const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding);
|
||||||
const location = showLocation ? currentlyPlaying?.location : null;
|
const location = showLocation ? currentlyPlaying?.location : null;
|
||||||
const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : 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));
|
const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -315,7 +348,7 @@ export default function MediaServerWidget({
|
|||||||
{columnVisibility.user && (
|
{columnVisibility.user && (
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs" wrap="nowrap" w="100%">
|
<Group gap="xs" wrap="nowrap" w="100%">
|
||||||
<Avatar size={28} src={session.user?.profilePictureUrl} />
|
<Avatar size={28} src={session.user?.profilePictureUrl} style={{ flexShrink: 0 }} />
|
||||||
<Stack gap={2} className={classes.cellContent}>
|
<Stack gap={2} className={classes.cellContent}>
|
||||||
<Text size="xs" truncate>
|
<Text size="xs" truncate>
|
||||||
{session.user?.username ?? t("items.unknownUser")}
|
{session.user?.username ?? t("items.unknownUser")}
|
||||||
@@ -340,24 +373,30 @@ export default function MediaServerWidget({
|
|||||||
<Table.Td>
|
<Table.Td>
|
||||||
{currentlyPlaying && (
|
{currentlyPlaying && (
|
||||||
<Stack gap={4} align="flex-start" w="100%" className={classes.cellContent}>
|
<Stack gap={4} align="flex-start" w="100%" className={classes.cellContent}>
|
||||||
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
|
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
||||||
{t(`items.${status}` as never)}
|
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
|
||||||
</Badge>
|
{t(`items.${status}` as never)}
|
||||||
{(location ?? bitrateLabel) && (
|
</Badge>
|
||||||
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
{location && (
|
||||||
<Group gap={4} align="center" wrap="nowrap">
|
<Group gap={4} align="center" wrap="nowrap">
|
||||||
{location &&
|
{location === "lan" ? (
|
||||||
(location === "lan" ? (
|
<IconWifi size="var(--mantine-font-size-xs)" />
|
||||||
<IconWifi size="var(--mantine-font-size-xs)" />
|
) : (
|
||||||
) : (
|
<IconWorld size="var(--mantine-font-size-xs)" />
|
||||||
<IconWorld size="var(--mantine-font-size-xs)" />
|
|
||||||
))}
|
|
||||||
{location && (
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase">
|
|
||||||
{location}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase">
|
||||||
|
{location}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{(resolutionLabel ?? bitrateLabel) && (
|
||||||
|
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
||||||
|
{resolutionLabel && (
|
||||||
|
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{resolutionLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{bitrateLabel && (
|
{bitrateLabel && (
|
||||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
{bitrateLabel}
|
{bitrateLabel}
|
||||||
@@ -386,25 +425,42 @@ export default function MediaServerWidget({
|
|||||||
borderTop: "1px solid var(--border-color)",
|
borderTop: "1px solid var(--border-color)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
|
||||||
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
||||||
count: flatSessions.length,
|
count: flatSessions.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
{totalBitrateLabel && (isAdvanced || width >= 300) && (
|
{totalBitrateLabel && (isAdvanced || width >= 300) && (
|
||||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
<>
|
||||||
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
</Text>
|
•
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
<Group gap={6}>
|
||||||
{uniqueIntegrations.map((integration) => (
|
{uniqueIntegrations.map((integration) => (
|
||||||
<Group key={integration.integrationId} gap="xs" align="center">
|
<Group
|
||||||
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
|
key={integration.integrationId}
|
||||||
|
gap={6}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
pl={2}
|
||||||
|
pr={8}
|
||||||
|
py={2}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--mantine-color-default-hover)",
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar className="media-server-icon" src={integration.integrationIcon} radius="xl" size={18} />
|
||||||
{(isAdvanced || width >= 480) && (
|
{(isAdvanced || width >= 480) && (
|
||||||
<Text className="media-server-name" size="sm" truncate="end">
|
<Text className="media-server-name" size="xs" fw={500} truncate="end">
|
||||||
{integration.integrationName}
|
{integration.integrationName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user