mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Resume videos and audio where the user left off; show progress on cards
Videos and audio resume where the user left off, on the video page and in the file preview, and keep playing. `?t=` starts at an explicit time instead. useFileProgress saves how far the user is through a file: every 15 seconds while it changes, and a final save (a beacon, which survives the page closing) on pause, end, hiding the page, leaving it, or switching files. Each save is also kept in sessionStorage; on open the newer of that and the server's position wins, so coming straight back resumes exactly even if the final save is still in flight. Cards show a progress bar along the bottom of their poster. The dashboard's Recently Viewed Files gains All / Continue; Continue uses the new `in_progress` file search filter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bcbc42632d
commit
9db52da49c
14 changed files with 543 additions and 24 deletions
|
|
@ -5,6 +5,7 @@ import {
|
|||
PageContainer,
|
||||
RefreshHeader,
|
||||
SearchResultsInput,
|
||||
useLocalStorage,
|
||||
useTitle
|
||||
} from "./components/Common";
|
||||
import React, {useContext, useState} from "react";
|
||||
|
|
@ -14,6 +15,7 @@ import {DownloadMenu} from "./components/Download";
|
|||
import {
|
||||
ActionInput,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Divider,
|
||||
Header,
|
||||
Icon,
|
||||
|
|
@ -351,7 +353,14 @@ export function DashboardStatus() {
|
|||
}
|
||||
|
||||
function DashboardRecentFiles() {
|
||||
const {searchFiles, loading, fetchFiles} = useSearchRecentFiles();
|
||||
// Show only the files the user is part way through.
|
||||
const [continueOnly, setContinueOnly] = useLocalStorage('dashboard_recent_continue', false);
|
||||
const {searchFiles, loading, fetchFiles} = useSearchRecentFiles(continueOnly);
|
||||
|
||||
let files = <FileCards files={searchFiles} loading={loading}/>;
|
||||
if (continueOnly && searchFiles && searchFiles.length === 0) {
|
||||
files = <p>Nothing in progress. A video, audio file, ebook or comic you stop part way through will be here.</p>;
|
||||
}
|
||||
|
||||
return <Panel>
|
||||
<RefreshHeader
|
||||
|
|
@ -359,7 +368,15 @@ function DashboardRecentFiles() {
|
|||
popupContents='Fetch the most recent files again'
|
||||
onRefresh={fetchFiles}
|
||||
/>
|
||||
<FileCards files={searchFiles} loading={loading}/>
|
||||
<ButtonGroup>
|
||||
<Button size='small' variant={continueOnly ? 'default' : 'filled'} onClick={() => setContinueOnly(false)}>
|
||||
All
|
||||
</Button>
|
||||
<Button size='small' variant={continueOnly ? 'filled' : 'default'} onClick={() => setContinueOnly(true)}>
|
||||
Continue
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<div style={{marginTop: '1em'}}>{files}</div>
|
||||
</Panel>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1305,7 +1305,7 @@ export async function deleteDownload(downloadId) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep) {
|
||||
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep, inProgress) {
|
||||
const body = {search_str: searchStr, offset: parseInt(offset), limit: parseInt(limit), any_tag: anyTag};
|
||||
if (suffix) {
|
||||
body['suffix'] = suffix;
|
||||
|
|
@ -1340,6 +1340,9 @@ export async function filesSearch(offset, limit, searchStr, mimetypes, model, ta
|
|||
if (deep) {
|
||||
body['deep'] = true;
|
||||
}
|
||||
if (inProgress) {
|
||||
body['in_progress'] = true;
|
||||
}
|
||||
console.info('searching files', body);
|
||||
const response = await apiPost(`${API_URI}/files/search`, body);
|
||||
|
||||
|
|
|
|||
|
|
@ -757,6 +757,18 @@ export function findPosterPath(file) {
|
|||
}
|
||||
}
|
||||
|
||||
/** How far the user is through a file, as a bar along the bottom of its poster. A full bar is finished. */
|
||||
export function ViewProgress({progress}) {
|
||||
if (!(progress > 0)) {
|
||||
return null;
|
||||
}
|
||||
const percent = Math.round(Math.min(progress, 1) * 100);
|
||||
return <div className='wrolpi-view-progress' role='progressbar' aria-label='Viewing progress'
|
||||
aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent}>
|
||||
<div className='wrolpi-view-progress-bar' style={{width: `${percent}%`}}/>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function CardPoster({to, file, overlay}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -785,6 +797,7 @@ export function CardPoster({to, file, overlay}) {
|
|||
{imageLabel}
|
||||
<img alt='poster' src={posterPath}/>
|
||||
{overlay}
|
||||
<ViewProgress progress={file.progress}/>
|
||||
</>;
|
||||
|
||||
return <div className='wrolpi-card-poster'>
|
||||
|
|
@ -802,6 +815,7 @@ export function CardPoster({to, file, overlay}) {
|
|||
<div className='wrolpi-card-icon'>
|
||||
{imageLabel}
|
||||
<FileIcon file={file}/>
|
||||
<ViewProgress progress={file.progress}/>
|
||||
</div>
|
||||
</PreviewLink>
|
||||
} else if (!posterPath && to) {
|
||||
|
|
@ -810,6 +824,7 @@ export function CardPoster({to, file, overlay}) {
|
|||
<div className='wrolpi-card-icon' onClick={() => navigate(to)}>
|
||||
{imageLabel}
|
||||
<FileIcon file={file}/>
|
||||
<ViewProgress progress={file.progress}/>
|
||||
</div>
|
||||
</Link>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {CbzViewer} from "./CbzViewer";
|
|||
import {StlViewer} from "react-stl-viewer";
|
||||
import {Button, ButtonGroup, Header, Icon, IconButton, MediaGate, Menu, Modal, toast} from "./ui";
|
||||
import {useOneQuery} from "../hooks/customHooks";
|
||||
import {useMediaProgress} from "../hooks/useFileProgress";
|
||||
import {ShareButton} from "./Share";
|
||||
import {AddToPlaylistButton} from "./AddToPlaylist";
|
||||
import {pathDirectory} from "./FileBrowser";
|
||||
|
|
@ -157,6 +158,14 @@ function getImagePreviewModal(previewFile) {
|
|||
</React.Fragment>
|
||||
}
|
||||
|
||||
/** A <video> or <audio> which resumes where the user left off, and saves how far they got. */
|
||||
function PreviewMediaPlayer({as: Element, previewFile, path, style, children}) {
|
||||
const progressHandlers = useMediaProgress(path, previewFile);
|
||||
return <Element controls autoPlay={true} id="player" playsInline={true} style={style} {...progressHandlers}>
|
||||
{children}
|
||||
</Element>
|
||||
}
|
||||
|
||||
function getVideoPreviewModal(previewFile) {
|
||||
const url = getMediaPathURL(previewFile);
|
||||
const path = previewFile.primary_path ?? previewFile.path;
|
||||
|
|
@ -168,14 +177,10 @@ function getVideoPreviewModal(previewFile) {
|
|||
<Modal.Content>
|
||||
<Header as='h5'>{path['path']}</Header>
|
||||
<div className='preview-fit'>
|
||||
<video controls
|
||||
autoPlay={true}
|
||||
id="player"
|
||||
playsInline={true}
|
||||
style={{maxHeight: '100%', maxWidth: '100%'}}
|
||||
>
|
||||
<PreviewMediaPlayer as='video' previewFile={previewFile} path={path}
|
||||
style={{maxHeight: '100%', maxWidth: '100%'}}>
|
||||
<source src={url}/>
|
||||
</video>
|
||||
</PreviewMediaPlayer>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</React.Fragment>
|
||||
|
|
@ -192,14 +197,10 @@ function getAudioPreviewModal(previewFile) {
|
|||
{name}
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<audio controls
|
||||
autoPlay={true}
|
||||
id="player"
|
||||
playsInline={true}
|
||||
style={{width: '90%', maxWidth: '95%'}}
|
||||
>
|
||||
<PreviewMediaPlayer as='audio' previewFile={previewFile} path={path}
|
||||
style={{width: '90%', maxWidth: '95%'}}>
|
||||
<source src={url} type={type}/>
|
||||
</audio>
|
||||
</PreviewMediaPlayer>
|
||||
</Modal.Content>
|
||||
</React.Fragment>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import {
|
|||
toast,
|
||||
} from './ui';
|
||||
import {paletteColorNames} from '../themes/mantine';
|
||||
import {contrastingColor} from './Common';
|
||||
import {contrastingColor, ViewProgress} from './Common';
|
||||
import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors';
|
||||
import {IconMenu2} from '@tabler/icons-react';
|
||||
import {BookmarkAddForms, BookmarkMenuItems, BookmarkTable} from './Bookmarks';
|
||||
|
|
@ -1032,6 +1032,31 @@ export function ThemeSamplePage() {
|
|||
rather than upscaled.
|
||||
</p>
|
||||
|
||||
<Header as='h4'>Viewing progress</Header>
|
||||
<CardGroup>
|
||||
{[
|
||||
['Part way through', 0.4],
|
||||
['Nearly done', 0.9],
|
||||
['Finished', 1],
|
||||
['Not started', null],
|
||||
].map(([label, progress]) => <Card
|
||||
key={label}
|
||||
title={label}
|
||||
meta={progress ? `${Math.round(progress * 100)}%` : 'no bar'}
|
||||
color='blue'
|
||||
media={<div className='wrolpi-card-poster'>
|
||||
<div className='wrolpi-card-poster-frame'>
|
||||
<img alt='' src={samplePoster(1280, 720)}/>
|
||||
<ViewProgress progress={progress}/>
|
||||
</div>
|
||||
</div>}
|
||||
/>)}
|
||||
</CardGroup>
|
||||
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginTop: 10}}>
|
||||
How far the user is through a video, audio file, ebook or comic, along the bottom
|
||||
of its poster. A full bar is finished; a file not yet started has none.
|
||||
</p>
|
||||
|
||||
<Header as='h4'>Meta, body and actions</Header>
|
||||
<CardGroup>
|
||||
{[
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import {
|
|||
useTitle
|
||||
} from "./Common";
|
||||
import {VideoPlaceholder} from "./Placeholder";
|
||||
import {useChannel, useVideoCaptions, useVideoExtras} from "../hooks/customHooks";
|
||||
import {useChannel, useOneQuery, useVideoCaptions, useVideoExtras} from "../hooks/customHooks";
|
||||
import {useMediaProgress} from "../hooks/useFileProgress";
|
||||
import {Button, Grid, Header, Icon, Label, Loading, Panel, Placeholder, Tabs, TextInput} from "./ui";
|
||||
import {VideoCard} from "./Videos";
|
||||
import {TagsSelector} from "../Tags";
|
||||
|
|
@ -230,6 +231,12 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
video = videoFile.video;
|
||||
}
|
||||
|
||||
// `?t=120` starts at 2:00, rather than where the user left off.
|
||||
const [startTimeQuery] = useOneQuery('t');
|
||||
const startSeconds = startTimeQuery && Number.isFinite(parseFloat(startTimeQuery))
|
||||
? parseFloat(startTimeQuery) : null;
|
||||
const progressHandlers = useMediaProgress(videoFile?.primary_path, videoFile, {startSeconds});
|
||||
|
||||
// Seeks to the `seconds` on video player.
|
||||
const setVideoTime = (seconds) => {
|
||||
videoRef.current.currentTime = seconds;
|
||||
|
|
@ -456,6 +463,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
id="player"
|
||||
style={{width: '100%', marginTop: posterUrl ? '0.5em' : 0}}
|
||||
ref={videoRef}
|
||||
{...progressHandlers}
|
||||
>
|
||||
{videoSource}
|
||||
</audio>
|
||||
|
|
@ -467,6 +475,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
playsInline={true}
|
||||
style={{maxWidth: '100%'}}
|
||||
ref={videoRef}
|
||||
{...progressHandlers}
|
||||
>
|
||||
{videoSource}
|
||||
{/* Only WebVTT captions can be displayed. */}
|
||||
|
|
|
|||
|
|
@ -1675,6 +1675,24 @@ html[data-theme="amber"] .wrolpi-card-tag {
|
|||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/*
|
||||
* How far the user is through a file, along the bottom of its poster. Drawn from tokens beside the
|
||||
* image rather than inside it, like the tag mark, so night and amber color it themselves.
|
||||
*/
|
||||
.wrolpi-view-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 0.25rem;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.wrolpi-view-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Statistics */
|
||||
/*
|
||||
* A statistic carries its own spacing rather than relying on a parent to supply
|
||||
|
|
|
|||
|
|
@ -943,16 +943,17 @@ export const useChannels = () => {
|
|||
return [channels, total, metadata];
|
||||
}
|
||||
|
||||
export const useSearchRecentFiles = () => {
|
||||
export const useSearchRecentFiles = (inProgress = false) => {
|
||||
const [searchFiles, setSearchFiles] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const localSearchFiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// `inProgress` returns only the files the user is part way through.
|
||||
let [file_groups, total] = await filesSearch(
|
||||
null, 12, null, null, null, [], false,
|
||||
null, null, null, false, 'viewed');
|
||||
null, null, null, false, 'viewed', null, null, false, inProgress);
|
||||
setSearchFiles(file_groups);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
|
@ -969,7 +970,7 @@ export const useSearchRecentFiles = () => {
|
|||
|
||||
React.useEffect(() => {
|
||||
localSearchFiles();
|
||||
}, []);
|
||||
}, [inProgress]);
|
||||
|
||||
return {searchFiles, loading, fetchFiles: localSearchFiles};
|
||||
}
|
||||
|
|
|
|||
200
app/src/hooks/useFileProgress.js
Normal file
200
app/src/hooks/useFileProgress.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import React from "react";
|
||||
import {API_URI} from "../components/Vars";
|
||||
|
||||
/*
|
||||
* How far the user is through a file, and where to resume it (see wrolpi/files/progress.py).
|
||||
*
|
||||
* The server is the source of truth; its FileGroup JSON carries `progress` and `position`. Every save is
|
||||
* also written to sessionStorage, because the final save sent while leaving a page can still be in flight
|
||||
* when the user comes straight back, and the server would then answer with an older position. Whichever
|
||||
* of the two is newer wins.
|
||||
*/
|
||||
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
const CACHE_PREFIX = 'wrolpi-progress:';
|
||||
|
||||
// Mirrors wrolpi/files/progress.py, so a position the server would discard is not resumed from the cache.
|
||||
const FINISHED_PROGRESS = 0.95;
|
||||
const MINIMUM_PROGRESS = 0.01;
|
||||
const MINIMUM_SECONDS = 10;
|
||||
|
||||
function readCache(path) {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(CACHE_PREFIX + path);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(path, entry) {
|
||||
try {
|
||||
window.sessionStorage.setItem(CACHE_PREFIX + path, JSON.stringify(entry));
|
||||
} catch (e) {
|
||||
// Storage is unavailable (private mode, quota); the server still has the position.
|
||||
}
|
||||
}
|
||||
|
||||
/** Send progress to the server. A final save uses a beacon, which survives the page closing. */
|
||||
export function sendProgress(path, progress, position, final = false) {
|
||||
const url = `${API_URI}/files/progress`;
|
||||
const body = JSON.stringify({file: path, progress, position, final});
|
||||
if (final && navigator.sendBeacon) {
|
||||
if (navigator.sendBeacon(url, new Blob([body], {type: 'application/json'}))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
fetch(url, {method: 'POST', body, keepalive: true})
|
||||
.catch(e => console.error('Failed to save progress', e));
|
||||
}
|
||||
|
||||
export function isResumable(progress, position) {
|
||||
if (!position || progress === null || progress === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (progress >= FINISHED_PROGRESS || progress < MINIMUM_PROGRESS) {
|
||||
return false;
|
||||
}
|
||||
if (position.kind === 'time') {
|
||||
return position.seconds >= MINIMUM_SECONDS;
|
||||
}
|
||||
if (position.kind === 'page') {
|
||||
return position.page > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to resume `path`: the newer of the server's position (from `fileGroup`) and this tab's own.
|
||||
*
|
||||
* @returns {object|null} A position, or null to start from the beginning.
|
||||
*/
|
||||
export function getResumePosition(path, fileGroup) {
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
let best = null;
|
||||
let bestTime = -1;
|
||||
if (fileGroup && fileGroup.progress !== null && fileGroup.progress !== undefined) {
|
||||
// A finished file has no position; when it was finished is when it was viewed.
|
||||
bestTime = fileGroup.position?.updated_at ?? (Date.parse(fileGroup.viewed) || 0);
|
||||
best = {progress: fileGroup.progress, position: fileGroup.position};
|
||||
}
|
||||
const cached = readCache(path);
|
||||
if (cached && cached.updated_at > bestTime) {
|
||||
best = cached;
|
||||
}
|
||||
return best && isResumable(best.progress, best.position) ? best.position : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save how far the user is through `path`.
|
||||
*
|
||||
* The viewer calls `report({progress, position})` as the user moves through the file; that is only
|
||||
* remembered. It is sent every 15 seconds if it changed, and as a final save when `save(true)` is called
|
||||
* (pause, end), when the page is hidden or closed, and when `path` changes or the viewer unmounts.
|
||||
*
|
||||
* The last report is kept rather than read from the viewer at unmount, because by then React has detached
|
||||
* the viewer's elements.
|
||||
*/
|
||||
export function useFileProgress(path, {enabled = true} = {}) {
|
||||
const snapshotRef = React.useRef(null);
|
||||
const sentRef = React.useRef(null);
|
||||
|
||||
const report = React.useCallback((snapshot) => {
|
||||
snapshotRef.current = snapshot;
|
||||
}, []);
|
||||
|
||||
const save = React.useCallback((final = false) => {
|
||||
const snapshot = snapshotRef.current;
|
||||
if (!path || !enabled || !snapshot) {
|
||||
return;
|
||||
}
|
||||
const key = JSON.stringify(snapshot);
|
||||
if (!final && key === sentRef.current) {
|
||||
return;
|
||||
}
|
||||
sentRef.current = key;
|
||||
|
||||
const updated_at = Date.now();
|
||||
const position = snapshot.position ? {...snapshot.position, updated_at} : null;
|
||||
writeCache(path, {progress: snapshot.progress, position, updated_at});
|
||||
sendProgress(path, snapshot.progress, position, final);
|
||||
}, [path, enabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!path || !enabled) {
|
||||
return;
|
||||
}
|
||||
// A new file starts with nothing to save.
|
||||
snapshotRef.current = null;
|
||||
sentRef.current = null;
|
||||
|
||||
const interval = setInterval(() => save(false), HEARTBEAT_MS);
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
save(true);
|
||||
}
|
||||
};
|
||||
const onPageHide = () => save(true);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
window.removeEventListener('pagehide', onPageHide);
|
||||
save(true);
|
||||
};
|
||||
}, [path, enabled, save]);
|
||||
|
||||
return {report, save};
|
||||
}
|
||||
|
||||
/**
|
||||
* Track and resume a <video> or <audio>. Spread the returned handlers onto the element.
|
||||
*
|
||||
* @param path The FileGroup's primary path, relative to the media directory.
|
||||
* @param fileGroup The FileGroup JSON, which holds the server's position.
|
||||
* @param startSeconds An explicit start (e.g. `?t=`), which wins over any saved position.
|
||||
*/
|
||||
export function useMediaProgress(path, fileGroup, {startSeconds = null, enabled = true} = {}) {
|
||||
const {report, save} = useFileProgress(path, {enabled});
|
||||
// The path whose position was restored; the element fires `loadedmetadata` again on some seeks.
|
||||
const restoredRef = React.useRef(null);
|
||||
|
||||
const onLoadedMetadata = (e) => {
|
||||
if (!enabled || !path || restoredRef.current === path) {
|
||||
return;
|
||||
}
|
||||
restoredRef.current = path;
|
||||
const media = e.currentTarget;
|
||||
let seconds = startSeconds;
|
||||
if (seconds === null || seconds === undefined) {
|
||||
const position = getResumePosition(path, fileGroup);
|
||||
seconds = position && position.kind === 'time' ? position.seconds : null;
|
||||
}
|
||||
if (seconds && Number.isFinite(media.duration) && seconds < media.duration) {
|
||||
media.currentTime = seconds;
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeUpdate = (e) => {
|
||||
const media = e.currentTarget;
|
||||
if (!Number.isFinite(media.duration) || media.duration <= 0) {
|
||||
return;
|
||||
}
|
||||
report({
|
||||
progress: media.currentTime / media.duration,
|
||||
position: {kind: 'time', seconds: media.currentTime},
|
||||
});
|
||||
};
|
||||
|
||||
const onPause = () => save(true);
|
||||
|
||||
const onEnded = () => {
|
||||
report({progress: 1, position: null});
|
||||
save(true);
|
||||
};
|
||||
|
||||
return {onLoadedMetadata, onTimeUpdate, onPause, onEnded};
|
||||
}
|
||||
198
app/src/hooks/useFileProgress.test.js
Normal file
198
app/src/hooks/useFileProgress.test.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import React from 'react';
|
||||
import {act, render, renderHook} from '@testing-library/react';
|
||||
import {getResumePosition, useFileProgress, useMediaProgress} from './useFileProgress';
|
||||
import {ViewProgress} from '../components/Common';
|
||||
|
||||
const PATH = 'videos/movie.mp4';
|
||||
const CACHE_KEY = `wrolpi-progress:${PATH}`;
|
||||
|
||||
/** The bodies of the progress saves sent by beacon or fetch, in order. */
|
||||
const sent = () => [
|
||||
...navigator.sendBeacon.mock.calls.map(([, blob]) => blob.__body),
|
||||
...global.fetch.mock.calls.map(([, init]) => init.body),
|
||||
].map(i => JSON.parse(i));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
window.sessionStorage.clear();
|
||||
navigator.sendBeacon = jest.fn(() => true);
|
||||
global.fetch = jest.fn(() => Promise.resolve({ok: true}));
|
||||
// jsdom's Blob cannot be read synchronously; keep the body so a test can check it.
|
||||
global.Blob = class {
|
||||
constructor(parts) {
|
||||
this.__body = parts.join('');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getResumePosition', () => {
|
||||
const position = {kind: 'time', seconds: 300, updated_at: 1000};
|
||||
|
||||
test('resumes from the server', () => {
|
||||
expect(getResumePosition(PATH, {progress: 0.5, position})).toEqual(position);
|
||||
});
|
||||
|
||||
test('a newer position from this tab wins over the server', () => {
|
||||
const cached = {kind: 'time', seconds: 400, updated_at: 2000};
|
||||
window.sessionStorage.setItem(CACHE_KEY, JSON.stringify({progress: 0.6, position: cached, updated_at: 2000}));
|
||||
expect(getResumePosition(PATH, {progress: 0.5, position})).toEqual(cached);
|
||||
});
|
||||
|
||||
test('a newer position from the server wins over this tab', () => {
|
||||
const cached = {kind: 'time', seconds: 400, updated_at: 500};
|
||||
window.sessionStorage.setItem(CACHE_KEY, JSON.stringify({progress: 0.6, position: cached, updated_at: 500}));
|
||||
expect(getResumePosition(PATH, {progress: 0.5, position})).toEqual(position);
|
||||
});
|
||||
|
||||
test('a file finished on another device starts over', () => {
|
||||
const cached = {kind: 'time', seconds: 400, updated_at: 500};
|
||||
window.sessionStorage.setItem(CACHE_KEY, JSON.stringify({progress: 0.6, position: cached, updated_at: 500}));
|
||||
const fileGroup = {progress: 1, position: null, viewed: new Date(9000).toISOString()};
|
||||
expect(getResumePosition(PATH, fileGroup)).toBeNull();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['finished', {progress: 1, position: null}],
|
||||
['too early', {progress: 0.2, position: {kind: 'time', seconds: 5}}],
|
||||
['first page', {progress: 0.1, position: {kind: 'page', page: 0}}],
|
||||
['never viewed', {progress: null, position: null}],
|
||||
['no file group', null],
|
||||
])('%s starts from the beginning', (_, fileGroup) => {
|
||||
expect(getResumePosition(PATH, fileGroup)).toBeNull();
|
||||
});
|
||||
|
||||
test('unreadable storage falls back to the server', () => {
|
||||
const getItem = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('denied');
|
||||
});
|
||||
try {
|
||||
expect(getResumePosition(PATH, {progress: 0.5, position})).toEqual(position);
|
||||
} finally {
|
||||
getItem.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('useFileProgress', () => {
|
||||
const snapshot = {progress: 0.25, position: {kind: 'time', seconds: 150}};
|
||||
|
||||
test('a report is sent by the heartbeat, once while it is unchanged', () => {
|
||||
const {result} = renderHook(() => useFileProgress(PATH));
|
||||
act(() => result.current.report(snapshot));
|
||||
expect(sent()).toEqual([]);
|
||||
|
||||
act(() => jest.advanceTimersByTime(15_000));
|
||||
act(() => jest.advanceTimersByTime(15_000));
|
||||
expect(sent()).toEqual([{
|
||||
file: PATH, progress: 0.25, final: false,
|
||||
position: {kind: 'time', seconds: 150, updated_at: expect.any(Number)},
|
||||
}]);
|
||||
// Heartbeats use fetch; only a final save needs a beacon.
|
||||
expect(navigator.sendBeacon).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(window.sessionStorage.getItem(CACHE_KEY)).position.seconds).toBe(150);
|
||||
});
|
||||
|
||||
test('leaving the page sends a final save by beacon', () => {
|
||||
const {result, unmount} = renderHook(() => useFileProgress(PATH));
|
||||
act(() => result.current.report(snapshot));
|
||||
unmount();
|
||||
expect(navigator.sendBeacon).toHaveBeenCalledTimes(1);
|
||||
expect(sent()[0]).toMatchObject({file: PATH, progress: 0.25, final: true});
|
||||
});
|
||||
|
||||
test('hiding the page sends a final save', () => {
|
||||
const {result} = renderHook(() => useFileProgress(PATH));
|
||||
act(() => result.current.report(snapshot));
|
||||
jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
|
||||
act(() => document.dispatchEvent(new Event('visibilitychange')));
|
||||
expect(sent()).toEqual([expect.objectContaining({final: true})]);
|
||||
});
|
||||
|
||||
test('a new file saves the old one under its own path, and starts empty', () => {
|
||||
const {result, rerender} = renderHook(({path}) => useFileProgress(path), {initialProps: {path: PATH}});
|
||||
act(() => result.current.report(snapshot));
|
||||
rerender({path: 'videos/next.mp4'});
|
||||
expect(sent()).toEqual([expect.objectContaining({file: PATH, final: true})]);
|
||||
|
||||
// Nothing has been reported for the next file.
|
||||
act(() => jest.advanceTimersByTime(15_000));
|
||||
expect(sent()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('nothing is sent until something is reported', () => {
|
||||
const {unmount} = renderHook(() => useFileProgress(PATH));
|
||||
act(() => jest.advanceTimersByTime(60_000));
|
||||
unmount();
|
||||
expect(sent()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMediaProgress', () => {
|
||||
/** A stand-in for the <video> element the handlers receive as `e.currentTarget`. */
|
||||
const media = (props) => ({currentTarget: {duration: 600, currentTime: 0, ...props}});
|
||||
|
||||
test('resumes the saved position when the metadata loads', () => {
|
||||
const fileGroup = {progress: 0.5, position: {kind: 'time', seconds: 300}};
|
||||
const {result} = renderHook(() => useMediaProgress(PATH, fileGroup));
|
||||
const event = media();
|
||||
act(() => result.current.onLoadedMetadata(event));
|
||||
expect(event.currentTarget.currentTime).toBe(300);
|
||||
|
||||
// Only once; a later `loadedmetadata` does not jump back.
|
||||
event.currentTarget.currentTime = 450;
|
||||
act(() => result.current.onLoadedMetadata(event));
|
||||
expect(event.currentTarget.currentTime).toBe(450);
|
||||
});
|
||||
|
||||
test('an explicit start time wins over the saved position', () => {
|
||||
const fileGroup = {progress: 0.5, position: {kind: 'time', seconds: 300}};
|
||||
const {result} = renderHook(() => useMediaProgress(PATH, fileGroup, {startSeconds: 42}));
|
||||
const event = media();
|
||||
act(() => result.current.onLoadedMetadata(event));
|
||||
expect(event.currentTarget.currentTime).toBe(42);
|
||||
});
|
||||
|
||||
test('a finished file starts from the beginning', () => {
|
||||
const {result} = renderHook(() => useMediaProgress(PATH, {progress: 1, position: null}));
|
||||
const event = media();
|
||||
act(() => result.current.onLoadedMetadata(event));
|
||||
expect(event.currentTarget.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
test('pausing saves where the user is; the end saves it finished', () => {
|
||||
const {result} = renderHook(() => useMediaProgress(PATH, null));
|
||||
act(() => result.current.onTimeUpdate(media({currentTime: 120})));
|
||||
act(() => result.current.onPause());
|
||||
act(() => result.current.onEnded());
|
||||
expect(sent()).toEqual([
|
||||
expect.objectContaining({progress: 0.2, position: expect.objectContaining({seconds: 120}), final: true}),
|
||||
expect.objectContaining({progress: 1, position: null, final: true}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('nothing is saved before the duration is known', () => {
|
||||
const {result} = renderHook(() => useMediaProgress(PATH, null));
|
||||
act(() => result.current.onTimeUpdate(media({duration: NaN, currentTime: 3})));
|
||||
act(() => result.current.onPause());
|
||||
expect(sent()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewProgress', () => {
|
||||
test.each([[0], [null], [undefined]])('no bar for progress %s', (progress) => {
|
||||
const {container} = render(<ViewProgress progress={progress}/>);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
test('the bar is as wide as the progress', () => {
|
||||
const {getByRole} = render(<ViewProgress progress={0.4}/>);
|
||||
const bar = getByRole('progressbar');
|
||||
expect(bar).toHaveAttribute('aria-valuenow', '40');
|
||||
expect(bar.firstChild).toHaveStyle({width: '40%'});
|
||||
});
|
||||
});
|
||||
|
|
@ -189,7 +189,7 @@ async def post_search_files(request: Request, body: schema.FilesSearchRequest):
|
|||
file_groups, total = await asyncio.to_thread(
|
||||
lib.search_files, request.ctx.session, body.search_str, body.limit, body.offset, body.mimetypes, body.model,
|
||||
body.tag_names, body.headline, body.months, body.from_year, body.to_year,
|
||||
body.any_tag, body.order, body.url, body.suffix, body.path, body.deep)
|
||||
body.any_tag, body.order, body.url, body.suffix, body.path, body.deep, body.in_progress)
|
||||
return json_response(dict(file_groups=file_groups, totals=dict(file_groups=total)))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1234,7 +1234,8 @@ def count_file_groups(session: Session, statement: str, params: dict) -> int:
|
|||
def search_files(session: Session, search_str: str, limit: int, offset: int, mimetypes: List[str] = None, model: str = None,
|
||||
tag_names: List[str] = None, headline: bool = False, months: List[int] = None,
|
||||
from_year: int = None, to_year: int = None, any_tag: bool = False, order: str = None,
|
||||
url: str = None, suffix: str = None, path: str = None, deep: bool = False) -> \
|
||||
url: str = None, suffix: str = None, path: str = None, deep: bool = False,
|
||||
in_progress: bool = False) -> \
|
||||
Tuple[List[dict], int]:
|
||||
"""Search the FileGroup table.
|
||||
|
||||
|
|
@ -1257,6 +1258,7 @@ def search_files(session: Session, search_str: str, limit: int, offset: int, mim
|
|||
@param suffix: Only return files whose primary file has this suffix (e.g. ".bin"), case-insensitive.
|
||||
@param path: Filter by primary_path using case-insensitive partial match (ILIKE).
|
||||
@param deep: Search all text including captions/body (d_text); slower but more thorough.
|
||||
@param in_progress: Only files the user is part way through (see wrolpi.files.progress).
|
||||
"""
|
||||
from wrolpi import fts
|
||||
|
||||
|
|
@ -1307,6 +1309,9 @@ def search_files(session: Session, search_str: str, limit: int, offset: int, mim
|
|||
params['path_filter'] = f'%{path}%'
|
||||
wheres.append('fg.primary_path LIKE :path_filter')
|
||||
|
||||
if in_progress:
|
||||
wheres.append('fg.progress > 0 AND fg.progress < 1')
|
||||
|
||||
if fts_search and headline:
|
||||
# b/c/d headlines come from FTS5 snippets (computed in the fts subquery); the title
|
||||
# headline is computed in Python by `handle_file_group_search_results` (title is not an
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class FilesSearchRequest:
|
|||
suffix: Optional[str] = None # Filter by primary file suffix (e.g. ".bin"), case-insensitive exact match
|
||||
path: Optional[str] = None # Filter by primary_path (case-insensitive partial match)
|
||||
deep: bool = False # When True, search all text including captions/body (d_text)
|
||||
in_progress: bool = False # Only files the user is part way through (0 < progress < 1)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.any_tag and self.tag_names:
|
||||
|
|
|
|||
|
|
@ -433,3 +433,29 @@ async def test_progress_symlink_into_ignored_directory(test_session, async_clien
|
|||
await async_client.post('/api/files/file', content=json.dumps(dict(file='videos/link.mp4')))
|
||||
await await_background_tasks()
|
||||
assert test_session.query(FileGroup).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_in_progress(test_session, async_client, test_directory, make_files_structure):
|
||||
"""Search can return only files the user is part way through, most recently viewed first."""
|
||||
started, finished, unviewed, older = make_files_structure(
|
||||
['started.mp4', 'finished.mp4', 'unviewed.mp4', 'older.mp4'])
|
||||
now = dates.now()
|
||||
for path, viewed, progress in ((started, now, 0.4), (finished, now, 1.0), (unviewed, None, None),
|
||||
(older, now - timedelta(days=1), 0.2)):
|
||||
fg = FileGroup.from_paths(test_session, path)
|
||||
fg.viewed = viewed
|
||||
fg.progress = progress
|
||||
test_session.commit()
|
||||
|
||||
body = dict(order='viewed', in_progress=True)
|
||||
request, response = await async_client.post('/api/files/search', content=json.dumps(body))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
file_groups = response.json['file_groups']
|
||||
assert [i['primary_path'] for i in file_groups] == ['started.mp4', 'older.mp4']
|
||||
assert [i['progress'] for i in file_groups] == [0.4, 0.2]
|
||||
assert response.json['totals']['file_groups'] == 2
|
||||
|
||||
# Without the filter every viewed file is returned.
|
||||
request, response = await async_client.post('/api/files/search', content=json.dumps(dict(order='viewed')))
|
||||
assert {i['primary_path'] for i in response.json['file_groups']} == {'started.mp4', 'finished.mp4', 'older.mp4'}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue