302 lines
9.9 KiB
TypeScript
302 lines
9.9 KiB
TypeScript
/**
|
|
* A Jellyfin DeviceProfile describing what this browser client can play.
|
|
*
|
|
* The server uses this to decide direct-play vs direct-stream vs transcode
|
|
* and to choose codecs/containers that browsers can decode.
|
|
*
|
|
* Strategy mirrors jellyfin-web:
|
|
* - DirectPlay any mp4/m4v with codecs the browser advertises support for.
|
|
* - Transcode (or stream-copy via remux) into fMP4 over HLS, served through
|
|
* hls.js + MSE. Listing multiple VideoCodec values on the TranscodingProfile
|
|
* lets the server pick the source codec when it's already compatible -
|
|
* that's the difference between "remux a file in 2 seconds" and "re-encode
|
|
* every frame in real time". A Raspberry Pi can do the former; nothing in
|
|
* this codebase makes it do the latter unless the source is genuinely
|
|
* incompatible.
|
|
*
|
|
* MaxStreamingBitrate is intentionally high - we'd rather direct-stream the
|
|
* source bitrate as-is than throttle it. The server only honours the cap when
|
|
* a transcode is actually required.
|
|
*/
|
|
|
|
/**
|
|
* Best-effort runtime probes for codec support in MSE. Browsers report this
|
|
* accurately for VP9/AV1; HEVC support is hardware-gated and can vary even
|
|
* on the same browser version, so we trust whatever isTypeSupported says.
|
|
*/
|
|
function canPlayInMse(mime: string): boolean {
|
|
try {
|
|
return typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported(mime)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function canDecodeHdr(contentType: string, transferFunction: 'pq' | 'hlg' = 'pq'): Promise<boolean> {
|
|
if (!canPlayInMse(contentType)) return false
|
|
if (typeof navigator.mediaCapabilities?.decodingInfo !== 'function') return false
|
|
|
|
try {
|
|
const info = await navigator.mediaCapabilities.decodingInfo({
|
|
type: 'media-source',
|
|
video: {
|
|
contentType,
|
|
width: 3840,
|
|
height: 2160,
|
|
bitrate: 25_000_000,
|
|
framerate: 24,
|
|
colorGamut: 'rec2020',
|
|
transferFunction,
|
|
},
|
|
} as any)
|
|
return !!info.supported
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function supportedVideoCodecs(): string[] {
|
|
const codecs: string[] = ['h264'] // Universally supported in MSE
|
|
if (canPlayInMse('video/mp4; codecs="hev1.1.6.L93.B0"') ||
|
|
canPlayInMse('video/mp4; codecs="hvc1.1.6.L93.B0"')) {
|
|
codecs.push('hevc')
|
|
}
|
|
if (canPlayInMse('video/mp4; codecs="vp09.00.10.08"')) {
|
|
codecs.push('vp9')
|
|
}
|
|
if (canPlayInMse('video/mp4; codecs="av01.0.04M.08"')) {
|
|
codecs.push('av1')
|
|
}
|
|
return codecs
|
|
}
|
|
|
|
/**
|
|
* 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`
|
|
*/
|
|
async function supportedVideoRanges(): Promise<string> {
|
|
const ranges = ['SDR']
|
|
if (!displaySupportsHdr()) return ranges.join('|')
|
|
|
|
const hdrCodec = (await Promise.all([
|
|
canDecodeHdr('video/mp4; codecs="hvc1.2.4.L153.B0"'),
|
|
canDecodeHdr('video/mp4; codecs="hev1.2.4.L153.B0"'),
|
|
canDecodeHdr('video/mp4; codecs="av01.0.08M.10.0.110.09"'),
|
|
canDecodeHdr('video/webm; codecs="vp09.02.10.10.01.09.16.09.00"'),
|
|
])).some(Boolean)
|
|
if (hdrCodec) {
|
|
ranges.push('HDR10', 'HDR10Plus')
|
|
}
|
|
|
|
const hlgCodec = (await Promise.all([
|
|
canDecodeHdr('video/mp4; codecs="hvc1.2.4.L153.B0"', 'hlg'),
|
|
canDecodeHdr('video/mp4; codecs="hev1.2.4.L153.B0"', 'hlg'),
|
|
canDecodeHdr('video/mp4; codecs="av01.0.08M.10.0.110.09"', 'hlg'),
|
|
canDecodeHdr('video/webm; codecs="vp09.02.10.10.01.09.16.09.00"', 'hlg'),
|
|
])).some(Boolean)
|
|
if (hlgCodec && !ranges.includes('HLG')) ranges.push('HLG')
|
|
|
|
const dvCodec = (await Promise.all([
|
|
canDecodeHdr('video/mp4; codecs="dvh1.05.06"'),
|
|
canDecodeHdr('video/mp4; codecs="dvhe.05.06"'),
|
|
])).some(Boolean)
|
|
if (dvCodec) {
|
|
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 async function browserDeviceProfile(audioPassthrough = false) {
|
|
const videoCodecs = supportedVideoCodecs()
|
|
const videoCodecsCsv = videoCodecs.join(',')
|
|
const videoRanges = await supportedVideoRanges()
|
|
|
|
return {
|
|
Name: 'Jellybloom Browser Client',
|
|
MaxStreamingBitrate: 120_000_000,
|
|
MaxStaticBitrate: 100_000_000,
|
|
MusicStreamingTranscodingBitrate: 384_000,
|
|
|
|
DirectPlayProfiles: [
|
|
{
|
|
// 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 <video> can't
|
|
// actually decode raw MKV - the player would just stall.
|
|
Container: 'mp4,m4v',
|
|
Type: 'Video',
|
|
VideoCodec: videoCodecsCsv,
|
|
AudioCodec: audioPassthrough
|
|
? 'aac,mp3,ac3,eac3,flac,opus,truehd,dts,alac,wmapro'
|
|
: 'aac,mp3,ac3,eac3,flac,opus',
|
|
},
|
|
{
|
|
Container: 'webm',
|
|
Type: 'Video',
|
|
VideoCodec: 'vp8,vp9,av1',
|
|
AudioCodec: 'opus,vorbis',
|
|
},
|
|
],
|
|
|
|
TranscodingProfiles: [
|
|
{
|
|
// fMP4 over HLS. Listing the full supported codec set lets the server
|
|
// stream-copy (remux only) when the source already matches one of
|
|
// them - that's what makes h264-in-mkv "play instantly" instead of
|
|
// burning CPU. The browser plays this through MSE via hls.js.
|
|
Container: 'mp4',
|
|
Type: 'Video',
|
|
VideoCodec: videoCodecsCsv,
|
|
AudioCodec: 'aac,mp3',
|
|
Protocol: 'hls',
|
|
Context: 'Streaming',
|
|
MaxAudioChannels: '6',
|
|
MinSegments: 1,
|
|
BreakOnNonKeyFrames: true,
|
|
},
|
|
{
|
|
Container: 'aac',
|
|
Type: 'Audio',
|
|
AudioCodec: 'aac',
|
|
Context: 'Streaming',
|
|
Protocol: 'http',
|
|
MaxAudioChannels: '2',
|
|
},
|
|
],
|
|
|
|
ContainerProfiles: [],
|
|
CodecProfiles: [
|
|
// Bound h264 to profiles/levels MSE actually decodes - avoids the
|
|
// server stream-copying high-10 or level 5.2 footage that the browser
|
|
// would silently refuse.
|
|
{
|
|
Type: 'Video',
|
|
Codec: 'h264',
|
|
Conditions: [
|
|
{
|
|
Condition: 'EqualsAny',
|
|
Property: 'VideoProfile',
|
|
Value: 'high|main|baseline|constrained baseline|high 10',
|
|
IsRequired: false,
|
|
},
|
|
{
|
|
Condition: 'LessThanEqual',
|
|
Property: 'VideoLevel',
|
|
Value: '52',
|
|
IsRequired: false,
|
|
},
|
|
],
|
|
},
|
|
// HEVC: cap at main / main10 + level 5.1 - matches what hardware
|
|
// decoders on most consumer GPUs can chew through. VideoRangeType
|
|
// only advertises HDR when the display is actually in HDR mode, so
|
|
// the server does FFmpeg tone-mapping for SDR displays instead of
|
|
// relying on the browser's built-in tone-mapping.
|
|
{
|
|
Type: 'Video',
|
|
Codec: 'hevc',
|
|
Conditions: [
|
|
{
|
|
Condition: 'EqualsAny',
|
|
Property: 'VideoProfile',
|
|
Value: 'main|main 10',
|
|
IsRequired: false,
|
|
},
|
|
{
|
|
Condition: 'LessThanEqual',
|
|
Property: 'VideoLevel',
|
|
Value: '153',
|
|
IsRequired: false,
|
|
},
|
|
{
|
|
Condition: 'EqualsAny',
|
|
Property: 'VideoRangeType',
|
|
Value: videoRanges,
|
|
IsRequired: true,
|
|
},
|
|
],
|
|
},
|
|
// VP9 + AV1 also support HDR transport; advertise the same range
|
|
// matrix so 4K HDR YouTube-style sources direct-play.
|
|
{
|
|
Type: 'Video',
|
|
Codec: 'vp9',
|
|
Conditions: [
|
|
{
|
|
Condition: 'EqualsAny',
|
|
Property: 'VideoRangeType',
|
|
Value: videoRanges,
|
|
IsRequired: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
Type: 'Video',
|
|
Codec: 'av1',
|
|
Conditions: [
|
|
{
|
|
Condition: 'EqualsAny',
|
|
Property: 'VideoRangeType',
|
|
Value: videoRanges,
|
|
IsRequired: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
|
|
// Mirror jellyfin-web: only External delivery. The server decides
|
|
// whether to burn-in or deliver externally based on these profiles.
|
|
// With no Embed/Hls methods listed, the server won't try to mux
|
|
// subtitles into the transcode stream - it delivers them as separate
|
|
// files that the client loads via libass-wasm or native VTT tracks.
|
|
SubtitleProfiles: [
|
|
{ Format: 'vtt', Method: 'External' },
|
|
{ Format: 'ass', Method: 'External' },
|
|
{ Format: 'ssa', Method: 'External' },
|
|
{ Format: 'subrip', Method: 'External' },
|
|
],
|
|
|
|
ResponseProfiles: [],
|
|
}
|
|
}
|