mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-20 15:55:14 +02:00
feat: add clock, source and titles to videoplayer
This commit is contained in:
@@ -5,17 +5,26 @@
|
||||
import type { NavigateEvent } from '../../selectable';
|
||||
import { formatMinutesToTime, formatSecondsToTime } from '../../utils';
|
||||
|
||||
export let totalTime: number;
|
||||
export let progressTime: number;
|
||||
export let duration: number;
|
||||
export let currentTime: number;
|
||||
export let bufferedTime: number;
|
||||
export let step = 0.1;
|
||||
export let paused = false;
|
||||
|
||||
export let seeking = false;
|
||||
|
||||
let displayTime = currentTime;
|
||||
|
||||
const updateDisplayTime = (time: number) => {
|
||||
if (!seeking) {
|
||||
displayTime = time;
|
||||
}
|
||||
};
|
||||
$: updateDisplayTime(currentTime);
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
jumpTo: number;
|
||||
playPause: void;
|
||||
play: void;
|
||||
pause: void;
|
||||
}>();
|
||||
|
||||
let pausedBeforeSeeking = paused;
|
||||
@@ -23,25 +32,28 @@
|
||||
seeking = true;
|
||||
|
||||
pausedBeforeSeeking = paused;
|
||||
paused = true;
|
||||
dispatch('pause');
|
||||
}
|
||||
|
||||
function handleStopSeeking() {
|
||||
if (!seeking) return;
|
||||
|
||||
paused = pausedBeforeSeeking;
|
||||
seeking = false;
|
||||
dispatch('jumpTo', displayTime);
|
||||
|
||||
dispatch('jumpTo', progressTime);
|
||||
if (!pausedBeforeSeeking) {
|
||||
dispatch('play');
|
||||
} else {
|
||||
dispatch('pause');
|
||||
}
|
||||
seeking = false;
|
||||
}
|
||||
|
||||
let pauseTime = 0;
|
||||
let pauseTime: number | undefined = undefined;
|
||||
const handlePause = (paused: boolean) => {
|
||||
if (paused) {
|
||||
pauseTime = progressTime;
|
||||
} else if (pauseTime > 0) {
|
||||
if (pauseTime !== undefined) {
|
||||
dispatch('jumpTo', pauseTime);
|
||||
pauseTime = 0;
|
||||
displayTime = pauseTime;
|
||||
pauseTime = undefined;
|
||||
}
|
||||
};
|
||||
$: handlePause(paused);
|
||||
@@ -49,16 +61,16 @@
|
||||
function handleNavigateEvent({ detail }: CustomEvent<NavigateEvent>) {
|
||||
if (detail.direction === 'left') {
|
||||
if (paused) {
|
||||
pauseTime = pauseTime - 10;
|
||||
pauseTime = Math.max(0, (pauseTime ?? currentTime) - 10);
|
||||
} else {
|
||||
dispatch('jumpTo', progressTime - 10);
|
||||
dispatch('jumpTo', displayTime - 10);
|
||||
detail.preventNavigation();
|
||||
}
|
||||
} else if (detail.direction === 'right') {
|
||||
if (paused) {
|
||||
pauseTime = pauseTime + 30;
|
||||
pauseTime = Math.min(duration, (pauseTime ?? currentTime) + 30);
|
||||
} else {
|
||||
dispatch('jumpTo', progressTime + 30);
|
||||
dispatch('jumpTo', displayTime + 30);
|
||||
detail.preventNavigation();
|
||||
}
|
||||
}
|
||||
@@ -71,15 +83,15 @@
|
||||
let:hasFocus
|
||||
focusOnMount
|
||||
on:navigate={handleNavigateEvent}
|
||||
on:select={() => dispatch('playPause')}
|
||||
on:select={() => dispatch(paused ? 'play' : 'pause')}
|
||||
>
|
||||
<div class="absolute inset-y-1 inset-x-2 rounded-full bg-zinc-300/50" />
|
||||
|
||||
<!-- Secondary progress -->
|
||||
<div
|
||||
class="absolute inset-y-1 inset-x-2 rounded-full bg-zinc-300/50 transition-transform"
|
||||
style={`left: 0.5rem; right: calc(${(1 - bufferedTime / totalTime) * 100}% - 0.5rem + ${
|
||||
bufferedTime / totalTime
|
||||
style={`left: 0.5rem; right: calc(${(1 - bufferedTime / duration) * 100}% - 0.5rem + ${
|
||||
bufferedTime / duration
|
||||
}rem);`}
|
||||
/>
|
||||
|
||||
@@ -87,8 +99,8 @@
|
||||
<div
|
||||
class="absolute inset-y-1 inset-x-2 rounded-full bg-secondary-100 transition-transform"
|
||||
style={`left: 0.5rem; right: calc(${
|
||||
(1 - (pauseTime > 0 ? pauseTime : progressTime) / totalTime) * 100
|
||||
}% - 0.5rem + ${(pauseTime > 0 ? pauseTime : progressTime) / totalTime}rem);`}
|
||||
(1 - (pauseTime ?? displayTime) / duration) * 100
|
||||
}% - 0.5rem + ${(pauseTime ?? displayTime) / duration}rem);`}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -98,8 +110,8 @@
|
||||
'opacity-0 group-hover:opacity-100': !hasFocus
|
||||
}
|
||||
)}
|
||||
style={`left: calc(${((pauseTime > 0 ? pauseTime : progressTime) / totalTime) * 100}% - ${
|
||||
(pauseTime > 0 ? pauseTime : progressTime) / totalTime
|
||||
style={`left: calc(${((pauseTime ?? displayTime) / duration) * 100}% - ${
|
||||
(pauseTime ?? displayTime) / duration
|
||||
}rem);
|
||||
box-shadow: 0 0 0.25rem 2px #00000033;
|
||||
`}
|
||||
@@ -109,9 +121,9 @@
|
||||
type="range"
|
||||
class="w-full absolute cursor-pointer h-4 inset-y-0 opacity-0"
|
||||
min={0}
|
||||
max={totalTime}
|
||||
max={duration}
|
||||
{step}
|
||||
bind:value={progressTime}
|
||||
bind:value={displayTime}
|
||||
on:mousedown={handleStartSeeking}
|
||||
on:mouseup={handleStopSeeking}
|
||||
on:touchstart={handleStartSeeking}
|
||||
@@ -119,7 +131,7 @@
|
||||
/>
|
||||
</Container>
|
||||
<div class="flex justify-between px-2 pt-4 text-lg">
|
||||
<span>{formatSecondsToTime(pauseTime || progressTime)}</span>
|
||||
<span>-{formatSecondsToTime(totalTime - (pauseTime || progressTime))}</span>
|
||||
<span>{formatSecondsToTime(pauseTime || displayTime)}</span>
|
||||
<span>-{formatSecondsToTime(duration - (pauseTime || displayTime))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { get } from 'svelte/store';
|
||||
import { sessions } from '../../stores/session.store';
|
||||
import { reiverrApiNew, user } from '../../stores/user.store';
|
||||
import type { PlaybackInfo, SubtitleInfo, Subtitles, VideoPlayerContext } from './VideoPlayer';
|
||||
import VideoPlayerModal from './VideoPlayerModal.svelte';
|
||||
import { getQualities } from '../../apis/jellyfin/qualities';
|
||||
import getDeviceProfile from '../../apis/jellyfin/playback-profiles';
|
||||
import { tmdbApi } from '../../apis/tmdb/tmdb-api';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { createLocalStorageStore } from '../../stores/localstorage.store';
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import VideoPlayer from './VideoPlayer.svelte';
|
||||
import { modalStackTop } from '../Modal/modal.store';
|
||||
import type { StreamDto } from '$lib/apis/reiverr/reiverr.openapi';
|
||||
import {
|
||||
episodeUserDataStore,
|
||||
movieUserDataStore,
|
||||
seriesUserDataStore
|
||||
seriesUserDataStore,
|
||||
tmdbMovieDataStore,
|
||||
tmdbSeriesDataStore
|
||||
} from '$lib/stores/data.store';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import getDeviceProfile from '../../apis/jellyfin/playback-profiles';
|
||||
import { createLocalStorageStore } from '../../stores/localstorage.store';
|
||||
import { sessions } from '../../stores/session.store';
|
||||
import { reiverrApiNew, user } from '../../stores/user.store';
|
||||
import { modalStackTop } from '../Modal/modal.store';
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import type { PlaybackInfo, SubtitleInfo, Subtitles, VideoPlayerContext } from './VideoPlayer';
|
||||
import VideoPlayer from './VideoPlayer.svelte';
|
||||
|
||||
export let modalId: symbol;
|
||||
|
||||
export let tmdbId: string;
|
||||
export let season: number | undefined = undefined;
|
||||
@@ -26,15 +27,22 @@
|
||||
export let key: string = '';
|
||||
export let progress: number = 0;
|
||||
|
||||
export let modalId: symbol;
|
||||
export let hidden: boolean = false;
|
||||
|
||||
let title: string = '';
|
||||
let subtitle: string = '';
|
||||
|
||||
let sourceUri = '';
|
||||
const { unsubscribe, ...request } = (
|
||||
season !== undefined && episode !== undefined ? tmdbSeriesDataStore : tmdbMovieDataStore
|
||||
).getRequest(Number(tmdbId));
|
||||
|
||||
let playerContext: VideoPlayerContext | undefined;
|
||||
request.subscribe((item) => {
|
||||
if (!item) return;
|
||||
if ('title' in item) {
|
||||
title = item.title ?? title;
|
||||
} else if ('name' in item) {
|
||||
title = `Episode ${episode}`;
|
||||
subtitle = item.name ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
type MediaLanguageStore = {
|
||||
subtitles?: string;
|
||||
@@ -43,8 +51,6 @@
|
||||
|
||||
let video: HTMLVideoElement;
|
||||
let paused: boolean;
|
||||
let progressTime: number;
|
||||
let videoDuration: number;
|
||||
|
||||
let playbackInfo: PlaybackInfo | undefined;
|
||||
let subtitleInfo: SubtitleInfo | undefined;
|
||||
@@ -170,7 +176,9 @@
|
||||
// (item?.UserData?.PlaybackPositionTicks || 0) / 10_000_000 ||
|
||||
// undefined
|
||||
};
|
||||
videoDuration = stream.duration;
|
||||
|
||||
// title = stream.title;
|
||||
// subtitle = stream.subtitle;
|
||||
|
||||
// if (mediaSourceId) reportPlaybackStarted(id, sessionId, mediaSourceId);
|
||||
|
||||
@@ -183,7 +191,9 @@
|
||||
);
|
||||
};
|
||||
|
||||
onMount(() => refreshVideoStream());
|
||||
onMount(() => {
|
||||
refreshVideoStream();
|
||||
});
|
||||
/*
|
||||
title
|
||||
subtitle
|
||||
@@ -232,8 +242,8 @@
|
||||
modalHidden={$modalStackTop?.id !== modalId}
|
||||
{title}
|
||||
{subtitle}
|
||||
source={`${sourceId}`}
|
||||
bind:paused
|
||||
bind:progressTime
|
||||
bind:video
|
||||
bind:subtitleInfo
|
||||
/>
|
||||
|
||||
@@ -10,17 +10,16 @@
|
||||
export let playbackInfo: PlaybackInfo | undefined;
|
||||
export let subtitleInfo: SubtitleInfo | undefined;
|
||||
|
||||
export let video: HTMLVideoElement;
|
||||
|
||||
export let videoDidLoad = false;
|
||||
export let paused = false;
|
||||
export let seeking = false;
|
||||
export let totalTime = 0;
|
||||
export let progressTime = 0;
|
||||
export let duration = 0;
|
||||
export let currentTime = 0;
|
||||
export let bufferedTime = 0;
|
||||
export let buffering = false;
|
||||
export let muted = false;
|
||||
export let volume = 1;
|
||||
export let videoDidLoad = false;
|
||||
export let buffering = false;
|
||||
|
||||
export let video: HTMLVideoElement;
|
||||
|
||||
// let hls: Hls | undefined;
|
||||
|
||||
@@ -102,12 +101,10 @@
|
||||
<video
|
||||
bind:this={video}
|
||||
bind:paused
|
||||
bind:duration={totalTime}
|
||||
bind:duration
|
||||
bind:volume
|
||||
bind:muted
|
||||
on:timeupdate={() => {
|
||||
progressTime = !seeking && videoDidLoad ? video.currentTime : progressTime;
|
||||
}}
|
||||
bind:currentTime
|
||||
on:progress={handleProgress}
|
||||
on:loadeddata={() => {
|
||||
// console.log('video loaded', video.currentTime, video.duration, playbackInfo?.progress);
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
<script lang="ts">
|
||||
import Container from '../Container.svelte';
|
||||
import VideoElement from './VideoElement.svelte';
|
||||
import type { PlaybackInfo, SubtitleInfo, Subtitles } from './VideoPlayer';
|
||||
import classNames from 'classnames';
|
||||
import ProgressBar from './ProgressBar.svelte';
|
||||
import { ChatBubble, Pause, TextAlignLeft } from 'radix-icons-svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Selectable } from '../../selectable';
|
||||
import Container from '../Container.svelte';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
import SelectSubtitlesModal from './SelectSubtitlesModal.svelte';
|
||||
import { ChatBubble, Pause, TextAlignLeft } from 'radix-icons-svelte';
|
||||
import IconButton from './IconButton.svelte';
|
||||
import SelectAudioModal from './SelectAudioModal.svelte';
|
||||
import Spinner from '../Utils/Spinner.svelte';
|
||||
import { createInfoNotification } from '../Notifications/notification.store';
|
||||
import IconButton from './IconButton.svelte';
|
||||
import ProgressBar from './ProgressBar.svelte';
|
||||
import SelectAudioModal from './SelectAudioModal.svelte';
|
||||
import SelectSubtitlesModal from './SelectSubtitlesModal.svelte';
|
||||
import VideoElement from './VideoElement.svelte';
|
||||
import type { PlaybackInfo, SubtitleInfo, Subtitles } from './VideoPlayer';
|
||||
|
||||
export let playbackInfo: PlaybackInfo | undefined;
|
||||
export let subtitleInfo: SubtitleInfo | undefined;
|
||||
export let title: string;
|
||||
export let subtitle: string;
|
||||
export let subtitle: string = ''
|
||||
export let source: string = '';
|
||||
|
||||
export let modalHidden = false;
|
||||
|
||||
// Bindings
|
||||
export let videoDidLoad = false;
|
||||
export let paused = false;
|
||||
export let seeking = false;
|
||||
export let totalTime = 0;
|
||||
export let progressTime = 0;
|
||||
export let duration = 0;
|
||||
export let currentTime = 0;
|
||||
export let bufferedTime = 0;
|
||||
let buffering = false;
|
||||
export let muted = false;
|
||||
export let volume = 1;
|
||||
export let videoDidLoad = false;
|
||||
let buffering = false;
|
||||
let seeking = false;
|
||||
|
||||
export let video: HTMLVideoElement;
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
let hideInterfaceTimeout: ReturnType<typeof setTimeout>;
|
||||
let container: Selectable;
|
||||
|
||||
let clockTime = 0;
|
||||
let clockInterval = setInterval(() => {
|
||||
clockTime = Date.now();
|
||||
}, 1000);
|
||||
|
||||
$: if (modalHidden) video?.pause();
|
||||
else video?.play();
|
||||
$: if (!seeking && !modalHidden) handleShowInterface();
|
||||
@@ -97,6 +102,7 @@
|
||||
onDestroy(() => {
|
||||
clearTimeout(showInterfaceTimeout);
|
||||
clearTimeout(hideInterfaceTimeout);
|
||||
clearInterval(clockInterval);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -110,14 +116,14 @@
|
||||
}
|
||||
handleShowInterface();
|
||||
}}
|
||||
on:click={() => (paused ? video?.play() : video?.pause())}
|
||||
>
|
||||
<VideoElement
|
||||
bind:playbackInfo
|
||||
bind:subtitleInfo
|
||||
bind:paused
|
||||
bind:seeking
|
||||
bind:totalTime
|
||||
bind:progressTime
|
||||
bind:duration
|
||||
bind:currentTime
|
||||
bind:bufferedTime
|
||||
bind:muted
|
||||
bind:volume
|
||||
@@ -156,69 +162,85 @@
|
||||
</div>
|
||||
{/if}
|
||||
<Container
|
||||
class={classNames('absolute inset-x-12 bottom-8 transition-opacity flex flex-col', {
|
||||
'opacity-0': !showInterface
|
||||
})}
|
||||
class={classNames(
|
||||
'absolute inset-x-12 inset-y-8 transition-opacity flex flex-col justify-between',
|
||||
{
|
||||
'opacity-0': !showInterface
|
||||
}
|
||||
)}
|
||||
bind:selectable={container}
|
||||
>
|
||||
<Container
|
||||
direction="horizontal"
|
||||
on:navigate={({ detail }) => {
|
||||
if (detail.direction === 'up') {
|
||||
detail.stopPropagation();
|
||||
detail.preventNavigation();
|
||||
handleHideInterface();
|
||||
}
|
||||
}}
|
||||
class="flex justify-between px-2 py-4 items-end"
|
||||
<div
|
||||
class="flex justify-between items-center text-secondary-300 font-medium text-wider text-xl tracking-wide"
|
||||
>
|
||||
<div>@{source}</div>
|
||||
|
||||
<div>
|
||||
<div class="text-secondary-300 font-medium text-wider text-xl mb-1 tracking-wide">
|
||||
{subtitle}
|
||||
Ends at {new Date(
|
||||
clockTime + ((duration ?? 0) - (currentTime ?? 0)) * 1000
|
||||
).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div on:click={(e) => e.stopPropagation()}>
|
||||
<Container
|
||||
direction="horizontal"
|
||||
on:navigate={({ detail }) => {
|
||||
if (detail.direction === 'up') {
|
||||
detail.stopPropagation();
|
||||
detail.preventNavigation();
|
||||
handleHideInterface();
|
||||
}
|
||||
}}
|
||||
class="flex justify-between px-2 py-4 items-end"
|
||||
>
|
||||
<div>
|
||||
<div class="text-secondary-300 font-medium text-wider text-xl mb-1 tracking-wide">
|
||||
{subtitle}
|
||||
</div>
|
||||
<h1 class="header4">{title}</h1>
|
||||
</div>
|
||||
<h1 class="header4">{title}</h1>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<IconButton
|
||||
on:clickOrSelect={() => {
|
||||
// video.pause();
|
||||
modalStack.create(SelectSubtitlesModal, {
|
||||
subtitleInfo,
|
||||
selectSubtitles
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TextAlignLeft size={24} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
on:clickOrSelect={() => {
|
||||
// video.pause();
|
||||
modalStack.create(SelectAudioModal, {
|
||||
selectedAudioStreamIndex: playbackInfo?.audioStreamIndex || -1,
|
||||
audioTracks: playbackInfo?.audioTracks || [],
|
||||
selectAudioStream
|
||||
// onClose: () => video.play()
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChatBubble size={24} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Container>
|
||||
<ProgressBar
|
||||
bind:seeking
|
||||
on:jumpTo={(e) => {
|
||||
video.currentTime = e.detail;
|
||||
}}
|
||||
on:playPause={() => {
|
||||
if (paused) video.play();
|
||||
else video.pause();
|
||||
}}
|
||||
bind:totalTime
|
||||
bind:progressTime
|
||||
bind:bufferedTime
|
||||
bind:paused
|
||||
/>
|
||||
<div class="flex space-x-2">
|
||||
<IconButton
|
||||
on:clickOrSelect={() => {
|
||||
// video.pause();
|
||||
modalStack.create(SelectSubtitlesModal, {
|
||||
subtitleInfo,
|
||||
selectSubtitles
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TextAlignLeft size={24} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
on:clickOrSelect={() => {
|
||||
// video.pause();
|
||||
modalStack.create(SelectAudioModal, {
|
||||
selectedAudioStreamIndex: playbackInfo?.audioStreamIndex || -1,
|
||||
audioTracks: playbackInfo?.audioTracks || [],
|
||||
selectAudioStream
|
||||
// onClose: () => video.play()
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChatBubble size={24} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Container>
|
||||
<ProgressBar
|
||||
bind:seeking
|
||||
on:jumpTo={(e) => {
|
||||
video.currentTime = e.detail;
|
||||
video.play();
|
||||
}}
|
||||
on:play={() => video.play()}
|
||||
on:pause={() => video.pause()}
|
||||
{duration}
|
||||
{currentTime}
|
||||
{bufferedTime}
|
||||
bind:paused
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user