feat: Subtitles and switching audio tracks

This commit is contained in:
Aleksi Lassila
2024-05-08 03:01:27 +03:00
parent 7bae4273d7
commit fe2dc56001
17 changed files with 2495 additions and 69 deletions

View 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>

View File

@@ -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>

View File

@@ -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
}

View 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>

View 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>

View 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} />

View File

@@ -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>

View File

@@ -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

View File

@@ -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: '' };