mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-24 17:55:11 +02:00
feat: Subtitles and switching audio tracks
This commit is contained in:
17
src/lib/components/VideoPlayer/IconButton.svelte
Normal file
17
src/lib/components/VideoPlayer/IconButton.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import Container from '../../../Container.svelte';
|
||||
import classNames from 'classnames';
|
||||
</script>
|
||||
|
||||
<Container on:clickOrSelect let:hasFocus class="cursor-pointer group">
|
||||
<div
|
||||
class={classNames(
|
||||
'group-hover:bg-primary-500 group-hover:text-secondary-800 rounded-full p-3',
|
||||
{
|
||||
'bg-primary-500 text-secondary-800': hasFocus
|
||||
}
|
||||
)}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Container>
|
||||
@@ -2,13 +2,13 @@
|
||||
import classNames from 'classnames';
|
||||
import Container from '../../../Container.svelte';
|
||||
import VideoPlayer from './VideoPlayer.svelte';
|
||||
import type { PlaybackInfo } from './VideoPlayer';
|
||||
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 { appState } from '../../stores/app-state.store';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
import { modalStack, modalStackTop } from '../Modal/modal.store';
|
||||
|
||||
export let id: string;
|
||||
export let modalId: symbol;
|
||||
@@ -19,6 +19,7 @@
|
||||
let progressTime: number;
|
||||
|
||||
let playbackInfo: PlaybackInfo | undefined;
|
||||
let subtitleInfo: SubtitleInfo | undefined;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
let reportProgressInterval: ReturnType<typeof setInterval>;
|
||||
@@ -36,14 +37,18 @@
|
||||
deleteEncoding(sessionId);
|
||||
};
|
||||
|
||||
async function loadPlaybackInfo(id: string, bitrate?: number) {
|
||||
async function loadPlaybackInfo(
|
||||
id: string,
|
||||
options: { audioStreamIndex?: number; bitrate?: number; playbackPosition?: number } = {}
|
||||
) {
|
||||
const itemP = jellyfinApi.getLibraryItem(id);
|
||||
const jellyfinPlaybackInfoP = itemP.then((item) =>
|
||||
jellyfinApi.getPlaybackInfo(
|
||||
id,
|
||||
getDeviceProfile(),
|
||||
item?.UserData?.PlaybackPositionTicks || 0,
|
||||
bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate
|
||||
options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0,
|
||||
options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate,
|
||||
options.audioStreamIndex || undefined
|
||||
)
|
||||
);
|
||||
const item = await itemP;
|
||||
@@ -63,15 +68,55 @@
|
||||
|
||||
sessionId = playSessionId;
|
||||
|
||||
const mediaSource = jellyfinPlaybackInfo.MediaSources?.[0];
|
||||
|
||||
let subtitles: Subtitles | undefined;
|
||||
for (const stream of mediaSource?.MediaStreams || []) {
|
||||
if (stream.Type === 'Subtitle' && stream.IsDefault) {
|
||||
subtitles = {
|
||||
kind: 'subtitles',
|
||||
srclang: stream.Language || '',
|
||||
url: `${$appState.user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${stream.Index}/${stream.Level}/Stream.vtt`,
|
||||
language: 'English'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const availableSubtitles =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === 'Subtitle').map((s) => ({
|
||||
kind: 'subtitles' as const,
|
||||
srclang: s.Language || '',
|
||||
url: `${$appState.user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${s.Index}/${s.Level}/Stream.vtt`,
|
||||
language: 'English'
|
||||
})) || [];
|
||||
|
||||
subtitleInfo = {
|
||||
subtitles,
|
||||
availableSubtitles
|
||||
};
|
||||
|
||||
playbackInfo = {
|
||||
audioStreamIndex: options.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(id, {
|
||||
...options,
|
||||
audioStreamIndex: index,
|
||||
playbackPosition: progressTime * 10_000_000
|
||||
}),
|
||||
directPlay,
|
||||
playbackUrl: $appState.user?.settings.jellyfin.baseUrl + playbackUri,
|
||||
backdrop: item?.BackdropImageTags?.length
|
||||
? `${$appState.user?.settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
|
||||
: '',
|
||||
startTime: item?.UserData?.PlaybackPositionTicks
|
||||
? item?.UserData?.PlaybackPositionTicks / 10_000_000
|
||||
: undefined
|
||||
startTime:
|
||||
(options.playbackPosition || 0) / 10_000_000 ||
|
||||
(item?.UserData?.PlaybackPositionTicks || 0) / 10_000_000 ||
|
||||
undefined
|
||||
};
|
||||
|
||||
if (mediaSourceId) reportPlaybackStarted(id, sessionId, mediaSourceId);
|
||||
@@ -98,5 +143,12 @@
|
||||
'opacity-0': hidden
|
||||
})}
|
||||
>
|
||||
<VideoPlayer {playbackInfo} bind:paused bind:progressTime bind:video />
|
||||
<VideoPlayer
|
||||
{playbackInfo}
|
||||
modalHidden={$modalStackTop?.id !== modalId}
|
||||
bind:paused
|
||||
bind:progressTime
|
||||
bind:video
|
||||
bind:subtitleInfo
|
||||
/>
|
||||
</Container>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
<!-- Primary progress -->
|
||||
<div
|
||||
class="absolute inset-y-1 inset-x-2 rounded-full bg-white transition-transform"
|
||||
class="absolute inset-y-1 inset-x-2 rounded-full bg-secondary-100 transition-transform"
|
||||
style={`left: 0.5rem; right: calc(${(1 - progressTime / totalTime) * 100}% - 0.5rem + ${
|
||||
progressTime / totalTime
|
||||
}rem);`}
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
<div
|
||||
class={classNames(
|
||||
'absolute inset-y-0 w-4 h-4 bg-white rounded-full drop-shadow-2xl transition-opacity',
|
||||
'absolute inset-y-0 w-4 h-4 bg-primary-500 rounded-full drop-shadow-2xl transition-opacity',
|
||||
{
|
||||
'opacity-0 group-hover:opacity-100': !hasFocus
|
||||
}
|
||||
|
||||
47
src/lib/components/VideoPlayer/SelectAudioModal.svelte
Normal file
47
src/lib/components/VideoPlayer/SelectAudioModal.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { AudioTrack, SubtitleInfo, Subtitles } from './VideoPlayer';
|
||||
import Button from '../Button.svelte';
|
||||
import { modalStack } from '../Modal/modal.store.js';
|
||||
import { scrollIntoView } from '../../selectable';
|
||||
import { ChatBubble, Check, TextAlignLeft } from 'radix-icons-svelte';
|
||||
import Dialog from '../Dialog/Dialog.svelte';
|
||||
import { ISO_2_LANGUAGES } from '../../utils/iso-2-languages';
|
||||
|
||||
export let modalId: symbol;
|
||||
|
||||
export let selectedAudioStreamIndex: number;
|
||||
export let audioTracks: AudioTrack[];
|
||||
export let selectAudioStream: (index: number) => void;
|
||||
export let onClose = () => {};
|
||||
</script>
|
||||
|
||||
<Dialog {modalId}>
|
||||
<div>
|
||||
<h1 class="header2 mb-4 flex items-center space-x-4">
|
||||
<span>Audio</span>
|
||||
<ChatBubble size={32} />
|
||||
</h1>
|
||||
<div class="flex flex-col space-y-4">
|
||||
{#each audioTracks || [] as track}
|
||||
<Button
|
||||
on:clickOrSelect={() => {
|
||||
modalStack.close(modalId);
|
||||
onClose();
|
||||
if (track.index !== selectedAudioStreamIndex) selectAudioStream(track.index);
|
||||
}}
|
||||
on:enter={scrollIntoView({ horizontal: 64 })}
|
||||
class="relative"
|
||||
>
|
||||
{#if track.index === selectedAudioStreamIndex}
|
||||
<div class="absolute inset-y-0 right-6 flex items-center justify-center">
|
||||
<Check size={24} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-left">
|
||||
{ISO_2_LANGUAGES[track.language]?.name || track.language}
|
||||
</div>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
56
src/lib/components/VideoPlayer/SelectSubtitlesModal.svelte
Normal file
56
src/lib/components/VideoPlayer/SelectSubtitlesModal.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import type { SubtitleInfo, Subtitles } from './VideoPlayer';
|
||||
import Button from '../Button.svelte';
|
||||
import { modalStack } from '../Modal/modal.store.js';
|
||||
import { scrollIntoView } from '../../selectable';
|
||||
import { Check, TextAlignLeft } from 'radix-icons-svelte';
|
||||
import Dialog from '../Dialog/Dialog.svelte';
|
||||
import { ISO_2_LANGUAGES } from '../../utils/iso-2-languages';
|
||||
|
||||
export let modalId: symbol;
|
||||
|
||||
export let subtitleInfo: SubtitleInfo | undefined;
|
||||
export let selectSubtitles: (subtitles?: Subtitles) => void;
|
||||
</script>
|
||||
|
||||
<Dialog {modalId}>
|
||||
<h1 class="header2 mb-4 flex items-center space-x-4">
|
||||
<span>Subtitles</span>
|
||||
<TextAlignLeft size={32} />
|
||||
</h1>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<Button
|
||||
on:clickOrSelect={() => {
|
||||
modalStack.close(modalId);
|
||||
selectSubtitles(undefined);
|
||||
}}
|
||||
class="relative"
|
||||
>
|
||||
{#if !subtitleInfo?.subtitles}
|
||||
<div class="absolute inset-y-0 right-6 flex items-center justify-center">
|
||||
<Check size={24} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-left">No Subtitles</div>
|
||||
</Button>
|
||||
{#each subtitleInfo?.availableSubtitles || [] as subtitles}
|
||||
<Button
|
||||
on:clickOrSelect={() => {
|
||||
modalStack.close(modalId);
|
||||
selectSubtitles(subtitles);
|
||||
}}
|
||||
on:enter={scrollIntoView({ horizontal: 64 })}
|
||||
class="relative"
|
||||
>
|
||||
{#if subtitleInfo?.subtitles?.url === subtitles.url}
|
||||
<div class="absolute inset-y-0 right-6 flex items-center justify-center">
|
||||
<Check size={24} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-left">
|
||||
{ISO_2_LANGUAGES[subtitles.srclang]?.name || subtitles.srclang}
|
||||
</div>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</Dialog>
|
||||
7
src/lib/components/VideoPlayer/Track.svelte
Normal file
7
src/lib/components/VideoPlayer/Track.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
export let src: string;
|
||||
export let kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata';
|
||||
export let srclang: string;
|
||||
</script>
|
||||
|
||||
<track {src} {kind} {srclang} />
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import Hls from 'hls.js';
|
||||
import { isTizen } from '../../utils/browser-detection';
|
||||
import type { PlaybackInfo } from './VideoPlayer';
|
||||
import type { PlaybackInfo, SubtitleInfo } from './VideoPlayer';
|
||||
|
||||
export let playbackInfo: PlaybackInfo | undefined;
|
||||
export let subtitleInfo: SubtitleInfo | undefined;
|
||||
|
||||
export let paused = false;
|
||||
export let seeking = false;
|
||||
@@ -19,6 +20,8 @@
|
||||
$: playbackInfo && loadPlaybackInfo(playbackInfo);
|
||||
|
||||
function loadPlaybackInfo(playbackInfo: PlaybackInfo) {
|
||||
videoDidLoad = false;
|
||||
|
||||
const { playbackUrl, directPlay, backdrop, startTime } = playbackInfo;
|
||||
|
||||
if (backdrop) {
|
||||
@@ -70,6 +73,11 @@
|
||||
video.pause();
|
||||
}
|
||||
}
|
||||
|
||||
$: if (subtitleInfo?.subtitles) {
|
||||
console.log('Unpausing because subtitles were set');
|
||||
video.play();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
@@ -84,10 +92,22 @@
|
||||
on:loadeddata={() => {
|
||||
video.currentTime = progressTime;
|
||||
videoDidLoad = true;
|
||||
console.log('Video loaded');
|
||||
}}
|
||||
on:dblclick
|
||||
on:click={togglePlay}
|
||||
autoplay
|
||||
playsinline
|
||||
crossorigin="anonymous"
|
||||
class="w-full h-full"
|
||||
/>
|
||||
>
|
||||
{#if subtitleInfo?.subtitles}
|
||||
<track
|
||||
src={subtitleInfo.subtitles.url}
|
||||
kind={subtitleInfo.subtitles.kind}
|
||||
srclang={subtitleInfo.subtitles.srclang}
|
||||
default={true}
|
||||
label={subtitleInfo.subtitles.language}
|
||||
/>
|
||||
{/if}
|
||||
</video>
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
<script lang="ts">
|
||||
import Container from '../../../Container.svelte';
|
||||
import VideoElement from './VideoElement.svelte';
|
||||
import type { PlaybackInfo } from './VideoPlayer';
|
||||
import type { PlaybackInfo, SubtitleInfo, Subtitles } from './VideoPlayer';
|
||||
import classNames from 'classnames';
|
||||
import ProgressBar from './ProgressBar.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Selectable } from '../../selectable';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
import SelectSubtitlesModal from './SelectSubtitlesModal.svelte';
|
||||
import { ChatBubble, TextAlignLeft } from 'radix-icons-svelte';
|
||||
import IconButton from './IconButton.svelte';
|
||||
import SelectAudioModal from './SelectAudioModal.svelte';
|
||||
|
||||
export let playbackInfo: PlaybackInfo | undefined;
|
||||
export let subtitleInfo: SubtitleInfo | undefined;
|
||||
|
||||
export let modalHidden = false;
|
||||
|
||||
// Bindings
|
||||
export let paused = false;
|
||||
export let seeking = false;
|
||||
export let totalTime = 0;
|
||||
@@ -25,13 +34,16 @@
|
||||
let hideInterfaceTimeout: ReturnType<typeof setTimeout>;
|
||||
let container: Selectable;
|
||||
|
||||
$: if (modalHidden) video?.pause();
|
||||
else video?.play();
|
||||
$: if (!seeking && !modalHidden) handleShowInterface();
|
||||
|
||||
function handleShowInterface() {
|
||||
showInterface = true;
|
||||
clearTimeout(showInterfaceTimeout);
|
||||
showInterfaceTimeout = setTimeout(() => {
|
||||
if (seeking) handleShowInterface();
|
||||
else handleHideInterface();
|
||||
}, 5000);
|
||||
if (!seeking && !modalHidden) handleHideInterface();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function handleHideInterface() {
|
||||
@@ -42,6 +54,28 @@
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function selectSubtitles(subtitles?: Subtitles) {
|
||||
if (subtitleInfo) {
|
||||
if (subtitles)
|
||||
subtitleInfo = {
|
||||
...subtitleInfo,
|
||||
subtitles
|
||||
};
|
||||
else
|
||||
subtitleInfo = {
|
||||
...subtitleInfo,
|
||||
subtitles: undefined
|
||||
};
|
||||
} else {
|
||||
console.error('No subtitle info when selecting subtitles');
|
||||
}
|
||||
}
|
||||
|
||||
function selectAudioStream(index: number) {
|
||||
if (playbackInfo) playbackInfo.selectAudioTrack(index);
|
||||
else console.error('No playback info when selecting audio stream');
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(showInterfaceTimeout);
|
||||
clearTimeout(hideInterfaceTimeout);
|
||||
@@ -61,6 +95,7 @@
|
||||
>
|
||||
<VideoElement
|
||||
bind:playbackInfo
|
||||
bind:subtitleInfo
|
||||
bind:paused
|
||||
bind:seeking
|
||||
bind:totalTime
|
||||
@@ -93,8 +128,35 @@
|
||||
handleHideInterface();
|
||||
}
|
||||
}}
|
||||
class="flex justify-between p-2"
|
||||
>
|
||||
Buttons
|
||||
<div />
|
||||
<div class="flex space-x-2">
|
||||
<IconButton
|
||||
on:clickOrSelect={() => {
|
||||
// video.pause();
|
||||
modalStack.create(SelectSubtitlesModal, {
|
||||
subtitleInfo,
|
||||
selectSubtitles
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TextAlignLeft size={19} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
on:clickOrSelect={() => {
|
||||
// video.pause();
|
||||
modalStack.create(SelectAudioModal, {
|
||||
selectedAudioStreamIndex: playbackInfo?.audioStreamIndex || -1,
|
||||
audioTracks: playbackInfo?.audioTracks || [],
|
||||
selectAudioStream
|
||||
// onClose: () => video.play()
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChatBubble size={19} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Container>
|
||||
<ProgressBar
|
||||
bind:seeking
|
||||
|
||||
@@ -3,11 +3,32 @@ import { modalStack } from '../Modal/modal.store';
|
||||
import { jellyfinItemsStore } from '../../stores/data.store';
|
||||
import VideoPlayerModal from './JellyfinVideoPlayerModal.svelte';
|
||||
|
||||
export type SubtitleInfo = {
|
||||
subtitles?: Subtitles;
|
||||
availableSubtitles: Subtitles[];
|
||||
};
|
||||
|
||||
export type Subtitles = {
|
||||
url: string;
|
||||
srclang: string;
|
||||
kind: 'subtitles' | 'captions' | 'descriptions';
|
||||
language: string;
|
||||
};
|
||||
|
||||
export type AudioTrack = {
|
||||
language: string;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type PlaybackInfo = {
|
||||
playbackUrl: string;
|
||||
directPlay: boolean;
|
||||
backdrop?: string;
|
||||
startTime?: number;
|
||||
|
||||
audioStreamIndex: number;
|
||||
audioTracks: AudioTrack[];
|
||||
selectAudioTrack: (index: number) => void;
|
||||
};
|
||||
|
||||
const initialValue = { visible: false, jellyfinId: '' };
|
||||
|
||||
Reference in New Issue
Block a user