@samitouri / QOSami-HFS / commits / 1e4f8dad

?autoplay

Massimo Melina committed Oct 27, 2024 at 00:55 UTC 1e4f8dade315e696bfe61cdc36fde40f82410a4f
3 files changed +72 -12
README.md
+1
@@ -127,6 +127,7 @@ If your language is missing, please consider [translating yourself](https://gith
127 - Appending `?login=USER:PASSWORD` will automatically log in the browser
128 - Appending `?overwrite` on uploads, will override the dont_overwrite_uploading configuration, provided you also have delete permission
129 - Appending `?search=PATTERN` will trigger search at start
130 +- Appending `?autoplay=shuffle` will trigger show & play; `?autoplay` will not shuffle, but also will not start until the list is complete
131 - Right-click on "check for updates" will let you input a URL of a version to install
132 - Shift+click on a file will show & play
133 - Type the name of a file/folder to focus it, and ctrl+backspace to go to parent folder
frontend/src/show.ts
+44 -10
@@ -20,10 +20,11 @@ enum ZoomMode {
20 contain, // leave this as last
21 }
22
23 -export function fileShow(entry: DirEntry, { startPlaying=false } = {}) {
23 +export function fileShow(entry: DirEntry, { startPlaying=false, startShuffle=false } = {}) {
24 let escOnce = false
25 let onClose: any
26 let firstUri: string
27 + let playMsgOnce = true
28 const { close } = newDialog({
29 noFrame: true,
30 className: 'file-show',
@@ -42,7 +43,8 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) {
43 const moving = useRef(0)
44 const lastGood = useRef(entry)
45 const [mode, setMode] = useState(ZoomMode.contain)
45 - const [shuffle, setShuffle] = useState<undefined|DirList>()
46 + const [shuffle, setShuffle] = useState<undefined | DirList>()
47 + useEffect(() => toggleShuffle(startShuffle), [])
48 // shuffle the rest of the list as we continue getting entries, leaving intact the part we've already played/being through
49 const shuffleIdx = useMemo(() => shuffle?.findIndex(x => x.n === cur.n), [cur])
50 useEffect(() => subscribeKey(state, 'list', list => {
@@ -102,8 +104,11 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) {
104
105 const { auto_play_seconds } = useSnapState()
106 const [autoPlaying, setAutoPlaying] = useState(startPlaying)
107 + function getShowElement() {
108 + return containerRef.current?.querySelector('.showing') // like this, we don't require component to forward ref (easier for plugins)
109 + }
110 useEffect(() => {
106 - const showElement = containerRef.current?.querySelector('.showing') // like this, we don't require component to forward ref (easier for plugins)
111 + const showElement = getShowElement()
112 if (!autoPlaying || !showElement) return
113 if (showElement instanceof HTMLMediaElement) {
114 showElement.play().catch(curFailed)
@@ -120,6 +125,12 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) {
125 const {t} = useI18N()
126 const autoPlaySecondsLabel = t('autoplay_seconds', "Seconds to wait on images")
127 const folder = dirname(cur.n)
128 + const failOnce = useRef<typeof cur>()
129 + useEffect(() => {
130 + if (component || failOnce.current === cur) return
131 + curFailed()
132 + failOnce.current = cur
133 + }, [cur, component])
134 return h(FlexV, {
135 gap: 0,
136 alignItems: 'stretch',
@@ -157,7 +168,7 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) {
168 'open','delete',
169 { id: 'zoom', icon: 'zoom', label: t`Switch zoom mode`, onClick: switchZoomMode },
170 { id: 'fullscreen', icon: 'fullscreen', label: t`Full screen`, onClick: toggleFullScreen },
160 - { id: 'shuffle', icon: 'shuffle', label: t`Shuffle`, toggled: Boolean(shuffle), onClick: toggleShuffle },
171 + { id: 'shuffle', icon: 'shuffle', label: t`Shuffle`, toggled: Boolean(shuffle), onClick: () => toggleShuffle() },
172 { id: 'repeat', icon: 'repeat', label: t`Repeat`, toggled: repeat, onClick: toggleRepeat },
173 ])),
174 iconBtn('close', close),
@@ -170,8 +181,11 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) {
181 h('div', {}, cur.name),
182 t`Loading failed`
183 ) : h('div', { className: 'showing-container', ref: containerRef },
173 - h('div', { className: 'cover ' + (cover ? '' : 'none'), style: { backgroundImage: cover && `url("${cover}")` } }),
174 - h(component || Fragment, {
184 + h('div', {
185 + className: 'cover ' + (cover ? '' : 'none'),
186 + style: { backgroundImage: cover && `url("${cover}")` }
187 + }),
188 + component && h(component, {
189 src: cur.uri,
190 className: 'showing',
191 onLoad() {
@@ -222,7 +236,27 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) {
236
237 function goNext() { go(+1) }
238
225 - function curFailed() {
239 + function curFailed(err?: Error) {
240 + console.debug(err)
241 + if (err?.name === 'NotAllowedError') { // browser won't allow automatic audio playing without user interaction
242 + if (!playMsgOnce) return
243 + playMsgOnce = false
244 + const el = getShowElement()
245 + if (!(el instanceof HTMLMediaElement)) return
246 + const dlg = newDialog({ // so we offer
247 + onClose: () => playMsgOnce = true,
248 + Content: () => h(Btn, {
249 + autoFocus: true,
250 + icon: 'play',
251 + label: "Click here to play",
252 + onClick: () => {
253 + el.play().catch(curFailed)
254 + dlg.close()
255 + }
256 + })
257 + })
258 + return
259 + }
260 const mediaError = (document.querySelector('.showing-container .showing') as any)?.error?.code // only presenti in video/audio elements
261 if (mediaError === 2) return // happens when chrome fails to fetch cover for videos. We don't skip the file for this reason. Tested on chrome129/windows
262 if (cur !== lastGood.current)
@@ -231,7 +265,7 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) {
265 setFailed(cur.n)
266 }
267
234 - function go(dir?: number, from=cur) {
268 + function go(dir=1, from=cur) {
269 if (dir)
270 moving.current = dir
271 let e = from
@@ -277,8 +311,8 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) {
311 setMode(x => x ? x - 1 : ZoomMode.contain)
312 }
313
280 - function toggleShuffle() {
281 - setShuffle(x => x ? undefined : _.shuffle(state.list))
314 + function toggleShuffle(force?: boolean) {
315 + setShuffle(x => (force ?? !x) ? _.shuffle(state.list) : undefined)
316 }
317
318 function toggleRepeat() {
frontend/src/useFetchList.ts
+27 -2
@@ -11,6 +11,7 @@ import { hfsEvent, HTTP_MESSAGES, HTTP_METHOD_NOT_ALLOWED, HTTP_UNAUTHORIZED, LI
11 import { t } from './i18n'
12 import { useLocation, useNavigate } from 'react-router-dom'
13 import { closeLoginDialog } from './login'
14 +import { fileShow, getShowComponent } from './show'
15
16 export function usePath() {
17 return useLocation().pathname
@@ -20,6 +21,8 @@ export function usePath() {
21 setTimeout(() => // wait, urlParams is defined at top level
22 state.remoteSearch = urlParams.search || '')
23
24 +let autoPlayOnce: string | undefined = urlParams.autoplay // this will be consumed, so to only act once
25 +
26 export default function useFetchList() {
27 const snap = useSnapState()
28 const uri = usePath() // this api can still work removing the initial slash, but then we'll have a mixed situation that will require plugins an extra effort
@@ -55,17 +58,32 @@ export default function useFetchList() {
58 state.loading = true
59 state.error = undefined
60 state.props = undefined
61 + // while 'play' needs to wait for the whole list to be available (and sorted) to proceed in order, 'playShuffle' can start right away, and it's important it does because a ?search may take long
62 + let play = false
63 + let playShuffle = false
64 // buffering entries is necessary against burst of events that will hang the browser
65 const buffer: DirList = []
66 const flush = () => {
67 const chunk = buffer.splice(0, Infinity)
62 - if (chunk.length)
63 - state.list = sort([...state.list, ...chunk])
68 + if (!chunk.length) return
69 + state.list = sort([...state.list, ...chunk])
70 + if (playShuffle) // find first proper file, and play it
71 + for (const x of chunk)
72 + if (getShowComponent(x)) {
73 + fileShow(x, { startPlaying: true, startShuffle: true })
74 + playShuffle = false
75 + break
76 + }
77 }
78 const timer = setInterval(flush, 1000)
79 const src = apiEvents('get_file_list', params, (type, data) => {
80 if (!isMounted()) return
81 switch (type) {
82 + case 'connected':
83 + if (autoPlayOnce === '') play = true
84 + if (autoPlayOnce === 'shuffle') playShuffle = true
85 + autoPlayOnce = undefined
86 + return
87 case 'error':
88 state.stopSearch?.()
89 state.error = t`connection error`
@@ -75,6 +93,13 @@ export default function useFetchList() {
93 flush()
94 state.stopSearch?.()
95 state.loading = false
96 + if (play)
97 + for (const x of state.list)
98 + if (getShowComponent(x)) {
99 + fileShow(x, { startPlaying: true })
100 + play = false
101 + break
102 + }
103 return
104 case 'msg':
105 const showLogin = location.hash === '#LOGIN'