mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-19 08:53:23 +02:00
feat: Experimental movie playback through plugins
This commit is contained in:
69
src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte
Normal file
69
src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { get } from 'svelte/store';
|
||||
import { sessions } from '../../stores/session.store';
|
||||
import { reiverrApiNew } from '../../stores/user.store';
|
||||
import type { PlaybackInfo, VideoPlayerContext } from './VideoPlayer';
|
||||
import VideoPlayerModal from './VideoPlayerModal.svelte';
|
||||
import { getQualities } from '../../apis/jellyfin/qualities';
|
||||
import getDeviceProfile from '../../apis/jellyfin/playback-profiles';
|
||||
import type { VideoStreamDto } from '../../apis/reiverr/reiverr.openapi';
|
||||
import { tmdbApi } from '../../apis/tmdb/tmdb-api';
|
||||
|
||||
export let tmdbId: string;
|
||||
export let sourceId: string;
|
||||
|
||||
export let modalId: symbol;
|
||||
export let hidden: boolean = false;
|
||||
|
||||
let title: string = '';
|
||||
let subtitle: string = '';
|
||||
|
||||
let sourceUri = '';
|
||||
|
||||
let playerContext: VideoPlayerContext | undefined;
|
||||
|
||||
let videoStreamP: Promise<VideoStreamDto>;
|
||||
|
||||
const movieP = tmdbApi.getTmdbMovie(Number(tmdbId)).then((r) => {
|
||||
title = r?.title || '';
|
||||
subtitle = '';
|
||||
});
|
||||
|
||||
const refreshVideoStream = async (audioStreamIndex = 0) => {
|
||||
console.log('called2');
|
||||
videoStreamP = reiverrApiNew.movies
|
||||
.getMovieStream(sourceId, tmdbId, {
|
||||
// bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000,
|
||||
progress: 0,
|
||||
audioStreamIndex,
|
||||
deviceProfile: getDeviceProfile() as any
|
||||
})
|
||||
.then((r) => r.data)
|
||||
.then((d) => ({
|
||||
...d,
|
||||
uri: d.uri
|
||||
}));
|
||||
|
||||
await videoStreamP;
|
||||
};
|
||||
|
||||
refreshVideoStream();
|
||||
/*
|
||||
title
|
||||
subtitle
|
||||
sections
|
||||
|
||||
sourceUri <- quality
|
||||
playbackPosition
|
||||
*/
|
||||
</script>
|
||||
|
||||
<VideoPlayerModal
|
||||
{...$$props}
|
||||
{modalId}
|
||||
{hidden}
|
||||
{videoStreamP}
|
||||
{refreshVideoStream}
|
||||
{title}
|
||||
{subtitle}
|
||||
/>
|
||||
@@ -1,7 +1,12 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
import { jellyfinItemsStore } from '../../stores/data.store';
|
||||
import VideoPlayerModal from './JellyfinVideoPlayerModal.svelte';
|
||||
import JellyfinVideoPlayerModal from './JellyfinVideoPlayerModal.svelte';
|
||||
import { reiverrApiNew } from '../../stores/user.store';
|
||||
import { createErrorNotification } from '../Notifications/notification.store';
|
||||
import VideoPlayerModal from './VideoPlayerModal.svelte';
|
||||
import { sources } from '../../stores/sources.store';
|
||||
import MovieVideoPlayerModal from './MovieVideoPlayerModal.svelte';
|
||||
|
||||
export type SubtitleInfo = {
|
||||
subtitles?: Subtitles;
|
||||
@@ -21,6 +26,12 @@ export type AudioTrack = {
|
||||
index: number;
|
||||
};
|
||||
|
||||
export interface VideoPlayerContext {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
playbackInfo?: PlaybackInfo;
|
||||
}
|
||||
|
||||
export type PlaybackInfo = {
|
||||
playbackUrl: string;
|
||||
directPlay: boolean;
|
||||
@@ -32,26 +43,45 @@ export type PlaybackInfo = {
|
||||
selectAudioTrack: (index: number) => void;
|
||||
};
|
||||
|
||||
const initialValue = { visible: false, jellyfinId: '' };
|
||||
const initialValue = { visible: false, jellyfinId: '', sourceId: '' };
|
||||
export type PlayerStateValue = typeof initialValue;
|
||||
|
||||
function createPlayerState() {
|
||||
function usePlayerState() {
|
||||
const store = writable<PlayerStateValue>(initialValue);
|
||||
|
||||
async function streamMovie(tmdbId: string, sourceId: string = '') {
|
||||
if (!sourceId) {
|
||||
const sources = await reiverrApiNew.movies.getMovieSources(tmdbId).then((r) => r.data);
|
||||
sourceId = Object.keys(sources.sources)[0] || '';
|
||||
}
|
||||
|
||||
if (!sourceId) {
|
||||
createErrorNotification('Could not find a suitable source');
|
||||
return;
|
||||
}
|
||||
|
||||
store.set({ visible: true, jellyfinId: tmdbId, sourceId });
|
||||
modalStack.create(MovieVideoPlayerModal, {
|
||||
tmdbId,
|
||||
sourceId
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...store,
|
||||
streamMovie,
|
||||
streamJellyfinId: (id: string) => {
|
||||
store.set({ visible: true, jellyfinId: id });
|
||||
modalStack.create(VideoPlayerModal, { id });
|
||||
store.set({ visible: true, jellyfinId: id, sourceId: '' });
|
||||
modalStack.create(JellyfinVideoPlayerModal, { id });
|
||||
},
|
||||
close: () => {
|
||||
store.set({ visible: false, jellyfinId: '' });
|
||||
store.set({ visible: false, jellyfinId: '', sourceId: '' });
|
||||
jellyfinItemsStore.send();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const playerState = createPlayerState();
|
||||
export const playerState = usePlayerState();
|
||||
|
||||
export function getBrowserSpecificMediaFunctions() {
|
||||
// These functions are different in every browser
|
||||
|
||||
367
src/lib/components/VideoPlayer/VideoPlayerModal.svelte
Normal file
367
src/lib/components/VideoPlayer/VideoPlayerModal.svelte
Normal file
@@ -0,0 +1,367 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import Container from '../../../Container.svelte';
|
||||
import VideoPlayer from './VideoPlayer.svelte';
|
||||
import type { PlaybackInfo, Subtitles, SubtitleInfo, AudioTrack } from './VideoPlayer';
|
||||
import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api';
|
||||
import getDeviceProfile from '../../apis/jellyfin/playback-profiles';
|
||||
import { getQualities } from '../../apis/jellyfin/qualities';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { modalStack, modalStackTop } from '../Modal/modal.store';
|
||||
import { createLocalStorageStore } from '../../stores/localstorage.store';
|
||||
import { get } from 'svelte/store';
|
||||
import { ISO_2_LANGUAGES } from '../../utils/iso-2-languages';
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import { reiverrApiNew, user } from '../../stores/user.store';
|
||||
import { reiverrApi } from '../../apis/reiverr/reiverr-api';
|
||||
import type { VideoStreamDto } from '../../apis/reiverr/reiverr.openapi';
|
||||
import { sessions } from '../../stores/session.store';
|
||||
|
||||
type MediaLanguageStore = {
|
||||
subtitles?: string;
|
||||
audio?: string;
|
||||
};
|
||||
|
||||
export let videoStreamP: Promise<VideoStreamDto>;
|
||||
export let refreshVideoStream: (audioStreamIndex?: number) => Promise<void>;
|
||||
|
||||
export let modalId: symbol;
|
||||
export let hidden: boolean = false;
|
||||
|
||||
// const itemP = jellyfinApi.getLibraryItem(id);
|
||||
|
||||
export let title: string = '';
|
||||
export let subtitle: string = '';
|
||||
// itemP.then((item) => {
|
||||
// title = item?.Name || '';
|
||||
// subtitle = `${item?.SeriesName || ''} S${item?.ParentIndexNumber || ''}E${
|
||||
// item?.IndexNumber || ''
|
||||
// }`;
|
||||
// });
|
||||
|
||||
let video: HTMLVideoElement;
|
||||
let paused: boolean;
|
||||
let progressTime: number;
|
||||
|
||||
let playbackInfo: PlaybackInfo | undefined;
|
||||
let subtitleInfo: SubtitleInfo | undefined;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
let reportProgressInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
const reportProgress = () => {};
|
||||
|
||||
$: {
|
||||
videoStreamP;
|
||||
console.log('videoStreamP', videoStreamP);
|
||||
}
|
||||
|
||||
$: videoStreamP && asd();
|
||||
|
||||
const asd = () =>
|
||||
videoStreamP.then((stream) => {
|
||||
// async function loadPlaybackInfo(
|
||||
// options: { audioStreamIndex?: number; bitrate?: number; playbackPosition?: number } = {}
|
||||
// ) {
|
||||
// const item = await itemP;
|
||||
const mediaLanguagesStore = createLocalStorageStore<MediaLanguageStore>(
|
||||
'media-tracks-' + title,
|
||||
{}
|
||||
);
|
||||
// const storedAudioStreamIndex = item?.MediaStreams?.find(
|
||||
// (s) => s.Type === 'Audio' && s.Language === mediaLanguagesStore.get().audio
|
||||
// )?.Index;
|
||||
// const audioStreamIndex = options.audioStreamIndex ?? storedAudioStreamIndex ?? undefined;
|
||||
|
||||
// const jellyfinPlaybackInfo = await jellyfinApi.getPlaybackInfo(
|
||||
// id,
|
||||
// getDeviceProfile(),
|
||||
// options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0,
|
||||
// options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate,
|
||||
// audioStreamIndex
|
||||
// );
|
||||
|
||||
// if (!item || !jellyfinPlaybackInfo) {
|
||||
// console.error('No item or playback info', item, jellyfinPlaybackInfo);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const { playbackUri, playSessionId, mediaSourceId, directPlay } = jellyfinPlaybackInfo;
|
||||
|
||||
// if (!playbackUri || !playSessionId) {
|
||||
// console.error('No playback URL or session ID', playbackUri, playSessionId);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// sessionId = playSessionId;
|
||||
|
||||
// const mediaSource = jellyfinPlaybackInfo.MediaSources?.[0];
|
||||
|
||||
// const storedSubtitlesLang = mediaLanguagesStore.get().subtitles;
|
||||
|
||||
// if (options.audioStreamIndex) {
|
||||
// const audioLang = mediaSource?.MediaStreams?.[options.audioStreamIndex]?.Language;
|
||||
// mediaLanguagesStore.update((prev) => ({
|
||||
// ...prev,
|
||||
// audio: audioLang || undefined
|
||||
// }));
|
||||
// }
|
||||
|
||||
let subtitles: Subtitles | undefined;
|
||||
// for (const stream of mediaSource?.MediaStreams || []) {
|
||||
// if (
|
||||
// stream.Type === 'Subtitle' &&
|
||||
// (storedSubtitlesLang !== undefined
|
||||
// ? stream.Language === storedSubtitlesLang
|
||||
// : stream.IsDefault)
|
||||
// ) {
|
||||
// subtitles = {
|
||||
// kind: 'subtitles',
|
||||
// srclang: stream.Language || '',
|
||||
// url: `${$user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${stream.Index}/${stream.Level}/Stream.vtt`,
|
||||
// // @ts-ignore
|
||||
// language: ISO_2_LANGUAGES[stream?.Language || '']?.name || 'English'
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
const availableSubtitles: Subtitles[] = stream.subtitles.map((s) => ({
|
||||
kind: 'subtitles',
|
||||
srclang: s.label,
|
||||
url: get(sessions).activeSession?.baseUrl + s.uri,
|
||||
language: s.label
|
||||
}));
|
||||
// =
|
||||
// mediaSource?.MediaStreams?.filter((s) => s.Type === 'Subtitle').map((s) => ({
|
||||
// kind: 'subtitles' as const,
|
||||
// srclang: s.Language || '',
|
||||
// url: `${$user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${s.Index}/${s.Level}/Stream.vtt`,
|
||||
// language: 'English'
|
||||
// })) || [];
|
||||
|
||||
const selectSubtitles = (subtitles?: Subtitles) => {
|
||||
mediaLanguagesStore.update((prev) => ({
|
||||
...prev,
|
||||
subtitles: subtitles?.srclang || ''
|
||||
}));
|
||||
|
||||
if (subtitleInfo) {
|
||||
if (subtitles)
|
||||
subtitleInfo = {
|
||||
...subtitleInfo,
|
||||
subtitles
|
||||
};
|
||||
else
|
||||
subtitleInfo = {
|
||||
...subtitleInfo,
|
||||
subtitles: undefined
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
subtitleInfo = {
|
||||
subtitles,
|
||||
availableSubtitles,
|
||||
selectSubtitles
|
||||
};
|
||||
|
||||
playbackInfo = {
|
||||
audioStreamIndex: 0, // audioStreamIndex ?? mediaSource?.DefaultAudioStreamIndex ?? -1,
|
||||
audioTracks: [],
|
||||
// mediaSource?.MediaStreams?.filter((s) => s.Type === 'Audio').map((s) => ({
|
||||
// index: s.Index || -1,
|
||||
// language: s.Language || ''
|
||||
// })) || [],
|
||||
selectAudioTrack: (index: number) => refreshVideoStream(index),
|
||||
// loadPlaybackInfo({
|
||||
// ...options,
|
||||
// audioStreamIndex: index,
|
||||
// playbackPosition: progressTime * 10_000_000
|
||||
// }),
|
||||
directPlay: stream.directPlay,
|
||||
playbackUrl: (get(sessions).activeSession?.baseUrl || '') + stream.uri,
|
||||
backdrop:
|
||||
// item?.BackdropImageTags?.length
|
||||
// ? `${$user?.settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
|
||||
// :
|
||||
'',
|
||||
startTime: stream.progress
|
||||
// (options.playbackPosition || 0) / 10_000_000 ||
|
||||
// (item?.UserData?.PlaybackPositionTicks || 0) / 10_000_000 ||
|
||||
// undefined
|
||||
};
|
||||
|
||||
// if (mediaSourceId) reportPlaybackStarted(id, sessionId, mediaSourceId);
|
||||
|
||||
if (reportProgressInterval) clearInterval(reportProgressInterval);
|
||||
// reportProgressInterval = setInterval(() => {
|
||||
// if (video?.readyState === 4 && progressTime > 0 && sessionId && id)
|
||||
// reportProgress(id, sessionId, paused, progressTime);
|
||||
// }, 10_000);
|
||||
});
|
||||
|
||||
// const reportPlaybackStarted = (id: string, sessionId: string, mediaSourceId: string) =>
|
||||
// jellyfinApi.reportPlaybackStarted(id, sessionId, mediaSourceId);
|
||||
|
||||
// const reportProgress = (id: string, sessionId: string, paused: boolean, progressTime: number) =>
|
||||
// jellyfinApi.reportPlaybackProgress(id, sessionId, paused, progressTime * 10_000_000);
|
||||
|
||||
// const deleteEncoding = (sessionId: string) => jellyfinApi.deleteActiveEncoding(sessionId);
|
||||
|
||||
// const reportPlaybackStopped = (id: string, sessionId: string, progressTime: number) => {
|
||||
// jellyfinApi.reportPlaybackStopped(id, sessionId, progressTime * 10_000_000);
|
||||
// deleteEncoding(sessionId);
|
||||
// };
|
||||
|
||||
// async function loadPlaybackInfo(
|
||||
// options: { audioStreamIndex?: number; bitrate?: number; playbackPosition?: number } = {}
|
||||
// ) {
|
||||
// const item = await itemP;
|
||||
// const mediaLanguagesStore = createLocalStorageStore<MediaLanguageStore>(
|
||||
// 'media-tracks-' + (item?.SeriesName || id),
|
||||
// {}
|
||||
// );
|
||||
// const storedAudioStreamIndex = item?.MediaStreams?.find(
|
||||
// (s) => s.Type === 'Audio' && s.Language === mediaLanguagesStore.get().audio
|
||||
// )?.Index;
|
||||
// const audioStreamIndex = options.audioStreamIndex ?? storedAudioStreamIndex ?? undefined;
|
||||
|
||||
// const jellyfinPlaybackInfo = await jellyfinApi.getPlaybackInfo(
|
||||
// id,
|
||||
// getDeviceProfile(),
|
||||
// options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0,
|
||||
// options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate,
|
||||
// audioStreamIndex
|
||||
// );
|
||||
|
||||
// if (!item || !jellyfinPlaybackInfo) {
|
||||
// console.error('No item or playback info', item, jellyfinPlaybackInfo);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const { playbackUri, playSessionId, mediaSourceId, directPlay } = jellyfinPlaybackInfo;
|
||||
|
||||
// if (!playbackUri || !playSessionId) {
|
||||
// console.error('No playback URL or session ID', playbackUri, playSessionId);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// sessionId = playSessionId;
|
||||
|
||||
// const mediaSource = jellyfinPlaybackInfo.MediaSources?.[0];
|
||||
|
||||
// const storedSubtitlesLang = mediaLanguagesStore.get().subtitles;
|
||||
|
||||
// if (options.audioStreamIndex) {
|
||||
// const audioLang = mediaSource?.MediaStreams?.[options.audioStreamIndex]?.Language;
|
||||
// mediaLanguagesStore.update((prev) => ({
|
||||
// ...prev,
|
||||
// audio: audioLang || undefined
|
||||
// }));
|
||||
// }
|
||||
|
||||
// let subtitles: Subtitles | undefined;
|
||||
// for (const stream of mediaSource?.MediaStreams || []) {
|
||||
// if (
|
||||
// stream.Type === 'Subtitle' &&
|
||||
// (storedSubtitlesLang !== undefined
|
||||
// ? stream.Language === storedSubtitlesLang
|
||||
// : stream.IsDefault)
|
||||
// ) {
|
||||
// subtitles = {
|
||||
// kind: 'subtitles',
|
||||
// srclang: stream.Language || '',
|
||||
// url: `${$user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${stream.Index}/${stream.Level}/Stream.vtt`,
|
||||
// // @ts-ignore
|
||||
// language: ISO_2_LANGUAGES[stream?.Language || '']?.name || 'English'
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
// const availableSubtitles =
|
||||
// mediaSource?.MediaStreams?.filter((s) => s.Type === 'Subtitle').map((s) => ({
|
||||
// kind: 'subtitles' as const,
|
||||
// srclang: s.Language || '',
|
||||
// url: `${$user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${s.Index}/${s.Level}/Stream.vtt`,
|
||||
// language: 'English'
|
||||
// })) || [];
|
||||
|
||||
// const selectSubtitles = (subtitles?: Subtitles) => {
|
||||
// mediaLanguagesStore.update((prev) => ({
|
||||
// ...prev,
|
||||
// subtitles: subtitles?.srclang || ''
|
||||
// }));
|
||||
|
||||
// if (subtitleInfo) {
|
||||
// if (subtitles)
|
||||
// subtitleInfo = {
|
||||
// ...subtitleInfo,
|
||||
// subtitles
|
||||
// };
|
||||
// else
|
||||
// subtitleInfo = {
|
||||
// ...subtitleInfo,
|
||||
// subtitles: undefined
|
||||
// };
|
||||
// }
|
||||
// };
|
||||
|
||||
// subtitleInfo = {
|
||||
// subtitles,
|
||||
// availableSubtitles,
|
||||
// selectSubtitles
|
||||
// };
|
||||
|
||||
// playbackInfo = {
|
||||
// audioStreamIndex: audioStreamIndex ?? mediaSource?.DefaultAudioStreamIndex ?? -1,
|
||||
// audioTracks:
|
||||
// mediaSource?.MediaStreams?.filter((s) => s.Type === 'Audio').map((s) => ({
|
||||
// index: s.Index || -1,
|
||||
// language: s.Language || ''
|
||||
// })) || [],
|
||||
// selectAudioTrack: (index: number) =>
|
||||
// loadPlaybackInfo({
|
||||
// ...options,
|
||||
// audioStreamIndex: index,
|
||||
// playbackPosition: progressTime * 10_000_000
|
||||
// }),
|
||||
// directPlay,
|
||||
// playbackUrl: $user?.settings.jellyfin.baseUrl + playbackUri,
|
||||
// backdrop: item?.BackdropImageTags?.length
|
||||
// ? `${$user?.settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
|
||||
// : '',
|
||||
// startTime:
|
||||
// (options.playbackPosition || 0) / 10_000_000 ||
|
||||
// (item?.UserData?.PlaybackPositionTicks || 0) / 10_000_000 ||
|
||||
// undefined
|
||||
// };
|
||||
|
||||
// // if (mediaSourceId) reportPlaybackStarted(id, sessionId, mediaSourceId);
|
||||
|
||||
// if (reportProgressInterval) clearInterval(reportProgressInterval);
|
||||
// // reportProgressInterval = setInterval(() => {
|
||||
// // if (video?.readyState === 4 && progressTime > 0 && sessionId && id)
|
||||
// // reportProgress(id, sessionId, paused, progressTime);
|
||||
// // }, 10_000);
|
||||
// }
|
||||
|
||||
// loadPlaybackInfo();
|
||||
|
||||
onDestroy(() => {
|
||||
if (reportProgressInterval) clearInterval(reportProgressInterval);
|
||||
// if (id && sessionId && progressTime) reportPlaybackStopped(id, sessionId, progressTime);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal class="bg-black">
|
||||
<VideoPlayer
|
||||
{playbackInfo}
|
||||
modalHidden={$modalStackTop?.id !== modalId}
|
||||
{title}
|
||||
{subtitle}
|
||||
bind:paused
|
||||
bind:progressTime
|
||||
bind:video
|
||||
bind:subtitleInfo
|
||||
/>
|
||||
</Modal>
|
||||
Reference in New Issue
Block a user