feat: add resume for videos

This commit is contained in:
arabcoders 2026-05-15 18:24:52 +03:00
parent 734ef1c53c
commit 73b8d867a0
6 changed files with 284 additions and 85 deletions

View file

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

View file

@ -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();

View file

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

View file

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

View file

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

View file

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