diff --git a/src/components/player/EndOfVideoCard.tsx b/src/components/player/EndOfVideoCard.tsx index 5a0f3c2..c63d7af 100644 --- a/src/components/player/EndOfVideoCard.tsx +++ b/src/components/player/EndOfVideoCard.tsx @@ -124,14 +124,15 @@ export default function EndOfVideoCard({ } function RateAndLogRow({ itemId, itemName }: { itemId?: string; itemName: string }) { - if (!itemId || String(itemId).startsWith('tmdb-')) return null const [hoverRating, setHoverRating] = useState(0) - const personal = usePersonalData(s => s.entries[itemId]) + const personal = usePersonalData(s => itemId ? s.entries[itemId] : undefined) const setRating = usePersonalData(s => s.setRating) const addDiary = useDiary(s => s.add) const current = personal?.rating || 0 const [logged, setLogged] = useState(false) + if (!itemId || String(itemId).startsWith('tmdb-')) return null + return (
diff --git a/src/components/player/StreamInfo.tsx b/src/components/player/StreamInfo.tsx index f5c974a..291f335 100644 --- a/src/components/player/StreamInfo.tsx +++ b/src/components/player/StreamInfo.tsx @@ -13,6 +13,7 @@ import { videoRangeLabel, } from '../../lib/jellyfin-meta' import { formatBitrate } from '../../lib/format' +import { isHdrDisplayActive } from '../../lib/device-profile' interface Props { item?: BaseItemDto | null @@ -58,7 +59,7 @@ function readLiveStats(): LiveStats { } } -async function checkHwDecode(stream: { Codec?: string | null; Width?: number | null; Height?: number | null; BitRate?: number | null } | null): Promise { +async function checkHwDecode(stream: { Codec?: string | null; Width?: number | null; Height?: number | null; BitRate?: number | null; VideoRangeType?: string | null; VideoRange?: string | null } | null): Promise { if (!stream || typeof navigator.mediaCapabilities?.decodingInfo !== 'function') { return { supported: null, hwAccelerated: null } } @@ -66,9 +67,15 @@ async function checkHwDecode(stream: { Codec?: string | null; Width?: number | n const w = stream.Width ?? 1920 const h = stream.Height ?? 1080 const br = stream.BitRate ?? 8000000 - // Map common Jellyfin codec names to MIME codec strings + // Use the main10 profile codec string for HDR content so the + // MediaCapabilities query matches what the actual stream requires. + // The previous main (8-bit) string caused HDR sources to probe + // against the wrong profile. + const range = (stream.VideoRangeType || stream.VideoRange || '').toUpperCase() + const isHdr = range === 'HDR' || range === 'HDR10' || range === 'HLG' || range.startsWith('DOVI') const mimeCodec = codec === 'h264' ? 'avc1.640033' + : codec === 'hevc' && isHdr ? 'hev1.2.4.L153.B0' : codec === 'hevc' ? 'hev1.1.6.L150.90' : codec === 'av1' ? 'av01.0.05M.08' : codec === 'vp9' ? 'vp09.00.50.08' @@ -164,7 +171,7 @@ export default function StreamInfo({ item, visible, playMethod }: Props) { if (!visible) return const v = getVideoStream(item || {}) checkHwDecode(v).then(setHw) - }, [visible, item?.Id]) + }, [visible, item]) if (!visible || !item) return null const source = pickPrimarySource(item) @@ -192,6 +199,21 @@ export default function StreamInfo({ item, visible, playMethod }: Props) { const res = resolutionLabel(item) const range = videoRangeLabel(item) + + // HDR display status - tells the user whether their display is actually + // in HDR mode. When the source is HDR but the display is SDR, the browser + // tone-maps the content. If the profile was built with HDR display off, + // the server did FFmpeg tone-mapping instead. + const hdrDisplay = isHdrDisplayActive() + if (range && range !== 'SDR') { + rows.push({ + label: 'HDR display', + value: hdrDisplay ? 'Active' : 'Off (tone-mapped)', + accent: hdrDisplay, + warn: !hdrDisplay, + }) + } + if (res) rows.push({ label: 'Resolution', value: range ? `${res} ยท ${range}` : res }) if (v) { rows.push({ label: 'Video', value: videoCodecLabel(v) }) diff --git a/src/components/player/SubtitleOverlay.tsx b/src/components/player/SubtitleOverlay.tsx index 2fbc49a..01bb019 100644 --- a/src/components/player/SubtitleOverlay.tsx +++ b/src/components/player/SubtitleOverlay.tsx @@ -54,7 +54,7 @@ function parseVTT(raw: string): Cue[] { // Accept both WebVTT dots and SRT commas for the milliseconds separator. // Jellyfin sometimes serves SRT content even on the .vtt endpoint. const m = lines[tsLine].match( - /(\d+:)?(\d+):(\d+)[\.,](\d+)\s+-->\s+(\d+:)?(\d+):(\d+)[\.,](\d+)/, + /(\d+:)?(\d+):(\d+)[.,](\d+)\s+-->\s+(\d+:)?(\d+):(\d+)[.,](\d+)/, ) if (!m) continue const start = parseTimeStamp(m[1], m[2], m[3], m[4]) diff --git a/src/hooks/use-prebuffer.ts b/src/hooks/use-prebuffer.ts index 230e701..1cfff49 100644 --- a/src/hooks/use-prebuffer.ts +++ b/src/hooks/use-prebuffer.ts @@ -19,15 +19,16 @@ import type { BaseItemDto } from '../api/types' */ export function usePrebuffer(item: BaseItemDto | null | undefined, armed: boolean) { const qc = useQueryClient() + const itemId = item?.Id + const itemType = item?.Type useEffect(() => { if (!armed) return - if (!item || !item.Id || String(item.Id).startsWith('tmdb-')) return + if (!itemId || String(itemId).startsWith('tmdb-')) return // Episodes / movies are the only item kinds that go through PlaybackInfo // for streaming; series / seasons would need a child resolution first. - if (item.Type !== 'Movie' && item.Type !== 'Episode') return + if (itemType !== 'Movie' && itemType !== 'Episode') return - const itemId = item.Id let cancelled = false const key = ['jellyfin', 'playback-info', itemId, undefined, undefined, undefined] @@ -76,5 +77,5 @@ export function usePrebuffer(item: BaseItemDto | null | undefined, armed: boolea }).catch(() => { /* warm-only; ignore */ }) return () => { cancelled = true } - }, [armed, item?.Id, item?.Type, qc]) + }, [armed, itemId, itemType, qc]) } diff --git a/src/lib/device-profile.ts b/src/lib/device-profile.ts index bd59bda..141f5de 100644 --- a/src/lib/device-profile.ts +++ b/src/lib/device-profile.ts @@ -48,33 +48,66 @@ function supportedVideoCodecs(): string[] { } /** - * HDR support detection. Probes for the codec strings the major HDR - * formats use: - * - HDR10 / HLG via HEVC main10 level 5.1 (`hvc1.2.4.L153.B0`) or VP9.2 - * (`vp09.02.10.10.01.09.16.09.00`) - * - Dolby Vision via the `dvh1.05.06` codec string - * When a format is detected we add it to the `VideoRangeType` matrix on - * the corresponding CodecProfile so the server direct-plays HDR content - * instead of falling back to a tone-mapped SDR transcode. + * Check whether the display is in HDR mode. On Windows this means the + * user has "Use HDR" turned on in Display Settings. The media query + * returns false on SDR-only panels and on HDR panels with HDR mode off. + * + * jellyfin-web uses browser-identification heuristics (hardcoding + * `browser.chrome && !browser.mobile` etc.) because it doesn't control + * the shell. We control the Tauri shell so we can use the actual media + * query - same outcome, fewer moving parts. + */ +function displaySupportsHdr(): boolean { + try { + return window.matchMedia('(dynamic-range: high)').matches + } catch { + return false + } +} + +/** + * HDR format detection. Two-stage gate: + * 1. Codec probe - does the browser's MSE stack understand the codec + * string for this HDR format? + * 2. Display check - is the user's display actually in HDR mode? + * + * Both must be true. Advertising HDR10/HLG/DOVI in VideoRangeType when + * the display is SDR causes the server to direct-play the file; the + * browser then tone-maps it to SDR, which works but bypasses the + * server's configurable FFmpeg tone-mapping and can produce different + * (often worse) results. + * + * Codec strings probed: + * - HDR10 / HDR10+ / HLG: HEVC main10 level 5.1 (`hvc1.2.4.L153.B0`) + * or VP9.2 (`vp09.02.10.10.01.09.16.09.00`) + * - Dolby Vision: `dvh1.05.06` / `dvhe.05.06` */ function supportedVideoRanges(): string { const ranges = ['SDR'] - if ( + const hdrCodec = canPlayInMse('video/mp4; codecs="hvc1.2.4.L153.B0"') || canPlayInMse('video/mp4; codecs="hev1.2.4.L153.B0"') || canPlayInMse('video/webm; codecs="vp09.02.10.10.01.09.16.09.00"') - ) { - ranges.push('HDR10', 'HLG') + if (hdrCodec && displaySupportsHdr()) { + ranges.push('HDR10', 'HDR10Plus', 'HLG') } - if ( + const dvCodec = canPlayInMse('video/mp4; codecs="dvh1.05.06"') || canPlayInMse('video/mp4; codecs="dvhe.05.06"') - ) { + if (dvCodec && displaySupportsHdr()) { ranges.push('DOVI', 'DOVIWithHDR10', 'DOVIWithHLG', 'DOVIWithSDR') } return ranges.join('|') } +/** + * Exported helper so StreamInfo and other diagnostics can show whether + * the display is in HDR mode without re-probing the codec strings. + */ +export function isHdrDisplayActive(): boolean { + return displaySupportsHdr() +} + export function browserDeviceProfile(audioPassthrough = false) { const videoCodecs = supportedVideoCodecs() const videoCodecsCsv = videoCodecs.join(',') @@ -88,9 +121,12 @@ export function browserDeviceProfile(audioPassthrough = false) { DirectPlayProfiles: [ { - // MSE / hls.js can remux these on the fly - no need to force a - // server-side transcode just because the container isn't mp4. - Container: 'mp4,m4v,mkv,avi,mov,wmv,ts,mpeg,mpegts', + // Chromium can natively play mp4/m4v. Everything else (mkv, avi, etc) + // goes through the HLS TranscodingProfile below, which remuxes into + // fMP4 that hls.js + MSE can handle. Listing mkv here would make the + // server return SupportsDirectPlay=true, but Chromium's