diff --git a/app/src/DashboardPage.js b/app/src/DashboardPage.js index 3faf7a5e..b5b9aac7 100644 --- a/app/src/DashboardPage.js +++ b/app/src/DashboardPage.js @@ -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 = ; + if (continueOnly && searchFiles && searchFiles.length === 0) { + files =

Nothing in progress. A video, audio file, ebook or comic you stop part way through will be here.

; + } return - + + + + +
{files}
} diff --git a/app/src/api.js b/app/src/api.js index 8b2a421b..527ad696 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -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); diff --git a/app/src/components/Common.js b/app/src/components/Common.js index f5e0d69a..118ce30e 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -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
+
+
+} + export function CardPoster({to, file, overlay}) { const navigate = useNavigate(); @@ -785,6 +797,7 @@ export function CardPoster({to, file, overlay}) { {imageLabel} poster {overlay} + ; return
@@ -802,6 +815,7 @@ export function CardPoster({to, file, overlay}) {
{imageLabel} +
} else if (!posterPath && to) { @@ -810,6 +824,7 @@ export function CardPoster({to, file, overlay}) {
navigate(to)}> {imageLabel} +
} diff --git a/app/src/components/FilePreview.js b/app/src/components/FilePreview.js index 55e583ca..a03791d0 100644 --- a/app/src/components/FilePreview.js +++ b/app/src/components/FilePreview.js @@ -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) { } +/** A
} + />)} + +

+ 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. +

+
Meta, body and actions
{[ diff --git a/app/src/components/VideoPlayer.js b/app/src/components/VideoPlayer.js index 106296f2..1903ccd9 100644 --- a/app/src/components/VideoPlayer.js +++ b/app/src/components/VideoPlayer.js @@ -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} @@ -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. */} diff --git a/app/src/components/ui/ui.css b/app/src/components/ui/ui.css index a1647676..588a4ed8 100644 --- a/app/src/components/ui/ui.css +++ b/app/src/components/ui/ui.css @@ -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 diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 84339fca..1c366282 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -943,33 +943,46 @@ 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); + // All and Continue share this hook; only the latest request may write, so a slow response for the + // previous mode cannot replace the current one. + const latestRequestRef = React.useRef(0); const localSearchFiles = async () => { + const requestId = ++latestRequestRef.current; setLoading(true); try { - let [file_groups, total] = await filesSearch( + // `inProgress` returns only the files the user is part way through. + let [file_groups] = await filesSearch( null, 12, null, null, null, [], false, - null, null, null, false, 'viewed'); - setSearchFiles(file_groups); + null, null, null, false, 'viewed', null, null, false, inProgress); + if (requestId === latestRequestRef.current) { + setSearchFiles(file_groups); + } } catch (e) { console.error(e); - toast({ - type: 'error', - title: 'Unexpected server response', - description: 'Could not get recent files', - time: 5000, - }); + if (requestId === latestRequestRef.current) { + toast({ + type: 'error', + title: 'Unexpected server response', + description: 'Could not get recent files', + time: 5000, + }); + } } finally { - setLoading(false); + if (requestId === latestRequestRef.current) { + setLoading(false); + } } } React.useEffect(() => { + // The other mode's files are not this mode's; show the placeholder until they arrive. + setSearchFiles(null); localSearchFiles(); - }, []); + }, [inProgress]); return {searchFiles, loading, fetchFiles: localSearchFiles}; } diff --git a/app/src/hooks/customHooks.test.js b/app/src/hooks/customHooks.test.js index 0652557a..8ec45f7b 100644 --- a/app/src/hooks/customHooks.test.js +++ b/app/src/hooks/customHooks.test.js @@ -2,11 +2,12 @@ import React from 'react'; import {act, renderHook} from '@testing-library/react'; import {render, screen} from '../test-utils'; import { - usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useSettings, useStatistics, useStatus, - useVideoExtras, + usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useSearchRecentFiles, useSettings, + useStatistics, useStatus, useVideoExtras, } from './customHooks'; import { - ApiDownError, getSettings, getStatistics, getStatus, getVideoComments, getVideoDescription, searchChannels, + ApiDownError, filesSearch, getSettings, getStatistics, getStatus, getVideoComments, getVideoDescription, + searchChannels, } from '../api'; import {getControllerStats} from '../api/controller'; import {QueryContext, StatusContext} from '../contexts/contexts'; @@ -48,6 +49,7 @@ jest.mock('../api', () => ({ getStatistics: jest.fn(), getStatus: jest.fn(), getSettings: jest.fn(), + filesSearch: jest.fn(), })); jest.mock('../api/controller', () => ({ @@ -685,3 +687,52 @@ describe('useSettings loaded', () => { expect(result.current.settings.media_directory).toBe('/media/wrolpi'); }); }); + +describe('useSearchRecentFiles', () => { + /** A filesSearch call the test resolves when it chooses. */ + const deferred = () => { + let resolve; + const promise = new Promise(r => resolve = r); + return {promise, resolve}; + }; + + beforeEach(() => { + filesSearch.mockReset(); + }); + + test('switching modes shows only the new mode\'s files, even if the old response arrives last', async () => { + const all = deferred(); + const continuing = deferred(); + filesSearch.mockReturnValueOnce(all.promise).mockReturnValueOnce(continuing.promise); + + const {result, rerender} = renderHook(({inProgress}) => useSearchRecentFiles(inProgress), + {initialProps: {inProgress: false}}); + // Continue, before All's response arrives. + rerender({inProgress: true}); + expect(filesSearch.mock.calls[1][15]).toBe(true); + + await act(async () => continuing.resolve([[{primary_path: 'started.mp4'}], 1])); + await act(async () => all.resolve([[{primary_path: 'started.mp4'}, {primary_path: 'finished.mp4'}], 2])); + + expect(result.current.searchFiles).toEqual([{primary_path: 'started.mp4'}]); + expect(result.current.loading).toBe(false); + }); + + test('switching modes clears the old files until the new ones arrive', async () => { + const continuing = deferred(); + filesSearch.mockResolvedValueOnce([[{primary_path: 'a.mp4'}], 1]).mockReturnValueOnce(continuing.promise); + + const {result, rerender} = renderHook(({inProgress}) => useSearchRecentFiles(inProgress), + {initialProps: {inProgress: false}}); + await act(async () => { + }); + expect(result.current.searchFiles).toEqual([{primary_path: 'a.mp4'}]); + + rerender({inProgress: true}); + // Not the All list, and not an empty list ("No results!"): nothing yet. + expect(result.current.searchFiles).toBeNull(); + + await act(async () => continuing.resolve([[], 0])); + expect(result.current.searchFiles).toEqual([]); + }); +}); diff --git a/app/src/hooks/useFileProgress.js b/app/src/hooks/useFileProgress.js new file mode 100644 index 00000000..a0b01296 --- /dev/null +++ b/app/src/hooks/useFileProgress.js @@ -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