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
|
||||
v-if="videoItem"
|
||||
:open="Boolean(videoItem)"
|
||||
:open="videoOpen"
|
||||
title="Video"
|
||||
:dismissible="true"
|
||||
:ui="{
|
||||
content: lightsOut ? 'w-full sm:max-w-5xl shadow-2xl' : 'w-full sm:max-w-5xl',
|
||||
body: 'p-0',
|
||||
}"
|
||||
@update:open="(open) => !open && closePlayer()"
|
||||
@update:open="handleVideoOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<VideoPlayer
|
||||
|
|
@ -635,7 +635,7 @@
|
|||
:isControls="true"
|
||||
:item="videoItem"
|
||||
class="w-full"
|
||||
@closeModel="closePlayer"
|
||||
@closeModel="() => void requestCloseVideo()"
|
||||
@error="async (error: string) => await box.alert(error)"
|
||||
@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 { ItemStatus, StoreItem } from '~/types/store';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
|
||||
import { useHistoryState } from '~/composables/useHistoryState';
|
||||
import { useMediaQuery } from '~/composables/useMediaQuery';
|
||||
import { usePresetOptions } from '~/composables/usePresetOptions';
|
||||
|
|
@ -733,6 +734,16 @@ const embedUrl = ref('');
|
|||
const videoItem = ref<StoreItem | null>(null);
|
||||
const playingNow = ref(false);
|
||||
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 formPreset = ref(app.value.default_preset || '');
|
||||
|
|
@ -1050,6 +1061,18 @@ const closePlayer = (): void => {
|
|||
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 direct = (item.description ?? '').toString().trim();
|
||||
if (direct) {
|
||||
|
|
|
|||
|
|
@ -346,7 +346,7 @@ import {
|
|||
requestElementFullscreen,
|
||||
} from '~/utils/fullscreen';
|
||||
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 type { StoreItem } from '~/types/store';
|
||||
|
|
@ -407,10 +407,13 @@ let controlsHideTimeout = 0;
|
|||
let pendingVideoClickTimeout = 0;
|
||||
let unbindMediaSession: null | (() => void) = 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 mediaFile = computed(() => props.item.filename || '');
|
||||
const id = computed(() => props.item._id || '');
|
||||
const subtitleManifestUrl = computed(() => currentPlaybackUrl('api/player/subtitles/manifest'));
|
||||
const canPlay = computed(() => Boolean(mediaFile.value && !loadingError.value));
|
||||
const shouldRender = computed(() => active.value && !loading.value);
|
||||
|
|
@ -525,12 +528,17 @@ function handleVideoLoadedMetadata() {
|
|||
updateMediaSessionPosition(videoElement.value);
|
||||
}
|
||||
|
||||
if (pendingResume) {
|
||||
void resumeMedia(videoElement.value, pendingResume).finally(() => {
|
||||
pendingResume = null;
|
||||
if (pendingSeek !== null) {
|
||||
const time = pendingSeek;
|
||||
const play = pendingPlay;
|
||||
pendingSeek = null;
|
||||
pendingPlay = false;
|
||||
void restoreSwitch(time, play).finally(() => {
|
||||
syncVideoState();
|
||||
showControls();
|
||||
});
|
||||
} else {
|
||||
restoreStoredProgress();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -539,6 +547,8 @@ function handleVideoTimeUpdate() {
|
|||
if (videoElement.value) {
|
||||
updateMediaSessionPosition(videoElement.value);
|
||||
}
|
||||
|
||||
persistProgress(false);
|
||||
}
|
||||
|
||||
function handleVideoPlay() {
|
||||
|
|
@ -552,6 +562,7 @@ function handleVideoPause() {
|
|||
syncVideoState();
|
||||
clearControlsHideTimeout();
|
||||
controlsVisible.value = true;
|
||||
persistProgress(true);
|
||||
emitter('playback-state-change', false);
|
||||
}
|
||||
|
||||
|
|
@ -624,6 +635,85 @@ function handleMediaVolumeChange(event: Event) {
|
|||
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) {
|
||||
if (!playerContainer.value || isTouchDevice.value) {
|
||||
return;
|
||||
|
|
@ -721,6 +811,12 @@ function syncVideoState() {
|
|||
duration.value = nextDuration;
|
||||
currentTime.value = nextTime;
|
||||
paused.value = video.paused;
|
||||
|
||||
if (video.ended || nearEnd(video)) {
|
||||
clear(id.value);
|
||||
lastSaveAt = 0;
|
||||
}
|
||||
|
||||
emitter('playback-state-change', !video.paused);
|
||||
}
|
||||
|
||||
|
|
@ -1050,12 +1146,17 @@ async function src_error(event: Event) {
|
|||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingResume = resume;
|
||||
pendingSeek = time;
|
||||
pendingPlay = play;
|
||||
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
|
|
@ -1160,6 +1261,7 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
|
||||
if (videoElement.value) {
|
||||
persistProgress(true);
|
||||
destroyed.value = true;
|
||||
try {
|
||||
videoElement.value.pause();
|
||||
|
|
|
|||
|
|
@ -492,11 +492,11 @@
|
|||
|
||||
<UModal
|
||||
v-if="model_item"
|
||||
:open="Boolean(model_item)"
|
||||
:open="previewOpen"
|
||||
:title="previewTitle"
|
||||
:dismissible="true"
|
||||
:ui="previewModalUi"
|
||||
@update:open="(open) => !open && closeModel()"
|
||||
@update:open="handlePreviewOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<VideoPlayer
|
||||
|
|
@ -507,7 +507,7 @@
|
|||
:isControls="true"
|
||||
:item="model_item"
|
||||
class="w-full"
|
||||
@closeModel="closeModel"
|
||||
@closeModel="() => void requestClosePreview()"
|
||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||
/>
|
||||
|
||||
|
|
@ -533,6 +533,7 @@
|
|||
import moment from 'moment';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { DropdownMenuItem } from '@nuxt/ui';
|
||||
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
|
||||
import type { FileItem } from '~/types/filebrowser';
|
||||
import { formatPageTitle } from '~/utils';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
|
@ -628,6 +629,16 @@ const isUpdating = ref(false);
|
|||
|
||||
const model_item = ref<any | null>(null);
|
||||
const playingNow = ref(false);
|
||||
const previewOpen = computed<boolean>({
|
||||
get: () => Boolean(model_item.value),
|
||||
set: (value: boolean) => {
|
||||
if (value) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeModel();
|
||||
},
|
||||
});
|
||||
|
||||
const previewTitle = computed(() => {
|
||||
if (!model_item.value) {
|
||||
|
|
@ -746,6 +757,18 @@ const closeModel = (): void => {
|
|||
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 => {
|
||||
localSearch.value = '';
|
||||
show_filter.value = false;
|
||||
|
|
|
|||
|
|
@ -718,11 +718,11 @@
|
|||
|
||||
<UModal
|
||||
v-if="video_item"
|
||||
:open="Boolean(video_item)"
|
||||
:open="videoOpen"
|
||||
:dismissible="true"
|
||||
:title="video_item?.title || 'Player'"
|
||||
: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>
|
||||
<VideoPlayer
|
||||
|
|
@ -732,7 +732,7 @@
|
|||
:isControls="true"
|
||||
:item="video_item"
|
||||
class="w-full"
|
||||
@closeModel="closeVideo()"
|
||||
@closeModel="() => void requestCloseVideo()"
|
||||
@error="async (error: string) => await box.alert(error)"
|
||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||
/>
|
||||
|
|
@ -768,6 +768,7 @@ import { toRaw } from 'vue';
|
|||
import moment from 'moment';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import { useAppSocket } from '~/composables/useAppSocket';
|
||||
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
||||
|
|
@ -843,6 +844,16 @@ const contentStyle = computed<'grid' | 'list'>(() =>
|
|||
);
|
||||
const showThumbnails = computed(() => show_thumbnail.value && !hideThumbnail.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(() => ({
|
||||
...pagination.value,
|
||||
isLoading: isLoading.value,
|
||||
|
|
@ -894,6 +905,18 @@ const closeVideo = (): void => {
|
|||
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 = (
|
||||
url: string,
|
||||
useUrl: boolean = false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,53 @@
|
|||
type ResumeState = {
|
||||
time: number;
|
||||
shouldPlay: boolean;
|
||||
import { useLocalCache } from '~/utils/cache';
|
||||
|
||||
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 = (
|
||||
|
|
@ -31,38 +78,4 @@ const clampResumeTime = (
|
|||
|
||||
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<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 };
|
||||
export { clampResumeTime, clear, nearEnd, read, save };
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
it('clamp_seekable_range', async () => {
|
||||
const { clampResumeTime } = await import('~/utils/media');
|
||||
|
||||
it('clamp_seekable_range', () => {
|
||||
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);
|
||||
expect(media.clampResumeTime({ duration: Number.NaN, seekable }, 4)).toBe(12);
|
||||
expect(media.clampResumeTime({ duration: Number.NaN, seekable }, 22)).toBe(22);
|
||||
expect(media.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('store_resume_state', () => {
|
||||
media.save('item-1', 42);
|
||||
expect(media.read('item-1')).toBe(42);
|
||||
});
|
||||
|
||||
it('read_resume_state', async () => {
|
||||
const { readResumeState } = await import('~/utils/media');
|
||||
it('clear_resume_state', () => {
|
||||
media.save('item-1', 17);
|
||||
media.clear('item-1');
|
||||
|
||||
const target = {
|
||||
currentTime: 19,
|
||||
paused: false,
|
||||
ended: false,
|
||||
} as HTMLMediaElement;
|
||||
expect(cache.remove).toHaveBeenCalledWith('video:item-1');
|
||||
expect(media.read('item-1')).toBe(0);
|
||||
});
|
||||
|
||||
expect(readResumeState(target)).toEqual({ time: 19, shouldPlay: true });
|
||||
expect(readResumeState(null)).toEqual({ time: 0, shouldPlay: false });
|
||||
it('clear_resume_near_end', () => {
|
||||
expect(media.nearEnd({ currentTime: 97, duration: 100 })).toBe(true);
|
||||
expect(media.nearEnd({ currentTime: 80, duration: 100 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue