Jellyfin playback state synchronation
This commit is contained in:
14
README.md
14
README.md
@@ -1,13 +1,19 @@
|
||||
# Reiverr
|
||||
|
||||
TODO:
|
||||
- [ ] Sonarr support
|
||||
- [ ] Onboarding setup & sources
|
||||
- [ ] Settings page
|
||||
- [ ] Plex and Jellyfin sync
|
||||
- [ ] Jellyfin video sync
|
||||
- [ ] Mass edit local files & show space left
|
||||
- [ ] Finish discover page
|
||||
- [ ] Onboarding setup & sources
|
||||
- [ ] Settings page
|
||||
- [ ] Sonarr support
|
||||
- [ ] Event notifications & show indexer status
|
||||
- [ ] Plex video sync
|
||||
|
||||
FIX:
|
||||
- [ ] YouTube trailer, hide on finish
|
||||
- [ ] Finalize animations
|
||||
- [ ] Improve continue watching
|
||||
|
||||
Further ideas
|
||||
- [ ] Similar movies & shows, actor pages and recommendations
|
||||
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.15",
|
||||
"@jellyfin/sdk": "^0.7.0",
|
||||
"axios": "^1.4.0",
|
||||
"graphql": "^16.6.0",
|
||||
"hls.js": "^1.4.6",
|
||||
@@ -2601,6 +2602,24 @@
|
||||
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@jellyfin/sdk": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.7.0.tgz",
|
||||
"integrity": "sha512-GNoGv+2qY+xK7WpO7sUUNpZvzgN7RwXMyOhIy9mE/LdDSr6bqZHwrzT1Pv0+vUW7Epw67bwIMWuYivyBYejEHw==",
|
||||
"dependencies": {
|
||||
"axios": "0.27.2",
|
||||
"compare-versions": "5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jellyfin/sdk/node_modules/axios": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
|
||||
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.14.9",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
|
||||
@@ -3999,6 +4018,11 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compare-versions": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-5.0.1.tgz",
|
||||
"integrity": "sha512-v8Au3l0b+Nwkp4G142JcgJFh1/TUhdxut7wzD1Nq1dyp5oa3tXaqb03EXOAB6jS4gMlalkjAUPZBMiAfKUixHQ=="
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.15",
|
||||
"@jellyfin/sdk": "^0.7.0",
|
||||
"axios": "^1.4.0",
|
||||
"graphql": "^16.6.0",
|
||||
"hls.js": "^1.4.6",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
|
||||
9
src/lib/jellyfin/jellyfin-state.ts
Normal file
9
src/lib/jellyfin/jellyfin-state.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
async function fetchJellyfinState() {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => resolve('true'), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
export const jellyfinState = writable(fetchJellyfinState());
|
||||
@@ -2,6 +2,7 @@ import createClient from 'openapi-fetch';
|
||||
import type { paths } from '$lib/jellyfin/jellyfin-types';
|
||||
import { PUBLIC_JELLYFIN_API_KEY, PUBLIC_JELLYFIN_URL } from '$env/static/public';
|
||||
import { request } from '$lib/utils';
|
||||
import type { DeviceProfile } from '$lib/jellyfin/playback-profiles';
|
||||
|
||||
export const JELLYFIN_DEVICE_ID = 'Reiverr Client';
|
||||
export const JELLYFIN_USER_ID = '75dcb061c9404115a7acdc893ea6bbbc';
|
||||
@@ -42,16 +43,24 @@ export const getJellyfinItemByTmdbId = (tmdbId: string) =>
|
||||
}
|
||||
}).then((r) => r.data?.Items?.find((i) => i.ProviderIds?.Tmdb == tmdbId));
|
||||
|
||||
export const getJellyfinItem = (itemId: string) =>
|
||||
JellyfinApi.get('/Users/{userId}/Items/{itemId}', {
|
||||
params: {
|
||||
path: {
|
||||
itemId,
|
||||
userId: JELLYFIN_USER_ID
|
||||
}
|
||||
}
|
||||
}).then((r) => r.data);
|
||||
|
||||
export const requestJellyfinItemByTmdbId = () =>
|
||||
request((tmdbId: string) => getJellyfinItemByTmdbId(tmdbId));
|
||||
|
||||
export const getJellyfinPlaybackInfo = () => request(fetchJellyfinPlaybackUrl);
|
||||
|
||||
export const fetchJellyfinPlaybackUrl = (id: string) =>
|
||||
export const getJellyfinPlaybackInfo = (itemId: string, playbackProfile: DeviceProfile) =>
|
||||
JellyfinApi.post('/Items/{itemId}/PlaybackInfo', {
|
||||
params: {
|
||||
path: {
|
||||
itemId: id
|
||||
itemId: itemId
|
||||
},
|
||||
query: {
|
||||
userId: JELLYFIN_USER_ID,
|
||||
@@ -61,314 +70,59 @@ export const fetchJellyfinPlaybackUrl = (id: string) =>
|
||||
}
|
||||
},
|
||||
body: {
|
||||
DeviceProfile: {
|
||||
CodecProfiles: [
|
||||
{
|
||||
Codec: 'aac',
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'Equals',
|
||||
IsRequired: false,
|
||||
Property: 'IsSecondaryAudio',
|
||||
Value: 'false'
|
||||
}
|
||||
],
|
||||
Type: 'VideoAudio'
|
||||
},
|
||||
{
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'Equals',
|
||||
IsRequired: false,
|
||||
Property: 'IsSecondaryAudio',
|
||||
Value: 'false'
|
||||
}
|
||||
],
|
||||
Type: 'VideoAudio'
|
||||
},
|
||||
{
|
||||
Codec: 'h264',
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'NotEquals',
|
||||
IsRequired: false,
|
||||
Property: 'IsAnamorphic',
|
||||
Value: 'true'
|
||||
},
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoProfile',
|
||||
Value: 'high|main|baseline|constrained baseline'
|
||||
},
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoRangeType',
|
||||
Value: 'SDR'
|
||||
},
|
||||
{
|
||||
Condition: 'LessThanEqual',
|
||||
IsRequired: false,
|
||||
Property: 'VideoLevel',
|
||||
Value: '52'
|
||||
},
|
||||
{
|
||||
Condition: 'NotEquals',
|
||||
IsRequired: false,
|
||||
Property: 'IsInterlaced',
|
||||
Value: 'true'
|
||||
}
|
||||
],
|
||||
Type: 'Video'
|
||||
},
|
||||
{
|
||||
Codec: 'hevc',
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'NotEquals',
|
||||
IsRequired: false,
|
||||
Property: 'IsAnamorphic',
|
||||
Value: 'true'
|
||||
},
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoProfile',
|
||||
Value: 'main'
|
||||
},
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoRangeType',
|
||||
Value: 'SDR'
|
||||
},
|
||||
{
|
||||
Condition: 'LessThanEqual',
|
||||
IsRequired: false,
|
||||
Property: 'VideoLevel',
|
||||
Value: '120'
|
||||
},
|
||||
{
|
||||
Condition: 'NotEquals',
|
||||
IsRequired: false,
|
||||
Property: 'IsInterlaced',
|
||||
Value: 'true'
|
||||
}
|
||||
],
|
||||
Type: 'Video'
|
||||
},
|
||||
{
|
||||
Codec: 'vp9',
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoRangeType',
|
||||
Value: 'SDR|HDR10|HLG'
|
||||
}
|
||||
],
|
||||
Type: 'Video'
|
||||
},
|
||||
{
|
||||
Codec: 'av1',
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoRangeType',
|
||||
Value: 'SDR|HDR10|HLG'
|
||||
}
|
||||
],
|
||||
Type: 'Video'
|
||||
}
|
||||
],
|
||||
ContainerProfiles: [],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
AudioCodec: 'vorbis,opus',
|
||||
Container: 'webm',
|
||||
Type: 'Video',
|
||||
VideoCodec: 'vp8,vp9,av1'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac,mp3,opus,flac,alac,vorbis',
|
||||
Container: 'mp4,m4v',
|
||||
Type: 'Video',
|
||||
VideoCodec: 'h264,vp9,av1'
|
||||
},
|
||||
{
|
||||
Container: 'opus',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'opus',
|
||||
Container: 'webm',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'mp3',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'aac',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac',
|
||||
Container: 'm4a',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac',
|
||||
Container: 'm4b',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'flac',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'alac',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'alac',
|
||||
Container: 'm4a',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'alac',
|
||||
Container: 'm4b',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'webma',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'webma',
|
||||
Container: 'webm',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'wav',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'ogg',
|
||||
Type: 'Audio'
|
||||
}
|
||||
],
|
||||
MaxStaticBitrate: 100000000,
|
||||
MaxStreamingBitrate: 120000000,
|
||||
MusicStreamingTranscodingBitrate: 384000,
|
||||
ResponseProfiles: [
|
||||
{
|
||||
Container: 'm4v',
|
||||
MimeType: 'video/mp4',
|
||||
Type: 'Video'
|
||||
}
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
{
|
||||
Format: 'vtt',
|
||||
Method: 'External'
|
||||
},
|
||||
{
|
||||
Format: 'ass',
|
||||
Method: 'External'
|
||||
},
|
||||
{
|
||||
Format: 'ssa',
|
||||
Method: 'External'
|
||||
}
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
AudioCodec: 'aac',
|
||||
BreakOnNonKeyFrames: true,
|
||||
Container: 'ts',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'hls',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac',
|
||||
Container: 'aac',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'mp3',
|
||||
Container: 'mp3',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'opus',
|
||||
Container: 'opus',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'wav',
|
||||
Container: 'wav',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'opus',
|
||||
Container: 'opus',
|
||||
Context: 'Static',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'mp3',
|
||||
Container: 'mp3',
|
||||
Context: 'Static',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac',
|
||||
Container: 'aac',
|
||||
Context: 'Static',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'wav',
|
||||
Container: 'wav',
|
||||
Context: 'Static',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac,mp3',
|
||||
BreakOnNonKeyFrames: true,
|
||||
Container: 'ts',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'hls',
|
||||
Type: 'Video',
|
||||
VideoCodec: 'h264'
|
||||
}
|
||||
]
|
||||
}
|
||||
DeviceProfile: playbackProfile
|
||||
}
|
||||
}).then((r) => r.data?.MediaSources?.[0]?.TranscodingUrl);
|
||||
}).then((r) => ({
|
||||
playbackUrl: r.data?.MediaSources?.[0]?.TranscodingUrl,
|
||||
mediaSourceId: r.data?.MediaSources?.[0]?.Id,
|
||||
playSessionId: r.data?.PlaySessionId
|
||||
}));
|
||||
|
||||
export const reportJellyfinPlaybackStarted = (
|
||||
itemId: string,
|
||||
sessionId: string,
|
||||
mediaSourceId: string,
|
||||
audioStreamIndex?: number,
|
||||
subtitleStreamIndex?: number
|
||||
) =>
|
||||
JellyfinApi.post('/Sessions/Playing', {
|
||||
body: {
|
||||
CanSeek: true,
|
||||
ItemId: itemId,
|
||||
PlaySessionId: sessionId,
|
||||
MediaSourceId: mediaSourceId,
|
||||
AudioStreamIndex: 1,
|
||||
SubtitleStreamIndex: -1
|
||||
}
|
||||
});
|
||||
|
||||
export const reportJellyfinPlaybackProgress = (
|
||||
itemId: string,
|
||||
sessionId: string,
|
||||
isPaused: boolean,
|
||||
positionTicks: number
|
||||
) =>
|
||||
JellyfinApi.post('/Sessions/Playing/Progress', {
|
||||
body: {
|
||||
ItemId: itemId,
|
||||
PlaySessionId: sessionId,
|
||||
IsPaused: isPaused,
|
||||
PositionTicks: Math.round(positionTicks),
|
||||
CanSeek: true,
|
||||
MediaSourceId: itemId
|
||||
}
|
||||
});
|
||||
|
||||
export const reportJellyfinPlaybackStopped = (
|
||||
itemId: string,
|
||||
sessionId: string,
|
||||
positionTicks: number
|
||||
) =>
|
||||
JellyfinApi.post('/Sessions/Playing/Stopped', {
|
||||
body: {
|
||||
ItemId: itemId,
|
||||
PlaySessionId: sessionId,
|
||||
PositionTicks: Math.round(positionTicks),
|
||||
MediaSourceId: itemId
|
||||
}
|
||||
});
|
||||
|
||||
104
src/lib/jellyfin/playback-profiles/directplay-profile.ts
Normal file
104
src/lib/jellyfin/playback-profiles/directplay-profile.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { DlnaProfileType } from '@jellyfin/sdk/lib/generated-client';
|
||||
import type { DirectPlayProfile } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { getSupportedMP4VideoCodecs } from './helpers/mp4-video-formats';
|
||||
import { getSupportedMP4AudioCodecs } from './helpers/mp4-audio-formats';
|
||||
import { hasMkvSupport } from './helpers/transcoding-formats';
|
||||
import { getSupportedWebMAudioCodecs } from './helpers/webm-audio-formats';
|
||||
import { getSupportedWebMVideoCodecs } from './helpers/webm-video-formats';
|
||||
import { getSupportedAudioCodecs } from './helpers/audio-formats';
|
||||
|
||||
/**
|
||||
* Returns a valid DirectPlayProfile for the current platform.
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns An array of direct play profiles for the current platform.
|
||||
*/
|
||||
export function getDirectPlayProfiles(
|
||||
videoTestElement: HTMLVideoElement
|
||||
): Array<DirectPlayProfile> {
|
||||
const DirectPlayProfiles: DirectPlayProfile[] = [];
|
||||
|
||||
const webmVideoCodecs = getSupportedWebMVideoCodecs(videoTestElement);
|
||||
const webmAudioCodecs = getSupportedWebMAudioCodecs(videoTestElement);
|
||||
|
||||
const mp4VideoCodecs = getSupportedMP4VideoCodecs(videoTestElement);
|
||||
const mp4AudioCodecs = getSupportedMP4AudioCodecs(videoTestElement);
|
||||
|
||||
if (webmVideoCodecs.length > 0) {
|
||||
DirectPlayProfiles.push({
|
||||
Container: 'webm',
|
||||
Type: DlnaProfileType.Video,
|
||||
VideoCodec: webmVideoCodecs.join(','),
|
||||
AudioCodec: webmAudioCodecs.join(',')
|
||||
});
|
||||
}
|
||||
|
||||
if (mp4VideoCodecs.length > 0) {
|
||||
DirectPlayProfiles.push({
|
||||
Container: 'mp4,m4v',
|
||||
Type: DlnaProfileType.Video,
|
||||
VideoCodec: mp4VideoCodecs.join(','),
|
||||
AudioCodec: mp4AudioCodecs.join(',')
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMkvSupport(videoTestElement) && mp4VideoCodecs.length > 0) {
|
||||
DirectPlayProfiles.push({
|
||||
Container: 'mkv',
|
||||
Type: DlnaProfileType.Video,
|
||||
VideoCodec: mp4VideoCodecs.join(','),
|
||||
AudioCodec: mp4AudioCodecs.join(',')
|
||||
});
|
||||
}
|
||||
|
||||
const supportedAudio = [
|
||||
'opus',
|
||||
'mp3',
|
||||
'mp2',
|
||||
'aac',
|
||||
'flac',
|
||||
'alac',
|
||||
'webma',
|
||||
'wma',
|
||||
'wav',
|
||||
'ogg',
|
||||
'oga'
|
||||
];
|
||||
|
||||
for (const audioFormat of supportedAudio.filter((format) => getSupportedAudioCodecs(format))) {
|
||||
DirectPlayProfiles.push({
|
||||
Container: audioFormat,
|
||||
Type: DlnaProfileType.Audio
|
||||
});
|
||||
|
||||
if (audioFormat === 'opus' || audioFormat === 'webma') {
|
||||
DirectPlayProfiles.push({
|
||||
Container: 'webm',
|
||||
Type: DlnaProfileType.Audio,
|
||||
AudioCodec: audioFormat
|
||||
});
|
||||
}
|
||||
|
||||
// aac also appears in the m4a and m4b container
|
||||
if (audioFormat === 'aac' || audioFormat === 'alac') {
|
||||
DirectPlayProfiles.push(
|
||||
{
|
||||
Container: 'm4a',
|
||||
AudioCodec: audioFormat,
|
||||
Type: DlnaProfileType.Audio
|
||||
},
|
||||
{
|
||||
Container: 'm4b',
|
||||
AudioCodec: audioFormat,
|
||||
Type: DlnaProfileType.Audio
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return DirectPlayProfiles;
|
||||
}
|
||||
38
src/lib/jellyfin/playback-profiles/helpers/audio-formats.ts
Normal file
38
src/lib/jellyfin/playback-profiles/helpers/audio-formats.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { isApple, isTizen, isTv, isWebOS } from '$lib/utils/browser-detection';
|
||||
|
||||
/**
|
||||
* Determines if audio codec is supported
|
||||
*/
|
||||
export function getSupportedAudioCodecs(format: string): boolean {
|
||||
let typeString;
|
||||
|
||||
if (format === 'flac' && isTv()) {
|
||||
return true;
|
||||
} else if (format === 'wma' && isTizen()) {
|
||||
return true;
|
||||
} else if (format === 'asf' && isTv()) {
|
||||
return true;
|
||||
} else if (format === 'opus') {
|
||||
if (!isWebOS()) {
|
||||
typeString = 'audio/ogg; codecs="opus"';
|
||||
|
||||
return !!document.createElement('audio').canPlayType(typeString).replace(/no/, '');
|
||||
}
|
||||
|
||||
return false;
|
||||
} else if (format === 'alac' && isApple()) {
|
||||
return true;
|
||||
} else if (format === 'webma') {
|
||||
typeString = 'audio/webm';
|
||||
} else if (format === 'mp2') {
|
||||
typeString = 'audio/mpeg';
|
||||
} else {
|
||||
typeString = 'audio/' + format;
|
||||
}
|
||||
|
||||
return !!document.createElement('audio').canPlayType(typeString).replace(/no/, '');
|
||||
}
|
||||
329
src/lib/jellyfin/playback-profiles/helpers/codec-profiles.ts
Normal file
329
src/lib/jellyfin/playback-profiles/helpers/codec-profiles.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import {
|
||||
CodecType,
|
||||
ProfileConditionType,
|
||||
ProfileConditionValue
|
||||
} from '@jellyfin/sdk/lib/generated-client';
|
||||
import type { ProfileCondition, CodecProfile } from '@jellyfin/sdk/lib/generated-client';
|
||||
import {
|
||||
isApple,
|
||||
isChromiumBased,
|
||||
isEdge,
|
||||
isMobile,
|
||||
isPs4,
|
||||
isTizen,
|
||||
isTv,
|
||||
isWebOS,
|
||||
isXbox,
|
||||
safariVersion
|
||||
} from '$lib/utils/browser-detection';
|
||||
|
||||
/**
|
||||
* Gets the max video bitrate
|
||||
*
|
||||
* @returns Returns the MaxVideoBitrate
|
||||
*/
|
||||
function getGlobalMaxVideoBitrate(): number | undefined {
|
||||
let isTizenFhd = false;
|
||||
|
||||
if (
|
||||
isTizen() &&
|
||||
'webapis' in window &&
|
||||
typeof window.webapis === 'object' &&
|
||||
window.webapis &&
|
||||
'productinfo' in window.webapis &&
|
||||
typeof window.webapis.productinfo === 'object' &&
|
||||
window.webapis.productinfo &&
|
||||
'isUdPanelSupported' in window.webapis.productinfo &&
|
||||
typeof window.webapis.productinfo.isUdPanelSupported === 'function'
|
||||
) {
|
||||
isTizenFhd = !window.webapis.productinfo.isUdPanelSupported();
|
||||
}
|
||||
|
||||
// TODO: These values are taken directly from Jellyfin-web.
|
||||
// The source of them needs to be investigated.
|
||||
if (isPs4()) {
|
||||
return 8_000_000;
|
||||
}
|
||||
|
||||
if (isXbox()) {
|
||||
return 12_000_000;
|
||||
}
|
||||
|
||||
if (isTizen() && isTizenFhd) {
|
||||
return 20_000_000;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a profile condition object for use in device playback profiles.
|
||||
*
|
||||
* @param Property - Value for the property
|
||||
* @param Condition - Condition that the property must comply with
|
||||
* @param Value - Value to check in the condition
|
||||
* @param IsRequired - Whether this property is required
|
||||
* @returns - Constructed ProfileCondition object
|
||||
*/
|
||||
function createProfileCondition(
|
||||
Property: ProfileConditionValue,
|
||||
Condition: ProfileConditionType,
|
||||
Value: string,
|
||||
IsRequired = false
|
||||
): ProfileCondition {
|
||||
return {
|
||||
Condition,
|
||||
Property,
|
||||
Value,
|
||||
IsRequired
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the AAC audio codec profile conditions
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns - Array of ACC Profile conditions
|
||||
*/
|
||||
export function getAacCodecProfileConditions(
|
||||
videoTestElement: HTMLVideoElement
|
||||
): ProfileCondition[] {
|
||||
const supportsSecondaryAudio = isTizen();
|
||||
|
||||
const conditions: ProfileCondition[] = [];
|
||||
|
||||
// Handle he-aac not supported
|
||||
if (
|
||||
!videoTestElement.canPlayType('video/mp4; codecs="avc1.640029, mp4a.40.5"').replace(/no/, '')
|
||||
) {
|
||||
// TODO: This needs to become part of the stream url in order to prevent stream copy
|
||||
conditions.push(
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.AudioProfile,
|
||||
ProfileConditionType.NotEquals,
|
||||
'HE-AAC'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!supportsSecondaryAudio) {
|
||||
conditions.push(
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.IsSecondaryAudio,
|
||||
ProfileConditionType.Equals,
|
||||
'false'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an array with all the codec profiles that this client supports
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns - Array containing the different profiles for the client
|
||||
*/
|
||||
export function getCodecProfiles(videoTestElement: HTMLVideoElement): CodecProfile[] {
|
||||
const CodecProfiles: CodecProfile[] = [];
|
||||
|
||||
const aacProfileConditions = getAacCodecProfileConditions(videoTestElement);
|
||||
|
||||
const supportsSecondaryAudio = isTizen();
|
||||
|
||||
if (aacProfileConditions.length > 0) {
|
||||
CodecProfiles.push({
|
||||
Type: CodecType.VideoAudio,
|
||||
Codec: 'aac',
|
||||
Conditions: aacProfileConditions
|
||||
});
|
||||
}
|
||||
|
||||
if (!supportsSecondaryAudio) {
|
||||
CodecProfiles.push({
|
||||
Type: CodecType.VideoAudio,
|
||||
Conditions: [
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.IsSecondaryAudio,
|
||||
ProfileConditionType.Equals,
|
||||
'false'
|
||||
)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
let maxH264Level = 42;
|
||||
let h264Profiles = 'high|main|baseline|constrained baseline';
|
||||
|
||||
if (isTv() || videoTestElement.canPlayType('video/mp4; codecs="avc1.640833"').replace(/no/, '')) {
|
||||
maxH264Level = 51;
|
||||
}
|
||||
|
||||
if (videoTestElement.canPlayType('video/mp4; codecs="avc1.640834"').replace(/no/, '')) {
|
||||
maxH264Level = 52;
|
||||
}
|
||||
|
||||
if (
|
||||
(isTizen() ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="avc1.6e0033"').replace(/no/, '')) && // TODO: These tests are passing in Safari, but playback is failing
|
||||
(!isApple() || !isWebOS() || !(isEdge() && !isChromiumBased()))
|
||||
) {
|
||||
h264Profiles += '|high 10';
|
||||
}
|
||||
|
||||
let maxHevcLevel = 120;
|
||||
let hevcProfiles = 'main';
|
||||
const hevcProfilesMain10 = 'main|main 10';
|
||||
|
||||
// HEVC Main profile, Level 4.1
|
||||
if (
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hvc1.1.4.L123"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hev1.1.4.L123"').replace(/no/, '')
|
||||
) {
|
||||
maxHevcLevel = 123;
|
||||
}
|
||||
|
||||
// HEVC Main10 profile, Level 4.1
|
||||
if (
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hvc1.2.4.L123"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hev1.2.4.L123"').replace(/no/, '')
|
||||
) {
|
||||
maxHevcLevel = 123;
|
||||
hevcProfiles = hevcProfilesMain10;
|
||||
}
|
||||
|
||||
// HEVC Main10 profile, Level 5.1
|
||||
if (
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hvc1.2.4.L153"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hev1.2.4.L153"').replace(/no/, '')
|
||||
) {
|
||||
maxHevcLevel = 153;
|
||||
hevcProfiles = hevcProfilesMain10;
|
||||
}
|
||||
|
||||
// HEVC Main10 profile, Level 6.1
|
||||
if (
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hvc1.2.4.L183"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hev1.2.4.L183"').replace(/no/, '')
|
||||
) {
|
||||
maxHevcLevel = 183;
|
||||
hevcProfiles = hevcProfilesMain10;
|
||||
}
|
||||
|
||||
const hevcCodecProfileConditions: ProfileCondition[] = [
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.IsAnamorphic,
|
||||
ProfileConditionType.NotEquals,
|
||||
'true'
|
||||
),
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.VideoProfile,
|
||||
ProfileConditionType.EqualsAny,
|
||||
hevcProfiles
|
||||
),
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.VideoLevel,
|
||||
ProfileConditionType.LessThanEqual,
|
||||
maxHevcLevel.toString()
|
||||
)
|
||||
];
|
||||
|
||||
const h264CodecProfileConditions: ProfileCondition[] = [
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.IsAnamorphic,
|
||||
ProfileConditionType.NotEquals,
|
||||
'true'
|
||||
),
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.VideoProfile,
|
||||
ProfileConditionType.EqualsAny,
|
||||
h264Profiles
|
||||
),
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.VideoLevel,
|
||||
ProfileConditionType.LessThanEqual,
|
||||
maxH264Level.toString()
|
||||
)
|
||||
];
|
||||
|
||||
if (!isTv()) {
|
||||
h264CodecProfileConditions.push(
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.IsInterlaced,
|
||||
ProfileConditionType.NotEquals,
|
||||
'true'
|
||||
)
|
||||
);
|
||||
hevcCodecProfileConditions.push(
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.IsInterlaced,
|
||||
ProfileConditionType.NotEquals,
|
||||
'true'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const globalMaxVideoBitrate = (getGlobalMaxVideoBitrate() || '').toString();
|
||||
|
||||
if (globalMaxVideoBitrate) {
|
||||
h264CodecProfileConditions.push(
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.VideoBitrate,
|
||||
ProfileConditionType.LessThanEqual,
|
||||
globalMaxVideoBitrate,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (globalMaxVideoBitrate) {
|
||||
hevcCodecProfileConditions.push(
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.VideoBitrate,
|
||||
ProfileConditionType.LessThanEqual,
|
||||
globalMaxVideoBitrate,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// On iOS 12.x, for TS container max h264 level is 4.2
|
||||
if (isApple() && isMobile() && Number(safariVersion()) < 13) {
|
||||
const codecProfile = {
|
||||
Type: CodecType.Video,
|
||||
Codec: 'h264',
|
||||
Container: 'ts',
|
||||
Conditions: h264CodecProfileConditions.filter((condition) => {
|
||||
return condition.Property !== 'VideoLevel';
|
||||
})
|
||||
};
|
||||
|
||||
codecProfile.Conditions.push(
|
||||
createProfileCondition(
|
||||
ProfileConditionValue.VideoLevel,
|
||||
ProfileConditionType.LessThanEqual,
|
||||
'42'
|
||||
)
|
||||
);
|
||||
|
||||
CodecProfiles.push(codecProfile);
|
||||
}
|
||||
|
||||
CodecProfiles.push(
|
||||
{
|
||||
Type: CodecType.Video,
|
||||
Codec: 'h264',
|
||||
Conditions: h264CodecProfileConditions
|
||||
},
|
||||
{
|
||||
Type: CodecType.Video,
|
||||
Codec: 'hevc',
|
||||
Conditions: hevcCodecProfileConditions
|
||||
}
|
||||
);
|
||||
|
||||
return CodecProfiles;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { getSupportedAudioCodecs } from './audio-formats';
|
||||
import {
|
||||
hasAacSupport,
|
||||
hasAc3InHlsSupport,
|
||||
hasAc3Support,
|
||||
hasEac3Support,
|
||||
hasMp3AudioSupport
|
||||
} from './mp4-audio-formats';
|
||||
import { isEdge } from '$lib/utils/browser-detection';
|
||||
|
||||
/**
|
||||
* Gets an array with the supported fmp4 codecs
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns List of supported FMP4 audio codecs
|
||||
*/
|
||||
export function getSupportedFmp4AudioCodecs(videoTestElement: HTMLVideoElement): string[] {
|
||||
const codecs = [];
|
||||
|
||||
if (hasAacSupport(videoTestElement)) {
|
||||
codecs.push('aac');
|
||||
}
|
||||
|
||||
if (hasMp3AudioSupport(videoTestElement)) {
|
||||
codecs.push('mp3');
|
||||
}
|
||||
|
||||
if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) {
|
||||
codecs.push('ac3');
|
||||
|
||||
if (hasEac3Support(videoTestElement)) {
|
||||
codecs.push('eac3');
|
||||
}
|
||||
}
|
||||
|
||||
if (getSupportedAudioCodecs('flac') && !isEdge()) {
|
||||
codecs.push('flac');
|
||||
}
|
||||
|
||||
if (getSupportedAudioCodecs('alac')) {
|
||||
codecs.push('alac');
|
||||
}
|
||||
|
||||
return codecs;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { hasH264Support, hasHevcSupport } from './mp4-video-formats';
|
||||
import {
|
||||
isApple,
|
||||
isChrome,
|
||||
isEdge,
|
||||
isFirefox,
|
||||
isTizen,
|
||||
isWebOS
|
||||
} from '$lib/utils/browser-detection';
|
||||
|
||||
/**
|
||||
* Gets an array of supported fmp4 video codecs
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns List of supported fmp4 video codecs
|
||||
*/
|
||||
export function getSupportedFmp4VideoCodecs(videoTestElement: HTMLVideoElement): string[] {
|
||||
const codecs = [];
|
||||
|
||||
if ((isApple() || isEdge() || isTizen() || isWebOS()) && hasHevcSupport(videoTestElement)) {
|
||||
codecs.push('hevc');
|
||||
}
|
||||
|
||||
if (
|
||||
hasH264Support(videoTestElement) &&
|
||||
(isChrome() || isFirefox() || isApple() || isEdge() || isTizen() || isWebOS())
|
||||
) {
|
||||
codecs.push('h264');
|
||||
}
|
||||
|
||||
return codecs;
|
||||
}
|
||||
81
src/lib/jellyfin/playback-profiles/helpers/hls-formats.ts
Normal file
81
src/lib/jellyfin/playback-profiles/helpers/hls-formats.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { hasH264Support, hasH265Support } from './mp4-video-formats';
|
||||
import { hasEac3Support, hasAacSupport } from './mp4-audio-formats';
|
||||
import { getSupportedAudioCodecs } from './audio-formats';
|
||||
import { isTv } from '$lib/utils/browser-detection';
|
||||
|
||||
/**
|
||||
* Check if client supports AC3 in HLS stream
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if the browser has AC3 in HLS support
|
||||
*/
|
||||
function supportsAc3InHls(videoTestElement: HTMLVideoElement): boolean | string {
|
||||
if (isTv()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (videoTestElement.canPlayType) {
|
||||
return (
|
||||
videoTestElement
|
||||
.canPlayType('application/x-mpegurl; codecs="avc1.42E01E, ac-3"')
|
||||
.replace(/no/, '') ||
|
||||
videoTestElement
|
||||
.canPlayType('application/vnd.apple.mpegURL; codecs="avc1.42E01E, ac-3"')
|
||||
.replace(/no/, '')
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the supported HLS video codecs
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Array of video codecs supported in HLS
|
||||
*/
|
||||
export function getHlsVideoCodecs(videoTestElement: HTMLVideoElement): string[] {
|
||||
const hlsVideoCodecs = [];
|
||||
|
||||
if (hasH264Support(videoTestElement)) {
|
||||
hlsVideoCodecs.push('h264');
|
||||
}
|
||||
|
||||
if (hasH265Support(videoTestElement) || isTv()) {
|
||||
hlsVideoCodecs.push('h265', 'hevc');
|
||||
}
|
||||
|
||||
return hlsVideoCodecs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the supported HLS audio codecs
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Array of audio codecs supported in HLS
|
||||
*/
|
||||
export function getHlsAudioCodecs(videoTestElement: HTMLVideoElement): string[] {
|
||||
const hlsVideoAudioCodecs = [];
|
||||
|
||||
if (supportsAc3InHls(videoTestElement)) {
|
||||
hlsVideoAudioCodecs.push('ac3');
|
||||
|
||||
if (hasEac3Support(videoTestElement)) {
|
||||
hlsVideoAudioCodecs.push('eac3');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAacSupport(videoTestElement)) {
|
||||
hlsVideoAudioCodecs.push('aac');
|
||||
}
|
||||
|
||||
if (getSupportedAudioCodecs('opus')) {
|
||||
hlsVideoAudioCodecs.push('opus');
|
||||
}
|
||||
|
||||
return hlsVideoAudioCodecs;
|
||||
}
|
||||
180
src/lib/jellyfin/playback-profiles/helpers/mp4-audio-formats.ts
Normal file
180
src/lib/jellyfin/playback-profiles/helpers/mp4-audio-formats.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { hasVp8Support } from './mp4-video-formats';
|
||||
import { getSupportedAudioCodecs } from './audio-formats';
|
||||
import {
|
||||
isTizen,
|
||||
isTizen4,
|
||||
isTizen5,
|
||||
isTizen55,
|
||||
isTv,
|
||||
isWebOS
|
||||
} from '$lib/utils/browser-detection';
|
||||
|
||||
/**
|
||||
* Checks if the client can play the AC3 codec
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if the browser has AC3 support
|
||||
*/
|
||||
export function hasAc3Support(videoTestElement: HTMLVideoElement): boolean {
|
||||
if (isTv()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!videoTestElement.canPlayType('audio/mp4; codecs="ac-3"').replace(/no/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client can play AC3 in a HLS stream
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if the browser has AC3 support
|
||||
*/
|
||||
export function hasAc3InHlsSupport(videoTestElement: HTMLVideoElement): boolean {
|
||||
if (isTizen() || isWebOS()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (videoTestElement.canPlayType) {
|
||||
return !!(
|
||||
videoTestElement
|
||||
.canPlayType('application/x-mpegurl; codecs="avc1.42E01E, ac-3"')
|
||||
.replace(/no/, '') ||
|
||||
videoTestElement
|
||||
.canPlayType('application/vnd.apple.mpegURL; codecs="avc1.42E01E, ac-3"')
|
||||
.replace(/no/, '')
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the cliemt has E-AC3 codec support
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if browser has EAC3 support
|
||||
*/
|
||||
export function hasEac3Support(videoTestElement: HTMLVideoElement): boolean {
|
||||
if (isTv()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!videoTestElement.canPlayType('audio/mp4; codecs="ec-3"').replace(/no/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client has AAC codec support
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if browser has AAC support
|
||||
*/
|
||||
export function hasAacSupport(videoTestElement: HTMLVideoElement): boolean {
|
||||
return !!videoTestElement
|
||||
.canPlayType('video/mp4; codecs="avc1.640029, mp4a.40.2"')
|
||||
.replace(/no/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client has MP2 codec support
|
||||
*
|
||||
* @returns Determines if browser has MP2 support
|
||||
*/
|
||||
export function hasMp2AudioSupport(): boolean {
|
||||
return isTv();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client has MP3 audio codec support
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if browser has Mp3 support
|
||||
*/
|
||||
export function hasMp3AudioSupport(videoTestElement: HTMLVideoElement): boolean {
|
||||
return !!(
|
||||
videoTestElement.canPlayType('video/mp4; codecs="avc1.640029, mp4a.69"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="avc1.640029, mp4a.6B"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="avc1.640029, mp3"').replace(/no/, '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines DTS audio support
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if browserr has DTS audio support
|
||||
*/
|
||||
export function hasDtsSupport(videoTestElement: HTMLVideoElement): boolean | string {
|
||||
// DTS audio not supported in 2018 models (Tizen 4.0)
|
||||
if (isTizen4() || isTizen5() || isTizen55()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isTv() ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="dts-"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="dts+"').replace(/no/, '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an array of supported MP4 codecs
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Array of supported MP4 audio codecs
|
||||
*/
|
||||
export function getSupportedMP4AudioCodecs(videoTestElement: HTMLVideoElement): string[] {
|
||||
const codecs = [];
|
||||
|
||||
if (hasAacSupport(videoTestElement)) {
|
||||
codecs.push('aac');
|
||||
}
|
||||
|
||||
if (hasMp3AudioSupport(videoTestElement)) {
|
||||
codecs.push('mp3');
|
||||
}
|
||||
|
||||
if (hasAc3Support(videoTestElement)) {
|
||||
codecs.push('ac3');
|
||||
|
||||
if (hasEac3Support(videoTestElement)) {
|
||||
codecs.push('eac3');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMp2AudioSupport()) {
|
||||
codecs.push('mp2');
|
||||
}
|
||||
|
||||
if (hasDtsSupport(videoTestElement)) {
|
||||
codecs.push('dca', 'dts');
|
||||
}
|
||||
|
||||
if (isTizen() || isWebOS()) {
|
||||
codecs.push('pcm_s16le', 'pcm_s24le');
|
||||
}
|
||||
|
||||
if (isTizen()) {
|
||||
codecs.push('aac_latm');
|
||||
}
|
||||
|
||||
if (getSupportedAudioCodecs('opus')) {
|
||||
codecs.push('opus');
|
||||
}
|
||||
|
||||
if (getSupportedAudioCodecs('flac')) {
|
||||
codecs.push('flac');
|
||||
}
|
||||
|
||||
if (getSupportedAudioCodecs('alac')) {
|
||||
codecs.push('alac');
|
||||
}
|
||||
|
||||
if (hasVp8Support(videoTestElement) || isTizen()) {
|
||||
codecs.push('vorbis');
|
||||
}
|
||||
|
||||
return codecs;
|
||||
}
|
||||
158
src/lib/jellyfin/playback-profiles/helpers/mp4-video-formats.ts
Normal file
158
src/lib/jellyfin/playback-profiles/helpers/mp4-video-formats.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { isApple, isTizen, isTizen55, isTv, isWebOS5 } from '$lib/utils/browser-detection';
|
||||
|
||||
/**
|
||||
* Checks if the client has support for the H264 codec
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if browser has H264 support
|
||||
*/
|
||||
export function hasH264Support(videoTestElement: HTMLVideoElement): boolean {
|
||||
return !!(
|
||||
videoTestElement.canPlayType &&
|
||||
videoTestElement.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client has support for the H265 codec
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if browser has H265 support
|
||||
*/
|
||||
export function hasH265Support(videoTestElement: HTMLVideoElement): boolean {
|
||||
if (isTv()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!(
|
||||
videoTestElement.canPlayType &&
|
||||
(videoTestElement.canPlayType('video/mp4; codecs="hvc1.1.L120"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hev1.1.L120"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hvc1.1.0.L120"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hev1.1.0.L120"').replace(/no/, ''))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client has support for the HEVC codec
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if browser has HEVC Support
|
||||
*/
|
||||
export function hasHevcSupport(videoTestElement: HTMLVideoElement): boolean {
|
||||
if (isTv()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!(
|
||||
!!videoTestElement.canPlayType &&
|
||||
(videoTestElement.canPlayType('video/mp4; codecs="hvc1.1.L120"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hev1.1.L120"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hvc1.1.0.L120"').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mp4; codecs="hev1.1.0.L120"').replace(/no/, ''))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client has support for the AV1 codec
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if browser has AV1 support
|
||||
*/
|
||||
export function hasAv1Support(videoTestElement: HTMLVideoElement): boolean {
|
||||
if ((isTizen() && isTizen55()) || (isWebOS5() && window.outerHeight >= 2160)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!(
|
||||
videoTestElement.canPlayType &&
|
||||
videoTestElement.canPlayType('video/webm; codecs="av01.0.15M.10"').replace(/no/, '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client has support for the VC1 codec
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if browser has VC1 support
|
||||
*/
|
||||
function hasVc1Support(videoTestElement: HTMLVideoElement): boolean {
|
||||
return !!(isTv() || videoTestElement.canPlayType('video/mp4; codecs="vc-1"').replace(/no/, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client has support for the VP8 codec
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if browser has VP8 support
|
||||
*/
|
||||
export function hasVp8Support(videoTestElement: HTMLVideoElement): boolean {
|
||||
return !!(
|
||||
videoTestElement.canPlayType &&
|
||||
videoTestElement.canPlayType('video/webm; codecs="vp8"').replace(/no/, '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client has support for the VP9 codec
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if browser has VP9 support
|
||||
*/
|
||||
export function hasVp9Support(videoTestElement: HTMLVideoElement): boolean {
|
||||
return !!(
|
||||
videoTestElement.canPlayType &&
|
||||
videoTestElement.canPlayType('video/webm; codecs="vp9"').replace(/no/, '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the platform for the codecs suppers in an MP4 container.
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Array of codec identifiers.
|
||||
*/
|
||||
export function getSupportedMP4VideoCodecs(videoTestElement: HTMLVideoElement): string[] {
|
||||
const codecs = [];
|
||||
|
||||
if (hasH264Support(videoTestElement)) {
|
||||
codecs.push('h264');
|
||||
}
|
||||
|
||||
if (
|
||||
hasHevcSupport(videoTestElement) && // Safari is lying on HDR and 60fps videos, use fMP4 instead
|
||||
!isApple()
|
||||
) {
|
||||
codecs.push('hevc');
|
||||
}
|
||||
|
||||
if (isTv()) {
|
||||
codecs.push('mpeg2video');
|
||||
}
|
||||
|
||||
if (hasVc1Support(videoTestElement)) {
|
||||
codecs.push('vc1');
|
||||
}
|
||||
|
||||
if (isTizen()) {
|
||||
codecs.push('msmpeg4v2');
|
||||
}
|
||||
|
||||
if (hasVp8Support(videoTestElement)) {
|
||||
codecs.push('vp8');
|
||||
}
|
||||
|
||||
if (hasVp9Support(videoTestElement)) {
|
||||
codecs.push('vp9');
|
||||
}
|
||||
|
||||
if (hasAv1Support(videoTestElement)) {
|
||||
codecs.push('av1');
|
||||
}
|
||||
|
||||
return codecs;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { isEdge, isTizen, isTv, supportsMediaSource } from '$lib/utils/browser-detection';
|
||||
|
||||
/**
|
||||
* Checks if the client can play native HLS
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns Determines if the browser can play native Hls
|
||||
*/
|
||||
export function canPlayNativeHls(videoTestElement: HTMLVideoElement): boolean {
|
||||
if (isTizen()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!(
|
||||
videoTestElement.canPlayType('application/x-mpegURL').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('application/vnd.apple.mpegURL').replace(/no/, '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the browser can play Hls with Media Source Extensions
|
||||
*/
|
||||
export function canPlayHlsWithMSE(): boolean {
|
||||
return supportsMediaSource();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the browser can play Mkvs
|
||||
*/
|
||||
export function hasMkvSupport(videoTestElement: HTMLVideoElement): boolean {
|
||||
if (isTv()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
videoTestElement.canPlayType('video/x-matroska').replace(/no/, '') ||
|
||||
videoTestElement.canPlayType('video/mkv').replace(/no/, '')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!isEdge();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import {
|
||||
hasAacSupport,
|
||||
hasAc3InHlsSupport,
|
||||
hasAc3Support,
|
||||
hasEac3Support,
|
||||
hasMp3AudioSupport
|
||||
} from './mp4-audio-formats';
|
||||
|
||||
/**
|
||||
* List of supported Ts audio codecs
|
||||
*/
|
||||
export function getSupportedTsAudioCodecs(
|
||||
videoTestElement: HTMLVideoElement
|
||||
): string[] {
|
||||
const codecs = [];
|
||||
|
||||
if (hasAacSupport(videoTestElement)) {
|
||||
codecs.push('aac');
|
||||
}
|
||||
|
||||
if (hasMp3AudioSupport(videoTestElement)) {
|
||||
codecs.push('mp3');
|
||||
}
|
||||
|
||||
if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) {
|
||||
codecs.push('ac3');
|
||||
|
||||
if (hasEac3Support(videoTestElement)) {
|
||||
codecs.push('eac3');
|
||||
}
|
||||
}
|
||||
|
||||
return codecs;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { hasH264Support } from './mp4-video-formats';
|
||||
|
||||
/**
|
||||
* List of supported ts video codecs
|
||||
*/
|
||||
export function getSupportedTsVideoCodecs(
|
||||
videoTestElement: HTMLVideoElement
|
||||
): string[] {
|
||||
const codecs = [];
|
||||
|
||||
if (hasH264Support(videoTestElement)) {
|
||||
codecs.push('h264');
|
||||
}
|
||||
|
||||
return codecs;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { isWebOS } from '$lib/utils/browser-detection';
|
||||
|
||||
/**
|
||||
* Get an array of supported codecs
|
||||
*/
|
||||
export function getSupportedWebMAudioCodecs(videoTestElement: HTMLVideoElement): string[] {
|
||||
const codecs = [];
|
||||
|
||||
codecs.push('vorbis');
|
||||
|
||||
if (!isWebOS() && videoTestElement.canPlayType('audio/ogg; codecs="opus"').replace(/no/, '')) {
|
||||
codecs.push('opus');
|
||||
}
|
||||
|
||||
return codecs;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import {
|
||||
hasAv1Support,
|
||||
hasVp8Support,
|
||||
hasVp9Support
|
||||
} from './mp4-video-formats';
|
||||
|
||||
/**
|
||||
* Get an array of supported codecs WebM video codecs
|
||||
*/
|
||||
export function getSupportedWebMVideoCodecs(
|
||||
videoTestElement: HTMLVideoElement
|
||||
): string[] {
|
||||
const codecs = [];
|
||||
|
||||
if (hasVp8Support(videoTestElement)) {
|
||||
codecs.push('vp8');
|
||||
}
|
||||
|
||||
if (hasVp9Support(videoTestElement)) {
|
||||
codecs.push('vp9');
|
||||
}
|
||||
|
||||
if (hasAv1Support(videoTestElement)) {
|
||||
codecs.push('av1');
|
||||
}
|
||||
|
||||
return codecs;
|
||||
}
|
||||
58
src/lib/jellyfin/playback-profiles/index.ts
Normal file
58
src/lib/jellyfin/playback-profiles/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @deprecated
|
||||
* Since we're targeting modern environments/devices only, it makes sense to switch
|
||||
* to the native MediaCapabilities API, widely supported on modern devices, but not in older.
|
||||
*
|
||||
* Given a media file, we should test with MC the compatibility of video, audio and subtitle streams
|
||||
* independently:
|
||||
* If success: Don't request transcoding and direct play that specific stream.
|
||||
* If failure: Request transcoding of the failing streams to a previously hardcoded
|
||||
* bitrate/codec combination
|
||||
*
|
||||
* For the hardcoded bitrate/codecs combination we can use what we know that are universally
|
||||
* compatible, even without testing for explicit compatibility (we can do simple checks,
|
||||
* but the more we do, the complex/less portable our solution can get).
|
||||
* Examples: H264, AAC and VTT/SASS (thanks to JASSUB).
|
||||
*
|
||||
* Other codec combinations can be hardcoded, even if they're not direct-playable in
|
||||
* most browsers (like H265 or AV1), so the few browsers that support them benefits from less bandwidth
|
||||
* usage (although this will rarely happen: The most expected situations when transcoding
|
||||
* is when the media's codecs are more "powerful" than what the client is capable of, and H265 is
|
||||
* pretty modern, so it would've been catched-up by MediaCapabilities. However,
|
||||
* we must take into account the playback of really old codecs like MPEG or H263,
|
||||
* whose support are probably likely going to be removed from browsers,
|
||||
* so MediaCapabilities reports as unsupported, so we would be going from an "inferior" codec to a
|
||||
* "superior" codec in this situation)
|
||||
*/
|
||||
|
||||
import type { DeviceProfile as DP } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { getCodecProfiles } from './helpers/codec-profiles';
|
||||
import { getDirectPlayProfiles } from './directplay-profile';
|
||||
import { getTranscodingProfiles } from './transcoding-profile';
|
||||
import { getSubtitleProfiles } from './subtitle-profile';
|
||||
import { getResponseProfiles } from './response-profile';
|
||||
|
||||
export type DeviceProfile = DP;
|
||||
|
||||
/**
|
||||
* Creates a device profile containing supported codecs for the active Cast device.
|
||||
*
|
||||
* @param videoTestElement - Dummy video element for compatibility tests
|
||||
* @returns Device profile.
|
||||
*/
|
||||
function getDeviceProfile(videoTestElement?: HTMLVideoElement): DP {
|
||||
const element = videoTestElement || document.createElement('video');
|
||||
return {
|
||||
MaxStreamingBitrate: 120_000_000,
|
||||
MaxStaticBitrate: 0,
|
||||
MusicStreamingTranscodingBitrate: Math.min(120_000_000, 192_000),
|
||||
DirectPlayProfiles: getDirectPlayProfiles(element),
|
||||
TranscodingProfiles: getTranscodingProfiles(element),
|
||||
ContainerProfiles: [],
|
||||
CodecProfiles: getCodecProfiles(element),
|
||||
SubtitleProfiles: getSubtitleProfiles(),
|
||||
ResponseProfiles: getResponseProfiles()
|
||||
};
|
||||
}
|
||||
|
||||
export default getDeviceProfile;
|
||||
23
src/lib/jellyfin/playback-profiles/response-profile.ts
Normal file
23
src/lib/jellyfin/playback-profiles/response-profile.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { DlnaProfileType } from '@jellyfin/sdk/lib/generated-client';
|
||||
import type { ResponseProfile } from '@jellyfin/sdk/lib/generated-client';
|
||||
|
||||
/**
|
||||
* Returns a valid ResponseProfile for the current platform.
|
||||
*
|
||||
* @returns An array of subtitle profiles for the current platform.
|
||||
*/
|
||||
export function getResponseProfiles(): Array<ResponseProfile> {
|
||||
const ResponseProfiles = [];
|
||||
|
||||
ResponseProfiles.push({
|
||||
Type: DlnaProfileType.Video,
|
||||
Container: 'm4v',
|
||||
MimeType: 'video/mp4'
|
||||
});
|
||||
|
||||
return ResponseProfiles;
|
||||
}
|
||||
32
src/lib/jellyfin/playback-profiles/subtitle-profile.ts
Normal file
32
src/lib/jellyfin/playback-profiles/subtitle-profile.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { SubtitleDeliveryMethod } from '@jellyfin/sdk/lib/generated-client';
|
||||
import type { SubtitleProfile } from '@jellyfin/sdk/lib/generated-client';
|
||||
|
||||
/**
|
||||
* Returns a valid SubtitleProfile for the current platform.
|
||||
*
|
||||
* @returns An array of subtitle profiles for the current platform.
|
||||
*/
|
||||
export function getSubtitleProfiles(): Array<SubtitleProfile> {
|
||||
const SubtitleProfiles: Array<SubtitleProfile> = [];
|
||||
|
||||
SubtitleProfiles.push(
|
||||
{
|
||||
Format: 'vtt',
|
||||
Method: SubtitleDeliveryMethod.External
|
||||
},
|
||||
{
|
||||
Format: 'ass',
|
||||
Method: SubtitleDeliveryMethod.External
|
||||
},
|
||||
{
|
||||
Format: 'ssa',
|
||||
Method: SubtitleDeliveryMethod.External
|
||||
}
|
||||
);
|
||||
|
||||
return SubtitleProfiles;
|
||||
}
|
||||
118
src/lib/jellyfin/playback-profiles/transcoding-profile.ts
Normal file
118
src/lib/jellyfin/playback-profiles/transcoding-profile.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @deprecated - Check @/utils/playback-profiles/index
|
||||
*/
|
||||
|
||||
import { DlnaProfileType, EncodingContext } from '@jellyfin/sdk/lib/generated-client';
|
||||
import type { TranscodingProfile } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { getSupportedAudioCodecs } from './helpers/audio-formats';
|
||||
import { getSupportedMP4AudioCodecs } from './helpers/mp4-audio-formats';
|
||||
import { getSupportedMP4VideoCodecs, hasVp8Support } from './helpers/mp4-video-formats';
|
||||
import { canPlayNativeHls, canPlayHlsWithMSE, hasMkvSupport } from './helpers/transcoding-formats';
|
||||
import { getSupportedTsAudioCodecs } from './helpers/ts-audio-formats';
|
||||
import { getSupportedTsVideoCodecs } from './helpers/ts-video-formats';
|
||||
import {
|
||||
isTv,
|
||||
isApple,
|
||||
isEdge,
|
||||
isChromiumBased,
|
||||
isAndroid,
|
||||
isTizen
|
||||
} from '$lib/utils/browser-detection';
|
||||
|
||||
/**
|
||||
* Returns a valid TranscodingProfile for the current platform.
|
||||
*
|
||||
* @param videoTestElement - A HTML video element for testing codecs
|
||||
* @returns An array of transcoding profiles for the current platform.
|
||||
*/
|
||||
export function getTranscodingProfiles(
|
||||
videoTestElement: HTMLVideoElement
|
||||
): Array<TranscodingProfile> {
|
||||
const TranscodingProfiles: TranscodingProfile[] = [];
|
||||
const physicalAudioChannels = isTv() ? 6 : 2;
|
||||
|
||||
const hlsBreakOnNonKeyFrames = !!(
|
||||
isApple() ||
|
||||
(isEdge() && !isChromiumBased()) ||
|
||||
!canPlayNativeHls(videoTestElement)
|
||||
);
|
||||
|
||||
const mp4AudioCodecs = getSupportedMP4AudioCodecs(videoTestElement);
|
||||
const mp4VideoCodecs = getSupportedMP4VideoCodecs(videoTestElement);
|
||||
const canPlayHls = canPlayNativeHls(videoTestElement) || canPlayHlsWithMSE();
|
||||
|
||||
if (canPlayHls) {
|
||||
TranscodingProfiles.push({
|
||||
// hlsjs, edge, and android all seem to require ts container
|
||||
Container:
|
||||
!canPlayNativeHls(videoTestElement) || (isEdge() && !isChromiumBased()) || isAndroid()
|
||||
? 'ts'
|
||||
: 'aac',
|
||||
Type: DlnaProfileType.Audio,
|
||||
AudioCodec: 'aac',
|
||||
Context: EncodingContext.Streaming,
|
||||
Protocol: 'hls',
|
||||
MaxAudioChannels: physicalAudioChannels.toString(),
|
||||
MinSegments: isApple() ? 2 : 1,
|
||||
BreakOnNonKeyFrames: hlsBreakOnNonKeyFrames
|
||||
});
|
||||
}
|
||||
|
||||
for (const audioFormat of ['aac', 'mp3', 'opus', 'wav'].filter((format) =>
|
||||
getSupportedAudioCodecs(format)
|
||||
)) {
|
||||
TranscodingProfiles.push({
|
||||
Container: audioFormat,
|
||||
Type: DlnaProfileType.Audio,
|
||||
AudioCodec: audioFormat,
|
||||
Context: EncodingContext.Streaming,
|
||||
Protocol: 'http',
|
||||
MaxAudioChannels: physicalAudioChannels.toString()
|
||||
});
|
||||
}
|
||||
|
||||
const hlsInTsVideoCodecs = getSupportedTsVideoCodecs(videoTestElement);
|
||||
const hlsInTsAudioCodecs = getSupportedTsAudioCodecs(videoTestElement);
|
||||
|
||||
if (canPlayHls && hlsInTsVideoCodecs.length > 0 && hlsInTsAudioCodecs.length > 0) {
|
||||
TranscodingProfiles.push({
|
||||
Container: 'ts',
|
||||
Type: DlnaProfileType.Video,
|
||||
AudioCodec: hlsInTsAudioCodecs.join(','),
|
||||
VideoCodec: hlsInTsVideoCodecs.join(','),
|
||||
Context: EncodingContext.Streaming,
|
||||
Protocol: 'hls',
|
||||
MaxAudioChannels: physicalAudioChannels.toString(),
|
||||
MinSegments: isApple() ? 2 : 1,
|
||||
BreakOnNonKeyFrames: hlsBreakOnNonKeyFrames
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMkvSupport(videoTestElement) && !isTizen()) {
|
||||
TranscodingProfiles.push({
|
||||
Container: 'mkv',
|
||||
Type: DlnaProfileType.Video,
|
||||
AudioCodec: mp4AudioCodecs.join(','),
|
||||
VideoCodec: mp4VideoCodecs.join(','),
|
||||
Context: EncodingContext.Streaming,
|
||||
MaxAudioChannels: physicalAudioChannels.toString(),
|
||||
CopyTimestamps: true
|
||||
});
|
||||
}
|
||||
|
||||
if (hasVp8Support(videoTestElement)) {
|
||||
TranscodingProfiles.push({
|
||||
Container: 'webm',
|
||||
Type: DlnaProfileType.Video,
|
||||
AudioCodec: 'vorbis',
|
||||
VideoCodec: 'vpx',
|
||||
Context: EncodingContext.Streaming,
|
||||
Protocol: 'http',
|
||||
// If audio transcoding is needed, limit channels to number of physical audio channels
|
||||
// Trying to transcode to 5 channels when there are only 2 speakers generally does not sound good
|
||||
MaxAudioChannels: physicalAudioChannels.toString()
|
||||
});
|
||||
}
|
||||
|
||||
return TranscodingProfiles;
|
||||
}
|
||||
304
src/lib/utils/browser-detection.ts
Normal file
304
src/lib/utils/browser-detection.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Utilities to detect the browser and get information on the current environment
|
||||
* Based on https://github.com/google/shaka-player/blob/master/lib/util/platform.js
|
||||
*
|
||||
* @deprecated - Parsing User Agent is a maintenance burden and
|
||||
* should rely on external libraries only. It's also going to be replaced with Client-Hints.
|
||||
* Migration paths:
|
||||
* * Check for platform-specific features *where needed*
|
||||
* directly (i.e Chromecast/AirPlay/MSE) instead of a per-browser basis.
|
||||
* This will always be 100% fault free.
|
||||
*
|
||||
* * Use something like https://www.npmjs.com/package/unique-names-generator to
|
||||
* distinguish between instances. Instance names could be shown and be modified by the user
|
||||
* at settings. This would make user instances distinguishable in a 100% fault-tolerant way
|
||||
* and solve incongruencies like how a device is named. For instance,
|
||||
* an instance running in an Android Auto headset will be recognised as Android only, which is less
|
||||
* than ideal.
|
||||
*/
|
||||
export function supportsMediaSource(): boolean {
|
||||
// Browsers that lack a media source implementation will have no reference
|
||||
// to |window.MediaSource|.
|
||||
return !!window.MediaSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user agent of the navigator contains a key.
|
||||
*
|
||||
* @private
|
||||
* @static
|
||||
* @param key - Key for which to perform a check.
|
||||
* @returns Determines if user agent of navigator contains a key
|
||||
*/
|
||||
function userAgentContains(key: string): boolean {
|
||||
const userAgent = navigator.userAgent || '';
|
||||
|
||||
return userAgent.includes(key);
|
||||
}
|
||||
|
||||
/* Desktop Browsers */
|
||||
|
||||
/**
|
||||
* Check if the current platform is Mozilla Firefox.
|
||||
*
|
||||
* @returns Determines if browser is Mozilla Firefox
|
||||
*/
|
||||
export function isFirefox(): boolean {
|
||||
return userAgentContains('Firefox/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current platform is Microsoft Edge.
|
||||
*
|
||||
* @static
|
||||
* @returns Determines if browser is Microsoft Edge
|
||||
*/
|
||||
export function isEdge(): boolean {
|
||||
return userAgentContains('Edg/') || userAgentContains('Edge/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current platform is Chromium based.
|
||||
*
|
||||
* @returns Determines if browser is Chromium based
|
||||
*/
|
||||
export function isChromiumBased(): boolean {
|
||||
return userAgentContains('Chrome');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current platform is Google Chrome.
|
||||
*
|
||||
* @returns Determines if browser is Google Chrome
|
||||
*/
|
||||
export function isChrome(): boolean {
|
||||
// The Edge user agent will also contain the "Chrome" keyword, so we need
|
||||
// to make sure this is not Edge.
|
||||
return userAgentContains('Chrome') && !isEdge() && !isWebOS();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current platform is from Apple.
|
||||
*
|
||||
* Returns true on all iOS browsers and on desktop Safari.
|
||||
*
|
||||
* Returns false for non-Safari browsers on macOS, which are independent of
|
||||
* Apple.
|
||||
*
|
||||
* @returns Determines if current platform is from Apple
|
||||
*/
|
||||
export function isApple(): boolean {
|
||||
return navigator?.vendor.includes('Apple') && !isTizen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a major version number for Safari, or Safari-based iOS browsers.
|
||||
*
|
||||
* @returns The major version number for Safari
|
||||
*/
|
||||
export function safariVersion(): number | undefined {
|
||||
// All iOS browsers and desktop Safari will return true for isApple().
|
||||
if (!isApple()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let userAgent = '';
|
||||
|
||||
if (navigator.userAgent) {
|
||||
userAgent = navigator.userAgent;
|
||||
}
|
||||
|
||||
// This works for iOS Safari and desktop Safari, which contain something
|
||||
// like "Version/13.0" indicating the major Safari or iOS version.
|
||||
let match = userAgent.match(/Version\/(\d+)/);
|
||||
|
||||
if (match) {
|
||||
return Number.parseInt(match[1], /* base= */ 10);
|
||||
}
|
||||
|
||||
// This works for all other browsers on iOS, which contain something like
|
||||
// "OS 13_3" indicating the major & minor iOS version.
|
||||
match = userAgent.match(/OS (\d+)(?:_\d+)?/);
|
||||
|
||||
if (match) {
|
||||
return Number.parseInt(match[1], /* base= */ 10);
|
||||
}
|
||||
}
|
||||
|
||||
/* TV Platforms */
|
||||
|
||||
/**
|
||||
* Check if the current platform is Tizen.
|
||||
*
|
||||
* @returns Determines if current platform is Tizen
|
||||
*/
|
||||
export function isTizen(): boolean {
|
||||
return userAgentContains('Tizen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current platform is Tizen 2
|
||||
*
|
||||
* @returns Determines if current platform is Tizen 2
|
||||
*/
|
||||
export function isTizen2(): boolean {
|
||||
return userAgentContains('Tizen 2');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current platform is Tizen 3
|
||||
*
|
||||
* @returns Determines if current platform is Tizen 3
|
||||
* @memberof BrowserDetector
|
||||
*/
|
||||
export function isTizen3(): boolean {
|
||||
return userAgentContains('Tizen 3');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current platform is Tizen 4.
|
||||
*
|
||||
* @returns Determines if current platform is Tizen 4
|
||||
* @memberof BrowserDetector
|
||||
*/
|
||||
export function isTizen4(): boolean {
|
||||
return userAgentContains('Tizen 4');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current platform is Tizen 5.
|
||||
*
|
||||
* @returns Determines if current platform is Tizen 5
|
||||
* @memberof BrowserDetector
|
||||
*/
|
||||
export function isTizen5(): boolean {
|
||||
return userAgentContains('Tizen 5');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current platform is Tizen 5.5.
|
||||
*
|
||||
* @returns Determines if current platform is Tizen 5.5
|
||||
* @memberof BrowserDetector
|
||||
*/
|
||||
export function isTizen55(): boolean {
|
||||
return userAgentContains('Tizen 5.5');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current platform is WebOS.
|
||||
*
|
||||
* @returns Determines if current platform is WebOS
|
||||
* @memberof BrowserDetector
|
||||
*/
|
||||
export function isWebOS(): boolean {
|
||||
return userAgentContains('Web0S');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if current platform is WebOS1
|
||||
*/
|
||||
export function isWebOS1(): boolean {
|
||||
return (
|
||||
isWebOS() &&
|
||||
userAgentContains('AppleWebKit/537') &&
|
||||
!userAgentContains('Chrome/')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if current platform is WebOS2
|
||||
*/
|
||||
export function isWebOS2(): boolean {
|
||||
return (
|
||||
isWebOS() &&
|
||||
userAgentContains('AppleWebKit/538') &&
|
||||
!userAgentContains('Chrome/')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if current platform is WebOS3
|
||||
*/
|
||||
export function isWebOS3(): boolean {
|
||||
return isWebOS() && userAgentContains('Chrome/38');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if current platform is WebOS4
|
||||
*/
|
||||
export function isWebOS4(): boolean {
|
||||
return isWebOS() && userAgentContains('Chrome/53');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if current platform is WebOS5
|
||||
*/
|
||||
export function isWebOS5(): boolean {
|
||||
return isWebOS() && userAgentContains('Chrome/68');
|
||||
}
|
||||
|
||||
/* Platform Utilities */
|
||||
|
||||
/**
|
||||
* Determines if current platform is Android
|
||||
*/
|
||||
export function isAndroid(): boolean {
|
||||
return userAgentContains('Android');
|
||||
}
|
||||
|
||||
/**
|
||||
* Guesses if the platform is a mobile one (iOS or Android).
|
||||
*
|
||||
* @returns Determines if current platform is mobile (Guess)
|
||||
*/
|
||||
export function isMobile(): boolean {
|
||||
let userAgent = '';
|
||||
|
||||
if (navigator.userAgent) {
|
||||
userAgent = navigator.userAgent;
|
||||
}
|
||||
|
||||
if (/iPhone|iPad|iPod|Android/.test(userAgent)) {
|
||||
// This is Android, iOS, or iPad < 13.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Starting with iOS 13 on iPad, the user agent string no longer has the
|
||||
// word "iPad" in it. It looks very similar to desktop Safari. This seems
|
||||
// to be intentional on Apple's part.
|
||||
// See: https://forums.developer.apple.com/thread/119186
|
||||
//
|
||||
// So if it's an Apple device with multi-touch support, assume it's a mobile
|
||||
// device. If some future iOS version starts masking their user agent on
|
||||
// both iPhone & iPad, this clause should still work. If a future
|
||||
// multi-touch desktop Mac is released, this will need some adjustment.
|
||||
return isApple() && navigator.maxTouchPoints > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guesses if the platform is a Smart TV (Tizen or WebOS).
|
||||
*
|
||||
* @returns Determines if platform is a Smart TV
|
||||
*/
|
||||
export function isTv(): boolean {
|
||||
return isTizen() || isWebOS();
|
||||
}
|
||||
|
||||
/**
|
||||
* Guesses if the platform is a PS4
|
||||
*
|
||||
* @returns Determines if the device is a PS4
|
||||
*/
|
||||
export function isPs4(): boolean {
|
||||
return userAgentContains('playstation 4');
|
||||
}
|
||||
|
||||
/**
|
||||
* Guesses if the platform is a Xbox
|
||||
*
|
||||
* @returns Determines if the device is a Xbox
|
||||
*/
|
||||
export function isXbox(): boolean {
|
||||
return userAgentContains('xbox');
|
||||
}
|
||||
@@ -28,7 +28,9 @@
|
||||
</script>
|
||||
|
||||
{#if movies[index]}
|
||||
{#await Promise.all(movies) then awaitedMovies}
|
||||
{#await Promise.all(movies)}
|
||||
<div class="h-screen" />
|
||||
{:then awaitedMovies}
|
||||
<ResourceDetails movie={awaitedMovies[index]}>
|
||||
<ResourceDetailsControls
|
||||
slot="page-controls"
|
||||
@@ -41,6 +43,8 @@
|
||||
{:catch err}
|
||||
Error occurred {JSON.stringify(err)}
|
||||
{/await}
|
||||
{:else}
|
||||
<div class="h-screen" />
|
||||
{/if}
|
||||
|
||||
{#await data.streamed.continueWatching then continueWatching}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
streamFetching = true;
|
||||
getJellyfinItemByTmdbId(tmdbId).then((item: any) => {
|
||||
if (item.Id) streamJellyfinId(item.Id);
|
||||
streamFetching = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -54,6 +55,7 @@
|
||||
<div class="w-full h-full hover:bg-darken transition-all flex">
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-2 flex flex-col justify-between flex-1 cursor-pointer"
|
||||
on:click={() => (window.location.href = '/' + type + '/' + tmdbId)}
|
||||
>
|
||||
<div>
|
||||
<h1 class="font-bold text-lg tracking-wide">
|
||||
@@ -73,9 +75,10 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
class="bg-white border-2 border-white hover:bg-amber-400 hover:border-amber-400 transition-colors text-zinc-900 px-8 py-2.5 uppercase tracking-widest font-extrabold cursor-pointer text-xs"
|
||||
on:click={stream}>Stream</button
|
||||
on:click|stopPropagation={stream}>Stream</button
|
||||
>
|
||||
<a
|
||||
on:click|stopPropagation
|
||||
href={'/' + type + '/' + tmdbId}
|
||||
class="border-2 border-white cursor-pointer transition-colors px-8 py-2.5 uppercase tracking-widest font-semibold text-xs hover:bg-amber-400 hover:text-black text-center"
|
||||
>Details</a
|
||||
|
||||
@@ -1,31 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { fetchJellyfinPlaybackUrl } from '$lib/jellyfin/jellyfin';
|
||||
import {
|
||||
getJellyfinItem,
|
||||
getJellyfinPlaybackInfo,
|
||||
reportJellyfinPlaybackProgress,
|
||||
reportJellyfinPlaybackStarted,
|
||||
reportJellyfinPlaybackStopped
|
||||
} from '$lib/jellyfin/jellyfin';
|
||||
import Hls from 'hls.js';
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { Cross2 } from 'radix-icons-svelte';
|
||||
import classNames from 'classnames';
|
||||
import { getContext } from 'svelte';
|
||||
import { getContext, onDestroy } from 'svelte';
|
||||
import { PUBLIC_JELLYFIN_URL } from '$env/static/public';
|
||||
import getDeviceProfile from '$lib/jellyfin/playback-profiles';
|
||||
|
||||
const { playerState, close } = getContext('player');
|
||||
|
||||
let video: HTMLVideoElement;
|
||||
|
||||
const fetchPlaybackInfo = (id: string) =>
|
||||
fetchJellyfinPlaybackUrl(id).then((uri) => {
|
||||
if (!uri) return;
|
||||
let stopCallback;
|
||||
|
||||
const hls = new Hls();
|
||||
let progressInterval;
|
||||
onDestroy(() => clearInterval(progressInterval));
|
||||
|
||||
hls.loadSource(PUBLIC_JELLYFIN_URL + uri);
|
||||
hls.attachMedia(video);
|
||||
video.play();
|
||||
});
|
||||
const fetchPlaybackInfo = (itemId: string) =>
|
||||
getJellyfinPlaybackInfo(itemId, getDeviceProfile()).then(
|
||||
async ({ playbackUrl: uri, playSessionId: sessionId, mediaSourceId }) => {
|
||||
if (!uri || !sessionId) return;
|
||||
|
||||
const item = await getJellyfinItem(itemId);
|
||||
|
||||
const hls = new Hls();
|
||||
|
||||
hls.loadSource(PUBLIC_JELLYFIN_URL + uri);
|
||||
hls.attachMedia(video);
|
||||
video.play().then(() => {
|
||||
console.log(item);
|
||||
if (item?.UserData?.PlaybackPositionTicks) {
|
||||
console.log('Setting time');
|
||||
video.currentTime = item?.UserData?.PlaybackPositionTicks / 10_000_000;
|
||||
}
|
||||
});
|
||||
await reportJellyfinPlaybackStarted(itemId, sessionId, mediaSourceId);
|
||||
progressInterval = setInterval(() => {
|
||||
reportJellyfinPlaybackProgress(
|
||||
itemId,
|
||||
sessionId,
|
||||
video?.paused == true,
|
||||
video?.currentTime * 10_000_000
|
||||
);
|
||||
}, 5000);
|
||||
stopCallback = () => {
|
||||
reportJellyfinPlaybackStopped(itemId, sessionId, video?.currentTime * 10_000_000);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
close();
|
||||
video?.pause();
|
||||
clearInterval(progressInterval);
|
||||
stopCallback?.();
|
||||
playerState.set({ visible: false, jellyfinId: '' });
|
||||
}
|
||||
|
||||
let uiVisible = false;
|
||||
@@ -47,7 +84,7 @@
|
||||
throw new Error('HLS is not supported');
|
||||
}
|
||||
|
||||
fetchPlaybackInfo(state.jellyfinId);
|
||||
if (video.src === '') fetchPlaybackInfo(state.jellyfinId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Card from '../components/Card/Card.svelte';
|
||||
import { TMDB_IMAGES } from '$lib/constants.js';
|
||||
import CardPlaceholder from '../components/Card/CardPlaceholder.svelte';
|
||||
import CardProvider from '../components/Card/CardProvider.svelte';
|
||||
export let data: PageData;
|
||||
const watched = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user