feat: add resume for videos
This commit is contained in:
parent
734ef1c53c
commit
73b8d867a0
6 changed files with 284 additions and 85 deletions
|
|
@ -618,14 +618,14 @@
|
||||||
|
|
||||||
<UModal
|
<UModal
|
||||||
v-if="videoItem"
|
v-if="videoItem"
|
||||||
:open="Boolean(videoItem)"
|
:open="videoOpen"
|
||||||
title="Video"
|
title="Video"
|
||||||
:dismissible="true"
|
:dismissible="true"
|
||||||
:ui="{
|
:ui="{
|
||||||
content: lightsOut ? 'w-full sm:max-w-5xl shadow-2xl' : 'w-full sm:max-w-5xl',
|
content: lightsOut ? 'w-full sm:max-w-5xl shadow-2xl' : 'w-full sm:max-w-5xl',
|
||||||
body: 'p-0',
|
body: 'p-0',
|
||||||
}"
|
}"
|
||||||
@update:open="(open) => !open && closePlayer()"
|
@update:open="handleVideoOpenChange"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
|
|
@ -635,7 +635,7 @@
|
||||||
:isControls="true"
|
:isControls="true"
|
||||||
:item="videoItem"
|
:item="videoItem"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@closeModel="closePlayer"
|
@closeModel="() => void requestCloseVideo()"
|
||||||
@error="async (error: string) => await box.alert(error)"
|
@error="async (error: string) => await box.alert(error)"
|
||||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -667,6 +667,7 @@ import { useStorage } from '@vueuse/core';
|
||||||
import type { item_request } from '~/types/item';
|
import type { item_request } from '~/types/item';
|
||||||
import type { ItemStatus, StoreItem } from '~/types/store';
|
import type { ItemStatus, StoreItem } from '~/types/store';
|
||||||
import { useConfirm } from '~/composables/useConfirm';
|
import { useConfirm } from '~/composables/useConfirm';
|
||||||
|
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
|
||||||
import { useHistoryState } from '~/composables/useHistoryState';
|
import { useHistoryState } from '~/composables/useHistoryState';
|
||||||
import { useMediaQuery } from '~/composables/useMediaQuery';
|
import { useMediaQuery } from '~/composables/useMediaQuery';
|
||||||
import { usePresetOptions } from '~/composables/usePresetOptions';
|
import { usePresetOptions } from '~/composables/usePresetOptions';
|
||||||
|
|
@ -733,6 +734,16 @@ const embedUrl = ref('');
|
||||||
const videoItem = ref<StoreItem | null>(null);
|
const videoItem = ref<StoreItem | null>(null);
|
||||||
const playingNow = ref(false);
|
const playingNow = ref(false);
|
||||||
const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const videoOpen = computed<boolean>({
|
||||||
|
get: () => Boolean(videoItem.value),
|
||||||
|
set: (value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closePlayer();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const formUrl = ref('');
|
const formUrl = ref('');
|
||||||
const formPreset = ref(app.value.default_preset || '');
|
const formPreset = ref(app.value.default_preset || '');
|
||||||
|
|
@ -1050,6 +1061,18 @@ const closePlayer = (): void => {
|
||||||
videoItem.value = null;
|
videoItem.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { handleOpenChange: handleVideoOpenChange, requestClose: requestCloseVideo } =
|
||||||
|
useDirtyCloseGuard(videoOpen, {
|
||||||
|
dirty: playingNow,
|
||||||
|
title: 'Close player?',
|
||||||
|
message: 'Playback is active. Do you want to close the player?',
|
||||||
|
confirmText: 'Close player',
|
||||||
|
cancelText: 'Keep playing',
|
||||||
|
onDiscard: async () => {
|
||||||
|
closePlayer();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const getDescription = (item: StoreItem): string => {
|
const getDescription = (item: StoreItem): string => {
|
||||||
const direct = (item.description ?? '').toString().trim();
|
const direct = (item.description ?? '').toString().trim();
|
||||||
if (direct) {
|
if (direct) {
|
||||||
|
|
|
||||||
|
|
@ -346,7 +346,7 @@ import {
|
||||||
requestElementFullscreen,
|
requestElementFullscreen,
|
||||||
} from '~/utils/fullscreen';
|
} from '~/utils/fullscreen';
|
||||||
import { clampMediaVolume } from '~/utils/keyboard';
|
import { clampMediaVolume } from '~/utils/keyboard';
|
||||||
import { readResumeState, resumeMedia } from '~/utils/media';
|
import { clear, clampResumeTime, nearEnd, read, save } from '~/utils/media';
|
||||||
import { nextTapVisible } from '~/utils/playerControls';
|
import { nextTapVisible } from '~/utils/playerControls';
|
||||||
|
|
||||||
import type { StoreItem } from '~/types/store';
|
import type { StoreItem } from '~/types/store';
|
||||||
|
|
@ -407,10 +407,13 @@ let controlsHideTimeout = 0;
|
||||||
let pendingVideoClickTimeout = 0;
|
let pendingVideoClickTimeout = 0;
|
||||||
let unbindMediaSession: null | (() => void) = null;
|
let unbindMediaSession: null | (() => void) = null;
|
||||||
let hls: Hls | null = null;
|
let hls: Hls | null = null;
|
||||||
let pendingResume: null | { time: number; shouldPlay: boolean } = null;
|
let pendingSeek: number | null = null;
|
||||||
|
let pendingPlay = false;
|
||||||
|
let lastSaveAt = 0;
|
||||||
|
|
||||||
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent);
|
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent);
|
||||||
const mediaFile = computed(() => props.item.filename || '');
|
const mediaFile = computed(() => props.item.filename || '');
|
||||||
|
const id = computed(() => props.item._id || '');
|
||||||
const subtitleManifestUrl = computed(() => currentPlaybackUrl('api/player/subtitles/manifest'));
|
const subtitleManifestUrl = computed(() => currentPlaybackUrl('api/player/subtitles/manifest'));
|
||||||
const canPlay = computed(() => Boolean(mediaFile.value && !loadingError.value));
|
const canPlay = computed(() => Boolean(mediaFile.value && !loadingError.value));
|
||||||
const shouldRender = computed(() => active.value && !loading.value);
|
const shouldRender = computed(() => active.value && !loading.value);
|
||||||
|
|
@ -525,12 +528,17 @@ function handleVideoLoadedMetadata() {
|
||||||
updateMediaSessionPosition(videoElement.value);
|
updateMediaSessionPosition(videoElement.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingResume) {
|
if (pendingSeek !== null) {
|
||||||
void resumeMedia(videoElement.value, pendingResume).finally(() => {
|
const time = pendingSeek;
|
||||||
pendingResume = null;
|
const play = pendingPlay;
|
||||||
|
pendingSeek = null;
|
||||||
|
pendingPlay = false;
|
||||||
|
void restoreSwitch(time, play).finally(() => {
|
||||||
syncVideoState();
|
syncVideoState();
|
||||||
showControls();
|
showControls();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
restoreStoredProgress();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -539,6 +547,8 @@ function handleVideoTimeUpdate() {
|
||||||
if (videoElement.value) {
|
if (videoElement.value) {
|
||||||
updateMediaSessionPosition(videoElement.value);
|
updateMediaSessionPosition(videoElement.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
persistProgress(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVideoPlay() {
|
function handleVideoPlay() {
|
||||||
|
|
@ -552,6 +562,7 @@ function handleVideoPause() {
|
||||||
syncVideoState();
|
syncVideoState();
|
||||||
clearControlsHideTimeout();
|
clearControlsHideTimeout();
|
||||||
controlsVisible.value = true;
|
controlsVisible.value = true;
|
||||||
|
persistProgress(true);
|
||||||
emitter('playback-state-change', false);
|
emitter('playback-state-change', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -624,6 +635,85 @@ function handleMediaVolumeChange(event: Event) {
|
||||||
updateMediaSessionPosition(target);
|
updateMediaSessionPosition(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restoreStoredProgress() {
|
||||||
|
if (!id.value || !videoElement.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = read(id.value);
|
||||||
|
if (saved <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void seekTo(saved).finally(() => {
|
||||||
|
syncVideoState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSwitchTime() {
|
||||||
|
const video = videoElement.value;
|
||||||
|
if (!video) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number.isFinite(video.currentTime) && video.currentTime > 0 ? video.currentTime : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlaying() {
|
||||||
|
const video = videoElement.value;
|
||||||
|
return Boolean(video && !video.paused && !video.ended);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seekTo(time: number) {
|
||||||
|
const video = videoElement.value;
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTime = clampResumeTime(video, time);
|
||||||
|
if (nextTime > 0) {
|
||||||
|
try {
|
||||||
|
video.currentTime = nextTime;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSwitch(time: number, play: boolean) {
|
||||||
|
await seekTo(time);
|
||||||
|
|
||||||
|
if (play) {
|
||||||
|
try {
|
||||||
|
await videoElement.value?.play();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistProgress(force: boolean) {
|
||||||
|
const video = videoElement.value;
|
||||||
|
if (!id.value || !video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nearEnd(video)) {
|
||||||
|
clear(id.value);
|
||||||
|
lastSaveAt = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = Number.isFinite(video.currentTime) && video.currentTime > 0 ? video.currentTime : 0;
|
||||||
|
if (time <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && now - lastSaveAt < 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(id.value, time);
|
||||||
|
lastSaveAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
function handlePointerMove(event: PointerEvent) {
|
function handlePointerMove(event: PointerEvent) {
|
||||||
if (!playerContainer.value || isTouchDevice.value) {
|
if (!playerContainer.value || isTouchDevice.value) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -721,6 +811,12 @@ function syncVideoState() {
|
||||||
duration.value = nextDuration;
|
duration.value = nextDuration;
|
||||||
currentTime.value = nextTime;
|
currentTime.value = nextTime;
|
||||||
paused.value = video.paused;
|
paused.value = video.paused;
|
||||||
|
|
||||||
|
if (video.ended || nearEnd(video)) {
|
||||||
|
clear(id.value);
|
||||||
|
lastSaveAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
emitter('playback-state-change', !video.paused);
|
emitter('playback-state-change', !video.paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1050,12 +1146,17 @@ async function src_error(event: Event) {
|
||||||
attach_hls(currentPlaybackUrl('m3u8', true));
|
attach_hls(currentPlaybackUrl('m3u8', true));
|
||||||
}
|
}
|
||||||
|
|
||||||
function attach_hls(link: string, resume = readResumeState(videoElement.value)) {
|
function attach_hls(
|
||||||
|
link: string,
|
||||||
|
time = pendingSeek ?? readSwitchTime(),
|
||||||
|
play = pendingPlay || isPlaying(),
|
||||||
|
) {
|
||||||
if (!videoElement.value) {
|
if (!videoElement.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingResume = resume;
|
pendingSeek = time;
|
||||||
|
pendingPlay = play;
|
||||||
|
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
|
|
@ -1160,6 +1261,7 @@ onBeforeUnmount(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoElement.value) {
|
if (videoElement.value) {
|
||||||
|
persistProgress(true);
|
||||||
destroyed.value = true;
|
destroyed.value = true;
|
||||||
try {
|
try {
|
||||||
videoElement.value.pause();
|
videoElement.value.pause();
|
||||||
|
|
|
||||||
|
|
@ -492,11 +492,11 @@
|
||||||
|
|
||||||
<UModal
|
<UModal
|
||||||
v-if="model_item"
|
v-if="model_item"
|
||||||
:open="Boolean(model_item)"
|
:open="previewOpen"
|
||||||
:title="previewTitle"
|
:title="previewTitle"
|
||||||
:dismissible="true"
|
:dismissible="true"
|
||||||
:ui="previewModalUi"
|
:ui="previewModalUi"
|
||||||
@update:open="(open) => !open && closeModel()"
|
@update:open="handlePreviewOpenChange"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
|
|
@ -507,7 +507,7 @@
|
||||||
:isControls="true"
|
:isControls="true"
|
||||||
:item="model_item"
|
:item="model_item"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@closeModel="closeModel"
|
@closeModel="() => void requestClosePreview()"
|
||||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -533,6 +533,7 @@
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import type { DropdownMenuItem } from '@nuxt/ui';
|
import type { DropdownMenuItem } from '@nuxt/ui';
|
||||||
|
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
|
||||||
import type { FileItem } from '~/types/filebrowser';
|
import type { FileItem } from '~/types/filebrowser';
|
||||||
import { formatPageTitle } from '~/utils';
|
import { formatPageTitle } from '~/utils';
|
||||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||||
|
|
@ -628,6 +629,16 @@ const isUpdating = ref(false);
|
||||||
|
|
||||||
const model_item = ref<any | null>(null);
|
const model_item = ref<any | null>(null);
|
||||||
const playingNow = ref(false);
|
const playingNow = ref(false);
|
||||||
|
const previewOpen = computed<boolean>({
|
||||||
|
get: () => Boolean(model_item.value),
|
||||||
|
set: (value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModel();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const previewTitle = computed(() => {
|
const previewTitle = computed(() => {
|
||||||
if (!model_item.value) {
|
if (!model_item.value) {
|
||||||
|
|
@ -746,6 +757,18 @@ const closeModel = (): void => {
|
||||||
model_item.value = null;
|
model_item.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { handleOpenChange: handlePreviewOpenChange, requestClose: requestClosePreview } =
|
||||||
|
useDirtyCloseGuard(previewOpen, {
|
||||||
|
dirty: computed(() => Boolean(model_item.value?.type === 'video' && playingNow.value)),
|
||||||
|
title: 'Close player?',
|
||||||
|
message: 'Playback is active. Do you want to close the player?',
|
||||||
|
confirmText: 'Close player',
|
||||||
|
cancelText: 'Keep playing',
|
||||||
|
onDiscard: async () => {
|
||||||
|
closeModel();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const clearFilter = (): void => {
|
const clearFilter = (): void => {
|
||||||
localSearch.value = '';
|
localSearch.value = '';
|
||||||
show_filter.value = false;
|
show_filter.value = false;
|
||||||
|
|
|
||||||
|
|
@ -718,11 +718,11 @@
|
||||||
|
|
||||||
<UModal
|
<UModal
|
||||||
v-if="video_item"
|
v-if="video_item"
|
||||||
:open="Boolean(video_item)"
|
:open="videoOpen"
|
||||||
:dismissible="true"
|
:dismissible="true"
|
||||||
:title="video_item?.title || 'Player'"
|
:title="video_item?.title || 'Player'"
|
||||||
:ui="{ content: lightsOut ? 'sm:max-w-5xl shadow-2xl' : 'sm:max-w-5xl', body: 'p-0' }"
|
:ui="{ content: lightsOut ? 'sm:max-w-5xl shadow-2xl' : 'sm:max-w-5xl', body: 'p-0' }"
|
||||||
@update:open="(open) => !open && closeVideo()"
|
@update:open="handleVideoOpenChange"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
|
|
@ -732,7 +732,7 @@
|
||||||
:isControls="true"
|
:isControls="true"
|
||||||
:item="video_item"
|
:item="video_item"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@closeModel="closeVideo()"
|
@closeModel="() => void requestCloseVideo()"
|
||||||
@error="async (error: string) => await box.alert(error)"
|
@error="async (error: string) => await box.alert(error)"
|
||||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -768,6 +768,7 @@ import { toRaw } from 'vue';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { useConfirm } from '~/composables/useConfirm';
|
import { useConfirm } from '~/composables/useConfirm';
|
||||||
|
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
|
||||||
import { useDialog } from '~/composables/useDialog';
|
import { useDialog } from '~/composables/useDialog';
|
||||||
import { useAppSocket } from '~/composables/useAppSocket';
|
import { useAppSocket } from '~/composables/useAppSocket';
|
||||||
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
||||||
|
|
@ -843,6 +844,16 @@ const contentStyle = computed<'grid' | 'list'>(() =>
|
||||||
);
|
);
|
||||||
const showThumbnails = computed(() => show_thumbnail.value && !hideThumbnail.value);
|
const showThumbnails = computed(() => show_thumbnail.value && !hideThumbnail.value);
|
||||||
const lightsOut = computed(() => Boolean(video_item.value && playingNow.value));
|
const lightsOut = computed(() => Boolean(video_item.value && playingNow.value));
|
||||||
|
const videoOpen = computed<boolean>({
|
||||||
|
get: () => Boolean(video_item.value),
|
||||||
|
set: (value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeVideo();
|
||||||
|
},
|
||||||
|
});
|
||||||
const paginationInfo = computed(() => ({
|
const paginationInfo = computed(() => ({
|
||||||
...pagination.value,
|
...pagination.value,
|
||||||
isLoading: isLoading.value,
|
isLoading: isLoading.value,
|
||||||
|
|
@ -894,6 +905,18 @@ const closeVideo = (): void => {
|
||||||
video_item.value = null;
|
video_item.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { handleOpenChange: handleVideoOpenChange, requestClose: requestCloseVideo } =
|
||||||
|
useDirtyCloseGuard(videoOpen, {
|
||||||
|
dirty: playingNow,
|
||||||
|
title: 'Close player?',
|
||||||
|
message: 'Playback is active. Do you want to close the player?',
|
||||||
|
confirmText: 'Close player',
|
||||||
|
cancelText: 'Keep playing',
|
||||||
|
onDiscard: async () => {
|
||||||
|
closeVideo();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const view_info = (
|
const view_info = (
|
||||||
url: string,
|
url: string,
|
||||||
useUrl: boolean = false,
|
useUrl: boolean = false,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,53 @@
|
||||||
type ResumeState = {
|
import { useLocalCache } from '~/utils/cache';
|
||||||
time: number;
|
|
||||||
shouldPlay: boolean;
|
const KEY = 'video:';
|
||||||
|
const cache = useLocalCache();
|
||||||
|
|
||||||
|
const read = (id: string | null | undefined): number => {
|
||||||
|
if (!id) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const time = Number(cache.get<number>(`${KEY}${id}`));
|
||||||
|
return Number.isFinite(time) && time > 0 ? time : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = (id: string | null | undefined, time: number): void => {
|
||||||
|
if (!id || !Number.isFinite(time) || time <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(`${KEY}${id}`, time, 3600 * 24);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = (id: string | null | undefined): void => {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.remove(`${KEY}${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nearEnd = (
|
||||||
|
target: Pick<HTMLMediaElement, 'currentTime' | 'duration'> | null,
|
||||||
|
pad: number = 5,
|
||||||
|
): boolean => {
|
||||||
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = target.duration;
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time =
|
||||||
|
Number.isFinite(target.currentTime) && target.currentTime > 0 ? target.currentTime : 0;
|
||||||
|
return duration - time <= pad;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clampResumeTime = (
|
const clampResumeTime = (
|
||||||
|
|
@ -31,38 +78,4 @@ const clampResumeTime = (
|
||||||
|
|
||||||
return time;
|
return time;
|
||||||
};
|
};
|
||||||
|
export { clampResumeTime, clear, nearEnd, read, save };
|
||||||
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<void> => {
|
|
||||||
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 };
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,62 @@
|
||||||
import { describe, expect, it, mock } from 'bun:test';
|
import { beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||||
|
|
||||||
|
const store = new Map<string, unknown>();
|
||||||
|
|
||||||
|
const cache = {
|
||||||
|
get: mock(<T>(key: string) => (store.has(key) ? (store.get(key) as T) : null)),
|
||||||
|
set: mock((key: string, value: unknown) => {
|
||||||
|
store.set(key, value);
|
||||||
|
}),
|
||||||
|
remove: mock((key: string) => {
|
||||||
|
store.delete(key);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module('~/utils/cache', () => ({
|
||||||
|
useLocalCache: () => cache,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let media: Awaited<typeof import('~/utils/media')>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
media = await import('~/utils/media');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store.clear();
|
||||||
|
cache.get.mockClear();
|
||||||
|
cache.set.mockClear();
|
||||||
|
cache.remove.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
describe('media utils', () => {
|
describe('media utils', () => {
|
||||||
it('clamp_seekable_range', async () => {
|
it('clamp_seekable_range', () => {
|
||||||
const { clampResumeTime } = await import('~/utils/media');
|
|
||||||
|
|
||||||
const seekable = {
|
const seekable = {
|
||||||
length: 1,
|
length: 1,
|
||||||
start: () => 12,
|
start: () => 12,
|
||||||
end: () => 48,
|
end: () => 48,
|
||||||
} as TimeRanges;
|
} as TimeRanges;
|
||||||
|
|
||||||
expect(clampResumeTime({ duration: Number.NaN, seekable }, 4)).toBe(12);
|
expect(media.clampResumeTime({ duration: Number.NaN, seekable }, 4)).toBe(12);
|
||||||
expect(clampResumeTime({ duration: Number.NaN, seekable }, 22)).toBe(22);
|
expect(media.clampResumeTime({ duration: Number.NaN, seekable }, 22)).toBe(22);
|
||||||
expect(clampResumeTime({ duration: Number.NaN, seekable }, 60)).toBe(48);
|
expect(media.clampResumeTime({ duration: Number.NaN, seekable }, 60)).toBe(48);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resume_keep_time_and_play', async () => {
|
it('store_resume_state', () => {
|
||||||
const { resumeMedia } = await import('~/utils/media');
|
media.save('item-1', 42);
|
||||||
|
expect(media.read('item-1')).toBe(42);
|
||||||
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 () => {
|
it('clear_resume_state', () => {
|
||||||
const { readResumeState } = await import('~/utils/media');
|
media.save('item-1', 17);
|
||||||
|
media.clear('item-1');
|
||||||
|
|
||||||
const target = {
|
expect(cache.remove).toHaveBeenCalledWith('video:item-1');
|
||||||
currentTime: 19,
|
expect(media.read('item-1')).toBe(0);
|
||||||
paused: false,
|
});
|
||||||
ended: false,
|
|
||||||
} as HTMLMediaElement;
|
|
||||||
|
|
||||||
expect(readResumeState(target)).toEqual({ time: 19, shouldPlay: true });
|
it('clear_resume_near_end', () => {
|
||||||
expect(readResumeState(null)).toEqual({ time: 0, shouldPlay: false });
|
expect(media.nearEnd({ currentTime: 97, duration: 100 })).toBe(true);
|
||||||
|
expect(media.nearEnd({ currentTime: 80, duration: 100 })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue