diff --git a/ui/app/components/VideoPlayer.vue b/ui/app/components/VideoPlayer.vue index efb8d481..96b8df28 100644 --- a/ui/app/components/VideoPlayer.vue +++ b/ui/app/components/VideoPlayer.vue @@ -98,27 +98,13 @@ Your browser does not support the video tag. - - Looking for matching subtitles... - {{ subtitleLoadError }} - - - +
+ Looking for matching subtitles... + {{ subtitleLoadError }}
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; +import { useStorage } from '@vueuse/core'; import Hls from 'hls.js'; import { disableOpacity, @@ -359,7 +336,6 @@ import { request, uri, } from '~/utils'; -import { usePlayerMediaVolume } from '~/composables/usePlayerMediaVolume'; import { usePlayerShortcutHelp } from '~/composables/usePlayerShortcutHelp'; import { usePlayerShortcuts } from '~/composables/usePlayerShortcuts'; import { usePlayerSubtitles } from '~/composables/usePlayerSubtitles'; @@ -369,6 +345,9 @@ import { getFullscreenElement, requestElementFullscreen, } from '~/utils/fullscreen'; +import { clampMediaVolume } from '~/utils/keyboard'; +import { readResumeState, resumeMedia } from '~/utils/media'; +import { nextTapVisible } from '~/utils/playerControls'; import type { StoreItem } from '~/types/store'; import type { FileInfo, PlayerSourceElement } from '~/types/video'; @@ -382,14 +361,6 @@ const emitter = defineEmits<{ (e: 'playback-state-change', isPlaying: boolean): void; }>(); -const { - volume, - muted, - effectiveVolume, - setVolume, - changeVolume, - toggleMute: toggleStoredMute, -} = usePlayerMediaVolume(); const showShortcutHelp = usePlayerShortcutHelp(); const playerContainer = ref(null); @@ -415,6 +386,8 @@ const isAudio = ref(false); const hasVideo = ref(false); const usingHls = ref(false); const destroyed = ref(false); +const mediaVol = useStorage('player_volume', 1); +const muted = useStorage('player_muted', false); const showHelp = computed({ get: () => showShortcutHelp.value, set: (value: boolean) => { @@ -432,19 +405,15 @@ const helpPortal = computed(() => { let assLayoutRefreshFrame = 0; let controlsHideTimeout = 0; let pendingVideoClickTimeout = 0; -let gainContext: AudioContext | null = null; -let gainSource: MediaElementAudioSourceNode | null = null; -let gainNode: GainNode | null = null; -let gainElement: HTMLMediaElement | null = null; let unbindMediaSession: null | (() => void) = null; let hls: Hls | null = null; +let pendingResume: null | { time: number; shouldPlay: boolean } = null; const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent); const mediaFile = computed(() => props.item.filename || ''); const subtitleManifestUrl = computed(() => currentPlaybackUrl('api/player/subtitles/manifest')); const canPlay = computed(() => Boolean(mediaFile.value && !loadingError.value)); const shouldRender = computed(() => active.value && !loading.value); -const isPlaying = computed(() => !paused.value); const progress = computed(() => { if (!duration.value) return 0; return Math.round((currentTime.value / duration.value) * 1000); @@ -454,13 +423,6 @@ const timeLabel = computed(() => { const durationLabel = duration.value ? formatDuration(Math.round(duration.value)) : '--:--'; return `${currentLabel} / ${durationLabel}`; }); -const showSeekZones = computed(() => { - return Boolean(isTouchDevice.value && isPlaying.value); -}); -const useGainFallback = computed(() => { - return Boolean(isTouchDevice.value && getAudioContextCtor()); -}); - const { subtitleLoading, subtitleLoadError, @@ -481,15 +443,15 @@ const { useHead(() => (title.value ? { title: formatPageTitle(`Playing: ${title.value}`) } : {})); watch( - [volume, muted], - ([nextVolume]) => { - const normalizedVolume = normalizeMediaVolume(nextVolume); - if (normalizedVolume !== nextVolume) { - volume.value = normalizedVolume; + [mediaVol, muted], + ([nextVol]) => { + const normalizedVolume = clampMediaVolume(nextVol); + if (normalizedVolume !== nextVol) { + mediaVol.value = normalizedVolume; return; } - applyStoredMediaState(videoElement.value); + applyMediaState(videoElement.value); syncVideoState(); }, { immediate: true }, @@ -499,10 +461,10 @@ watch( videoElement, (element, previousElement) => { if (previousElement && previousElement !== element) { - disconnectGainController(); + previousElement.muted = true; } - applyStoredMediaState(element); + applyMediaState(element); syncVideoState(); }, { immediate: true }, @@ -526,21 +488,6 @@ function formatDuration(totalSeconds: number): string { return [minutes, seconds].map((value) => String(value).padStart(2, '0')).join(':'); } -function normalizeMediaVolume(volume: number): number { - if (!Number.isFinite(volume)) return 1; - return Math.min(1, Math.max(0, volume)); -} - -function getAudioContextCtor(): typeof AudioContext | null { - if (!import.meta.client) return null; - - const maybeWindow = window as Window & - typeof globalThis & { - webkitAudioContext?: typeof AudioContext; - }; - return maybeWindow.AudioContext || maybeWindow.webkitAudioContext || null; -} - function currentPlaybackUrl(base: string, playlist: boolean = false): string { if (!props.item.filename) { return ''; @@ -556,8 +503,7 @@ function currentPlaybackUrl(base: string, playlist: boolean = false): string { function activatePlayer() { active.value = true; void nextTick(async () => { - applyStoredMediaState(videoElement.value); - await resumeGainController(); + applyMediaState(videoElement.value); try { await videoElement.value?.play(); } catch {} @@ -578,6 +524,14 @@ function handleVideoLoadedMetadata() { if (videoElement.value) { updateMediaSessionPosition(videoElement.value); } + + if (pendingResume) { + void resumeMedia(videoElement.value, pendingResume).finally(() => { + pendingResume = null; + syncVideoState(); + showControls(); + }); + } } function handleVideoTimeUpdate() { @@ -589,7 +543,6 @@ function handleVideoTimeUpdate() { function handleVideoPlay() { loadingError.value = ''; - void resumeGainController(); syncVideoState(); showControls(); emitter('playback-state-change', true); @@ -604,13 +557,17 @@ function handleVideoPause() { function handleVideoClick() { if (isTouchDevice.value) { + toggleControls(); return; } clearPendingVideoClickTimeout(); pendingVideoClickTimeout = window.setTimeout(() => { pendingVideoClickTimeout = 0; - toggleControls(); + if (controlsVisible.value && !videoElement.value?.paused) { + clearControlsHideTimeout(); + controlsVisible.value = false; + } }, 180); } @@ -650,11 +607,9 @@ function handleMediaVolumeChange(event: Event) { muted.value = target.muted; } - if (!useGainFallback.value) { - const normalizedVolume = normalizeMediaVolume(target.volume); - if (Math.abs(volume.value - normalizedVolume) > 0.001) { - volume.value = normalizedVolume; - } + const normalizedVolume = clampMediaVolume(target.volume); + if (Math.abs(mediaVol.value - normalizedVolume) > 0.001) { + mediaVol.value = normalizedVolume; } syncVideoState(); @@ -691,11 +646,12 @@ function handleVolumeInput(event: Event) { const target = event.target as HTMLInputElement | null; if (!target || !videoElement.value) return; - setVolume(Number(target.value) / 100); - applyStoredMediaState(videoElement.value); + const nextVol = clampMediaVolume(Number(target.value) / 100); + mediaVol.value = nextVol; + muted.value = nextVol <= 0; + applyMediaState(videoElement.value); syncVideoState(); showControls(); - void resumeGainController(); } async function togglePlayback() { @@ -703,7 +659,6 @@ async function togglePlayback() { try { if (videoElement.value.paused) { - await resumeGainController(); await videoElement.value.play(); syncVideoState(); showControls(); @@ -716,46 +671,22 @@ async function togglePlayback() { } function toggleMute() { - toggleStoredMute(); - applyStoredMediaState(videoElement.value); - syncVideoState(); - showControls(); - void resumeGainController(); -} - -function seekBy(deltaSeconds: number) { - if (!videoElement.value) return; - - if (!isPlaying.value) { - showControls(); - return; + if (muted.value || mediaVol.value <= 0) { + mediaVol.value = mediaVol.value > 0 ? clampMediaVolume(mediaVol.value) : 1; + muted.value = false; + } else { + muted.value = true; } - const duration = Number.isFinite(videoElement.value.duration) ? videoElement.value.duration : 0; - const nextTime = Math.min( - Math.max(videoElement.value.currentTime + deltaSeconds, 0), - duration || Infinity, - ); - videoElement.value.currentTime = nextTime; + applyMediaState(videoElement.value); syncVideoState(); showControls(); } -function applyStoredMediaState(element: HTMLMediaElement | null) { +function applyMediaState(element: HTMLMediaElement | null) { if (!element) return; - if (useGainFallback.value) { - ensureGainController(element); - if (element.muted !== muted.value) { - element.muted = muted.value; - } - if (gainNode) { - gainNode.gain.value = effectiveVolume.value; - } - return; - } - - const normalizedVolume = normalizeMediaVolume(volume.value); + const normalizedVolume = clampMediaVolume(mediaVol.value); if (Math.abs(element.volume - normalizedVolume) > 0.001) { element.volume = normalizedVolume; } @@ -765,65 +696,6 @@ function applyStoredMediaState(element: HTMLMediaElement | null) { } } -function ensureGainController(element: HTMLMediaElement | null) { - if (!useGainFallback.value || !element) return; - - if (gainElement === element && gainNode) { - gainNode.gain.value = effectiveVolume.value; - return; - } - - disconnectGainController(); - - const AudioContextCtor = getAudioContextCtor(); - if (!AudioContextCtor) return; - - if (!gainContext || gainContext.state === 'closed') { - try { - gainContext = new AudioContextCtor(); - } catch { - gainContext = null; - return; - } - } - - try { - gainSource = gainContext.createMediaElementSource(element); - gainNode = gainContext.createGain(); - gainSource.connect(gainNode); - gainNode.connect(gainContext.destination); - gainNode.gain.value = effectiveVolume.value; - gainElement = element; - } catch { - disconnectGainController(); - } -} - -async function resumeGainController() { - if (!useGainFallback.value) return; - - ensureGainController(videoElement.value); - if (!gainContext || gainContext.state !== 'suspended') return; - - try { - await gainContext.resume(); - } catch {} -} - -function disconnectGainController() { - try { - gainSource?.disconnect(); - } catch {} - - try { - gainNode?.disconnect(); - } catch {} - - gainSource = null; - gainNode = null; - gainElement = null; -} - function syncVideoState() { const video = videoElement.value; if (!video) { @@ -873,12 +745,18 @@ function showControls() { } function toggleControls() { - if (!controlsVisible.value) { - showControls(); + const nextVisible = nextTapVisible({ + touch: isTouchDevice.value, + paused: Boolean(videoElement.value?.paused), + visible: controlsVisible.value, + }); + + if (nextVisible === controlsVisible.value) { return; } - if (videoElement.value?.paused) { + if (nextVisible) { + showControls(); return; } @@ -1216,7 +1094,7 @@ function prepareVideoPlayer() { return; } - applyStoredMediaState(videoElement.value); + applyMediaState(videoElement.value); restoreDefaultTextTrack(); if (hasVideo.value) { @@ -1249,11 +1127,13 @@ async function src_error(event: Event) { attach_hls(currentPlaybackUrl('m3u8', true)); } -function attach_hls(link: string) { +function attach_hls(link: string, resume = readResumeState(videoElement.value)) { if (!videoElement.value) { return; } + pendingResume = resume; + if (hls) { hls.destroy(); } @@ -1313,11 +1193,11 @@ usePlayerShortcuts({ media: videoElement, video: videoElement, adjustVolume: (delta) => { - changeVolume(delta); - applyStoredMediaState(videoElement.value); + mediaVol.value = clampMediaVolume(mediaVol.value + delta); + muted.value = mediaVol.value <= 0; + applyMediaState(videoElement.value); syncVideoState(); showControls(); - void resumeGainController(); }, canToggleSubs: hasSubtitles, helpOpen: showShortcutHelp, @@ -1369,12 +1249,6 @@ onBeforeUnmount(() => { unbindMediaSession = null; } - disconnectGainController(); - if (gainContext && gainContext.state !== 'closed') { - void gainContext.close().catch(() => {}); - } - gainContext = null; - if (videoElement.value) { destroyed.value = true; try { diff --git a/ui/app/composables/useKeyboardShortcuts.ts b/ui/app/composables/useKeyboardShortcuts.ts index 6093be72..4b210926 100644 --- a/ui/app/composables/useKeyboardShortcuts.ts +++ b/ui/app/composables/useKeyboardShortcuts.ts @@ -15,6 +15,8 @@ import { seekToPercent, seekBackward, seekForward, + seekStart, + seekEnd, fullscreen, pictureInPicture, toggleCaptions, @@ -171,13 +173,13 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => { // Jump to start (Home) case 'home': event.preventDefault(); - video.currentTime = 0; + seekStart(ctx); break; // Jump to end (End) case 'end': event.preventDefault(); - video.currentTime = video.duration; + seekEnd(ctx); break; // Show/hide help (Shift+/) diff --git a/ui/app/composables/usePlayerMediaVolume.ts b/ui/app/composables/usePlayerMediaVolume.ts deleted file mode 100644 index 9856d332..00000000 --- a/ui/app/composables/usePlayerMediaVolume.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { computed } from 'vue'; -import { useStorage } from '@vueuse/core'; - -function clampVolume(volume: number): number { - if (!Number.isFinite(volume)) return 1; - return Math.min(1, Math.max(0, volume)); -} - -export function usePlayerMediaVolume() { - const volume = useStorage('player_volume', 1); - const muted = useStorage('player_muted', false); - const effectiveVolume = computed(() => { - return muted.value ? 0 : clampVolume(volume.value); - }); - - function setVolume(nextVolume: number) { - const value = clampVolume(nextVolume); - volume.value = value; - muted.value = value <= 0; - } - - function changeVolume(delta: number) { - setVolume(volume.value + delta); - } - - function toggleMute() { - if (muted.value || effectiveVolume.value <= 0) { - volume.value = volume.value > 0 ? clampVolume(volume.value) : 1; - muted.value = false; - return; - } - - muted.value = true; - } - - return { - volume, - muted, - effectiveVolume, - setVolume, - changeVolume, - toggleMute, - }; -} diff --git a/ui/app/composables/usePlayerShortcuts.ts b/ui/app/composables/usePlayerShortcuts.ts index 24300752..f4ee5ac0 100644 --- a/ui/app/composables/usePlayerShortcuts.ts +++ b/ui/app/composables/usePlayerShortcuts.ts @@ -8,12 +8,24 @@ import { toValue, } from 'vue'; import { - clampMediaTime, - clampMediaVolume, + changeSpeed, + changeVolume, + forward, + frameStep, hasModifierKey, + playPause, + rewind, + seekBackward, + seekEnd, + seekForward, + seekStart, + seekToPercent, shouldHandleKeyboardShortcut, + toggleCaptions, } from '~/utils/keyboard'; +import type { KeyboardShortcutContext } from '~/types/video'; + type UsePlayerShortcutsOptions = { enabled: MaybeRefOrGetter; media: MaybeRefOrGetter; @@ -30,36 +42,6 @@ type UsePlayerShortcutsOptions = { export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) { const showHelp = options.helpOpen || ref(false); - function togglePlayPause(media: HTMLMediaElement) { - if (media.paused) { - void media.play().catch(() => {}); - return; - } - - media.pause(); - } - - function stepFrame(media: HTMLMediaElement, direction: 'forward' | 'backward') { - if (!media.paused) { - media.pause(); - } - - const frameStep = direction === 'forward' ? 0.033 : -0.033; - clampMediaTime(media, media.currentTime + frameStep); - } - - function toggleNativeSubtitles(video: HTMLVideoElement) { - const tracks = Array.from(video.textTracks); - const subtitleTrack = tracks.find( - (track) => track.kind === 'subtitles' || track.kind === 'captions', - ); - if (!subtitleTrack) { - return; - } - - subtitleTrack.mode = subtitleTrack.mode === 'showing' ? 'hidden' : 'showing'; - } - async function handleKeyDown(event: KeyboardEvent) { if (!toValue(options.enabled) || !shouldHandleKeyboardShortcut(event)) { return; @@ -70,6 +52,8 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) { return; } + const ctx: KeyboardShortcutContext = { video: media }; + const key = event.key.toLowerCase(); if (hasModifierKey(event) && !['f', '?', '/'].includes(key)) { return; @@ -80,39 +64,37 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) { case 'k': event.preventDefault(); event.stopPropagation(); - togglePlayPause(media); + playPause(ctx); break; case 'j': event.preventDefault(); event.stopPropagation(); - clampMediaTime(media, media.currentTime - 10); + rewind(ctx, 10); break; case 'l': event.preventDefault(); event.stopPropagation(); - clampMediaTime(media, media.currentTime + 10); + forward(ctx, 10); break; case 'arrowleft': event.preventDefault(); event.stopPropagation(); - clampMediaTime(media, media.currentTime - 5); + seekBackward(ctx, 5); break; case 'arrowright': event.preventDefault(); event.stopPropagation(); - clampMediaTime(media, media.currentTime + 5); + seekForward(ctx, 5); break; case 'home': event.preventDefault(); event.stopPropagation(); - media.currentTime = 0; + seekStart(ctx); break; case 'end': event.preventDefault(); event.stopPropagation(); - if (Number.isFinite(media.duration)) { - media.currentTime = media.duration; - } + seekEnd(ctx); break; case '0': case '1': @@ -126,9 +108,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) { case '9': { event.preventDefault(); event.stopPropagation(); - if (Number.isFinite(media.duration) && media.duration > 0) { - media.currentTime = (parseInt(key, 10) / 10) * media.duration; - } + seekToPercent(ctx, parseInt(key, 10) * 10); break; } case 'arrowup': @@ -139,8 +119,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) { break; } - media.volume = clampMediaVolume(media.volume + 0.1); - media.muted = false; + changeVolume(ctx, 0.1); break; case 'arrowdown': event.preventDefault(); @@ -150,10 +129,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) { break; } - media.volume = clampMediaVolume(media.volume - 0.1); - if (media.volume <= 0) { - media.muted = true; - } + changeVolume(ctx, -0.1); break; case 'm': event.preventDefault(); @@ -168,22 +144,22 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) { case ';': event.preventDefault(); event.stopPropagation(); - media.playbackRate = Math.max(0.25, media.playbackRate - 0.25); + changeSpeed(ctx, -0.25); break; case "'": event.preventDefault(); event.stopPropagation(); - media.playbackRate = Math.min(2, media.playbackRate + 0.25); + changeSpeed(ctx, 0.25); break; case ',': event.preventDefault(); event.stopPropagation(); - stepFrame(media, 'backward'); + frameStep(ctx, 'backward'); break; case '.': event.preventDefault(); event.stopPropagation(); - stepFrame(media, 'forward'); + frameStep(ctx, 'forward'); break; case 'f': event.preventDefault(); @@ -199,7 +175,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) { event.stopPropagation(); const video = toValue(options.video); if (video?.textTracks.length) { - toggleNativeSubtitles(video); + toggleCaptions(video); } options.toggleSubtitles(); break; diff --git a/ui/app/types/video.d.ts b/ui/app/types/video.d.ts index 92c6ce13..bd39b170 100644 --- a/ui/app/types/video.d.ts +++ b/ui/app/types/video.d.ts @@ -1,5 +1,5 @@ type KeyboardShortcutContext = { - video: HTMLVideoElement; + video: HTMLMediaElement; }; type VideoTrackElement = { diff --git a/ui/app/utils/keyboard.ts b/ui/app/utils/keyboard.ts index 2482f201..bbfc6878 100644 --- a/ui/app/utils/keyboard.ts +++ b/ui/app/utils/keyboard.ts @@ -7,6 +7,10 @@ export const clampMediaTime = (media: HTMLMediaElement, nextTime: number) => { }; export const clampMediaVolume = (volume: number) => { + if (!Number.isFinite(volume)) { + return 1; + } + return Math.min(1, Math.max(0, volume)); }; @@ -15,18 +19,18 @@ export const hasModifierKey = (event: KeyboardEvent): boolean => export const playPause = (ctx: KeyboardShortcutContext) => { if (ctx.video.paused) { - ctx.video.play(); + void ctx.video.play().catch(() => {}); } else { ctx.video.pause(); } }; export const rewind = (ctx: KeyboardShortcutContext, seconds: number = 10) => { - ctx.video.currentTime = Math.max(0, ctx.video.currentTime - seconds); + clampMediaTime(ctx.video, ctx.video.currentTime - seconds); }; export const forward = (ctx: KeyboardShortcutContext, seconds: number = 10) => { - ctx.video.currentTime = Math.min(ctx.video.duration, ctx.video.currentTime + seconds); + clampMediaTime(ctx.video, ctx.video.currentTime + seconds); }; export const mute = (ctx: KeyboardShortcutContext) => { @@ -36,6 +40,7 @@ export const mute = (ctx: KeyboardShortcutContext) => { export const changeVolume = (ctx: KeyboardShortcutContext, delta: number) => { const volume = clampMediaVolume(ctx.video.volume + delta); ctx.video.volume = volume; + ctx.video.muted = volume <= 0; }; export const changeSpeed = (ctx: KeyboardShortcutContext, delta: number) => { @@ -53,22 +58,29 @@ export const frameStep = ( // Frame step by ~33ms (approximately 1 frame at 30fps, ~16.7ms at 60fps) const frameStep = 'forward' === direction ? 0.033 : -0.033; - ctx.video.currentTime = Math.max( - 0, - Math.min(ctx.video.duration, ctx.video.currentTime + frameStep), - ); + clampMediaTime(ctx.video, ctx.video.currentTime + frameStep); }; export const seekToPercent = (ctx: KeyboardShortcutContext, percent: number) => { ctx.video.currentTime = (percent / 100) * ctx.video.duration; }; +export const seekStart = (ctx: KeyboardShortcutContext) => { + ctx.video.currentTime = 0; +}; + +export const seekEnd = (ctx: KeyboardShortcutContext) => { + if (Number.isFinite(ctx.video.duration)) { + ctx.video.currentTime = ctx.video.duration; + } +}; + export const seekBackward = (ctx: KeyboardShortcutContext, seconds: number = 5) => { - clampMediaTime(ctx.video, ctx.video.currentTime - seconds); + rewind(ctx, seconds); }; export const seekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => { - clampMediaTime(ctx.video, ctx.video.currentTime + seconds); + forward(ctx, seconds); }; export const fullscreen = (video: HTMLVideoElement) => { diff --git a/ui/app/utils/media.ts b/ui/app/utils/media.ts new file mode 100644 index 00000000..d8a48632 --- /dev/null +++ b/ui/app/utils/media.ts @@ -0,0 +1,68 @@ +type ResumeState = { + time: number; + shouldPlay: boolean; +}; + +const clampResumeTime = ( + target: Pick, + time: number, +): number => { + if (!Number.isFinite(time) || time <= 0) { + return 0; + } + + const seekable = target.seekable; + if (seekable && seekable.length > 0) { + const last = seekable.length - 1; + const start = seekable.start(last); + const end = seekable.end(last); + + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) { + return 0; + } + + return Math.min(Math.max(time, start), end); + } + + const duration = target.duration; + if (Number.isFinite(duration) && duration > 0) { + return Math.min(time, Math.max(duration - 0.25, 0)); + } + + return time; +}; + +const readResumeState = (target: HTMLMediaElement | null): ResumeState => { + if (!target) { + return { time: 0, shouldPlay: false }; + } + + const time = + Number.isFinite(target.currentTime) && target.currentTime > 0 ? target.currentTime : 0; + return { + time, + shouldPlay: !target.paused && !target.ended, + }; +}; + +const resumeMedia = async (target: HTMLMediaElement | null, state: ResumeState): Promise => { + if (!target) { + return; + } + + const nextTime = clampResumeTime(target, state.time); + if (nextTime > 0) { + try { + target.currentTime = nextTime; + } catch {} + } + + if (state.shouldPlay) { + try { + await target.play(); + } catch {} + } +}; + +export { clampResumeTime, readResumeState, resumeMedia }; +export type { ResumeState }; diff --git a/ui/app/utils/playerControls.ts b/ui/app/utils/playerControls.ts new file mode 100644 index 00000000..2ab0e0ae --- /dev/null +++ b/ui/app/utils/playerControls.ts @@ -0,0 +1,20 @@ +type TapState = { + touch: boolean; + paused: boolean; + visible: boolean; +}; + +const nextTapVisible = ({ touch, paused, visible }: TapState): boolean => { + if (paused) { + return visible; + } + + if (visible) { + return false; + } + + return touch; +}; + +export { nextTapVisible }; +export type { TapState }; diff --git a/ui/nuxt.config.ts b/ui/nuxt.config.ts index 7742ffc1..0d8e491a 100644 --- a/ui/nuxt.config.ts +++ b/ui/nuxt.config.ts @@ -92,8 +92,6 @@ export default defineNuxtConfig({ 'marked-gfm-heading-id', 'hls.js', 'assjs', - '@vue/devtools-core', - '@vue/devtools-kit', ], }, server: { diff --git a/ui/tests/composables/usePlayerShortcuts.test.ts b/ui/tests/composables/usePlayerShortcuts.test.ts index 7c90afb6..b48ff532 100644 --- a/ui/tests/composables/usePlayerShortcuts.test.ts +++ b/ui/tests/composables/usePlayerShortcuts.test.ts @@ -1,6 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import { ref } from 'vue'; +function getKeydownHandler(addEventListenerSpy: ReturnType) { + const handler = addEventListenerSpy.mock.calls.find((call) => call[0] === 'keydown')?.[1]; + if (!handler || typeof handler !== 'function') { + throw new Error('Expected keydown handler to be registered'); + } + + return handler; +} + describe('usePlayerShortcuts', () => { beforeEach(() => { document.body.innerHTML = ''; @@ -40,10 +49,7 @@ describe('usePlayerShortcuts', () => { toggleFullscreen: () => {}, }); - const handler = addEventListenerSpy.mock.calls.find((call) => call[0] === 'keydown')?.[1]; - if (!handler || typeof handler !== 'function') { - throw new Error('Expected keydown handler to be registered'); - } + const handler = getKeydownHandler(addEventListenerSpy); const preventDefault = mock(() => {}); const stopPropagation = mock(() => {}); @@ -63,6 +69,51 @@ describe('usePlayerShortcuts', () => { addEventListenerSpy.mockRestore(); }); + it('volume_up_unmute', async () => { + const { usePlayerShortcuts } = await import('~/composables/usePlayerShortcuts'); + const addEventListenerSpy = spyOn(document, 'addEventListener'); + + const media = { + paused: true, + currentTime: 0, + duration: 100, + playbackRate: 1, + volume: 0, + muted: true, + play: async () => {}, + pause: () => {}, + textTracks: [], + } as unknown as HTMLMediaElement; + + usePlayerShortcuts({ + enabled: ref(true), + media: ref(media), + video: ref(null), + canToggleSubs: ref(false), + toggleSubtitles: () => {}, + toggleFullscreen: () => {}, + }); + + const handler = getKeydownHandler(addEventListenerSpy); + const preventDefault = mock(() => {}); + const stopPropagation = mock(() => {}); + + handler({ + key: 'ArrowUp', + target: document.body, + preventDefault, + stopPropagation, + ctrlKey: false, + metaKey: false, + altKey: false, + } as unknown as KeyboardEvent); + + expect(media.volume).toBe(0.1); + expect(media.muted).toBe(false); + + addEventListenerSpy.mockRestore(); + }); + it('close_help_first', async () => { const { usePlayerShortcuts } = await import('~/composables/usePlayerShortcuts'); diff --git a/ui/tests/utils/media.test.ts b/ui/tests/utils/media.test.ts new file mode 100644 index 00000000..70697667 --- /dev/null +++ b/ui/tests/utils/media.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, mock } from 'bun:test'; + +describe('media utils', () => { + it('clamp_seekable_range', async () => { + const { clampResumeTime } = await import('~/utils/media'); + + const seekable = { + length: 1, + start: () => 12, + end: () => 48, + } as TimeRanges; + + expect(clampResumeTime({ duration: Number.NaN, seekable }, 4)).toBe(12); + expect(clampResumeTime({ duration: Number.NaN, seekable }, 22)).toBe(22); + expect(clampResumeTime({ duration: Number.NaN, seekable }, 60)).toBe(48); + }); + + it('resume_keep_time_and_play', async () => { + const { resumeMedia } = await import('~/utils/media'); + + const play = mock(async () => {}); + const target = { + currentTime: 0, + duration: 100, + seekable: { length: 0 } as TimeRanges, + play, + } as unknown as HTMLMediaElement; + + await resumeMedia(target, { time: 33, shouldPlay: true }); + + expect(target.currentTime).toBe(33); + expect(play).toHaveBeenCalledTimes(1); + }); + + it('read_resume_state', async () => { + const { readResumeState } = await import('~/utils/media'); + + const target = { + currentTime: 19, + paused: false, + ended: false, + } as HTMLMediaElement; + + expect(readResumeState(target)).toEqual({ time: 19, shouldPlay: true }); + expect(readResumeState(null)).toEqual({ time: 0, shouldPlay: false }); + }); +}); diff --git a/ui/tests/utils/playerControls.test.ts b/ui/tests/utils/playerControls.test.ts new file mode 100644 index 00000000..e29f7883 --- /dev/null +++ b/ui/tests/utils/playerControls.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'bun:test'; + +describe('playerControls utils', () => { + it('show_hidden_touch', async () => { + const { nextTapVisible } = await import('~/utils/playerControls'); + + expect(nextTapVisible({ touch: true, paused: false, visible: false })).toBe(true); + }); + + it('keep_hidden_desktop', async () => { + const { nextTapVisible } = await import('~/utils/playerControls'); + + expect(nextTapVisible({ touch: false, paused: false, visible: false })).toBe(false); + }); + + it('hide_visible_when_playing', async () => { + const { nextTapVisible } = await import('~/utils/playerControls'); + + expect(nextTapVisible({ touch: true, paused: false, visible: true })).toBe(false); + }); +});