diff --git a/packages/widgets/src/media-server/component.spec.tsx b/packages/widgets/src/media-server/component.spec.tsx index 0f23b232c..8e501ee6c 100644 --- a/packages/widgets/src/media-server/component.spec.tsx +++ b/packages/widgets/src/media-server/component.spec.tsx @@ -9,7 +9,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { StreamSession } from "@homarr/integrations"; -import { getMediaServerColumnVisibility, getSeasonEpisodeParams, SessionDetailsPopover } from "./component"; +import { + getMediaServerColumnVisibility, + getResolutionLabel, + getSeasonEpisodeParams, + SessionDetailsPopover, +} from "./component"; vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key, @@ -124,3 +129,24 @@ describe("getSeasonEpisodeParams", () => { 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"); + }); +}); diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 4118ff7ef..b73822ec9 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -77,6 +77,17 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | 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 { + // Letterboxed/cinemascope sources are cropped shorter than their nominal resolution class + // (a 1920x804 encode is still "1080p"), so classify off the width-derived 16:9 height instead + // of the raw (possibly cropped) height - matches how Plex/Jellyfin label these themselves. + const effectiveHeight = Math.max(height, Math.round((width * 9) / 16)); + const tier = RESOLUTION_TIER_HEIGHTS.find((candidate) => effectiveHeight >= candidate * 0.9); + return `${tier ?? effectiveHeight}p`; +} + export function getSeasonEpisodeParams( seasonNumber: number | null | undefined, episodeNumber: number | null | undefined, @@ -305,7 +316,7 @@ export default function MediaServerWidget({ // touches audio/container and leaves the video resolution untouched). const resolution = currentlyPlaying?.metadata?.transcoding.resolution ?? currentlyPlaying?.metadata?.video.resolution; - const resolutionLabel = resolution ? `${resolution.height}p` : null; + const resolutionLabel = resolution ? getResolutionLabel(resolution.width, resolution.height) : null; const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId)); return (