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