main
ts 419 lines 19.7 KB
Raw
1 import { DirEntry, DirList, ext2type, state, useSnapState } from './state'
2 import { createElement as h, forwardRef, Fragment, useEffect, useMemo, useRef, useState } from 'react'
3 import {
4 basename, dirname, domOn, hfsEvent, hIcon, isMac, newDialog, pathEncode, restartAnimation, useStateMounted,
5 isNumeric, safeDecodeURIComponent,
6 } from './misc'
7 import { useEventListener, useWindowSize } from 'usehooks-ts'
8 import { EntryDetails, useMidnight } from './BrowseFiles'
9 import { Btn, FlexV, iconBtn, Spinner } from './components'
10 import { openFileMenu } from './fileMenu'
11 import { alertDialog, toast } from './dialog'
12 import _ from 'lodash'
13 import { getId3Tags } from './id3'
14 import i18n from './i18n'
15 const { t, useI18N } = i18n
16
17 enum ZoomMode {
18 fullWidth,
19 freeY,
20 contain, // leave this as last
21 }
22
23 // return falsy if entry is not supported
24 export function fileShow(entry: DirEntry, { startPlaying=false, startShuffle=false } = {}) {
25 if (!getShowComponent(entry))
26 return
27 let escOnce = false
28 let onClose: any
29 let firstUri: string
30 let playMsgOnce = true
31 let justOpen = true
32 const { close } = newDialog({
33 noFrame: true,
34 className: 'file-show',
35 onClose() {
36 onClose?.()
37 },
38 Content() {
39 const { uri } = useSnapState()
40 useEffect(() => {
41 if (uri === firstUri) return
42 firstUri ??= uri // init
43 if (firstUri !== uri) // user must have clicked the folder link inside file-menu (which happens only for search results)
44 close()
45 }, [uri])
46 const [cur, setCur, getCur] = useStateMounted(entry)
47 const moving = useRef(0)
48 const lastGood = useRef(entry)
49 const [mode, setMode] = useState(ZoomMode.contain)
50 const [shuffle, setShuffle] = useState<undefined | DirList>()
51 useEffect(() => toggleShuffle(startShuffle), [])
52 const shufflePlayed = useRef(0) // keep track of how many entries of the shuffle list we played
53 if (!shuffle) shufflePlayed.current = 0
54 const [repeat, setRepeat, getRepeat] = useStateMounted(false)
55 const [cover, setCover] = useState('')
56 useEffect(() => {
57 if (shuffle)
58 goTo(shuffle[0])
59 }, [Boolean(shuffle)])
60 useEventListener('keydown', ({ key }) => {
61 if (key === 'Escape') {
62 if (escOnce)
63 return close()
64 escOnce = true
65 onClose = toast(t('esc_again', "Press ESC twice to close")).close
66 return
67 }
68 escOnce = false
69 if (key === 'ArrowLeft') return goPrev()
70 if (key === 'ArrowRight') return goNext()
71 if (key === 'ArrowDown') return scrollY(1)
72 if (key === 'ArrowUp') return scrollY(-1)
73 if (key === 'd') return location.href = cur.uri + '?dl'
74 if (key === 'z') return switchZoomMode()
75 if (key === 'f') return toggleFullScreen()
76 if (key === 's') return toggleShuffle()
77 if (key === 'r') return toggleRepeat()
78 if (key === 'a') return toggleAutoPlay()
79 if (key === ' ') {
80 const sel = state.selected
81 if (sel[cur.uri])
82 delete sel[cur.uri]
83 else
84 sel[cur.uri] = true
85 state.showFilter = true
86 return
87 }
88 })
89 const [showNav, setShowNav] = useState(false)
90 const component = useMemo(() => getShowComponent(cur), [cur])
91 const isAudio = component === Audio
92 useEffect(() => setShowNav(isAudio), [isAudio])
93 const timerRef = useRef(0)
94 const navClass = 'nav' + (showNav ? '' : ' nav-hidden')
95
96 const [loading, setLoading] = useState(false)
97 const [failed, setFailed] = useState<false | string>(false)
98 const containerRef = useRef<HTMLDivElement>()
99 const mainRef = useRef<HTMLDivElement>()
100 useEffect(() => { scrollY(-1E9) }, [cur])
101
102 const [tags, setTags] = useState<any>()
103 useEffect(() => setTags(undefined), [cur]) // reset
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(() => {
111 const showElement = getShowElement()
112 try {
113 if (!autoPlaying && !justOpen || !showElement) return
114 } finally {
115 justOpen = false
116 }
117 if (showElement instanceof HTMLMediaElement) {
118 showElement.play().catch(playFailed)
119 return domOn('ended', goNext, { target: showElement })
120 }
121 if (!autoPlaying) return // we reached here because of the justOpen, but we are not interested in images
122 // we are supposedly showing an image
123 const h = setTimeout(goNext, state.auto_play_seconds * 1000)
124 return () => clearTimeout(h)
125 }, [autoPlaying, cur])
126 const {mediaSession} = navigator
127 mediaSession?.setActionHandler('nexttrack', goNext)
128 mediaSession?.setActionHandler('previoustrack', goPrev)
129
130 const {t} = useI18N()
131 const autoPlaySecondsLabel = t('autoplay_seconds', "Seconds to wait on images")
132 const folder = dirname(cur.n)
133 const failOnce = useRef<typeof cur>()
134 useEffect(() => {
135 if (component || failOnce.current === cur) return
136 onError()
137 failOnce.current = cur
138 }, [cur, component])
139 return h(FlexV, {
140 gap: 0,
141 alignItems: 'stretch',
142 className: isAudio ? undefined : ZoomMode[mode], // we don't want zoom on audio
143 props: {
144 role: 'dialog',
145 onMouseMove() {
146 if (isAudio) return
147 setShowNav(true)
148 clearTimeout(timerRef.current)
149 timerRef.current = +setTimeout(() => setShowNav(false), 1_000)
150 }
151 }
152 },
153 h('div', { className: 'bar' },
154 h('div', { className: 'filename' }, h('span', { className: 'folder' }, folder), cur.n.slice(folder.length)),
155 cur.comment && h('div', { className: 'entry-comment' }, cur.comment),
156 h('div', { className: 'controls' }, // keep on same row
157 h(EntryDetails, { entry: cur, midnight: useMidnight() }),
158 useWindowSize().width > 800 && iconBtn('?', showHelp),
159 h('div', {}, // fuse buttons
160 h(Btn, {
161 className: 'small',
162 label: t`Auto-play`,
163 toggled: autoPlaying,
164 onClick: toggleAutoPlay,
165 }),
166 autoPlaying && h(Btn, {
167 className: 'small',
168 label: String(auto_play_seconds),
169 title: autoPlaySecondsLabel,
170 onClick: configAutoPlay,
171 }),
172 ),
173 iconBtn('menu', ev => openFileMenu(cur, ev, [
174 'open', 'delete',
175 { id: 'zoom', icon: 'zoom', label: t`Switch zoom mode`, onClick: switchZoomMode },
176 { id: 'fullscreen', icon: 'fullscreen', label: t`Full screen`, onClick: toggleFullScreen },
177 { id: 'shuffle', icon: 'shuffle', label: t`Shuffle`, toggled: Boolean(shuffle), onClick: () => toggleShuffle() },
178 { id: 'repeat', icon: 'repeat', label: t`Repeat`, toggled: repeat, onClick: toggleRepeat },
179 ])),
180 iconBtn('close', close),
181 ),
182 ),
183 h(FlexV, { center: true, alignItems: 'center', className: 'main', ref: mainRef },
184 loading && h(Spinner, { style: { position: 'absolute', fontSize: '20vh', opacity: .5 } }),
185 failed === cur.n ? h(FlexV, { alignItems: 'center', textAlign: 'center' },
186 hIcon('error', { style: { fontSize: '20vh' } }),
187 h('div', {}, cur.name),
188 t`Loading failed`
189 ) : h('div', { className: 'showing-container', ref: containerRef },
190 h('div', {
191 className: 'cover ' + (cover ? '' : 'none'),
192 style: { backgroundImage: cover && `url("${cover}")` }
193 }),
194 component && h(component, {
195 src: cur.uri,
196 className: 'showing',
197 onLoad() {
198 lastGood.current = cur
199 setLoading(false)
200 },
201 onError,
202 async onPlay() {
203 const covers = !isAudio ? [] : state.list.filter(x => folder === dirname(x.n) // same folder
204 && x.name.match(/(?:folder|cover|front|albumart.*)\.jpe?g$/i))
205 setCover(pathEncode(_.maxBy(covers, 's')?.n || ''))
206 const meta = {
207 title: cur.name,
208 album: safeDecodeURIComponent(basename(dirname(cur.uri)), ''),
209 artwork: covers.map(x => ({ src: x.n }))
210 }
211 const m = window.MediaMetadata && (navigator.mediaSession.metadata = new MediaMetadata(meta))
212 if (cur.ext === 'mp3') {
213 const arr = cur.name.split(' - ') // "artist - title" is quite common for mp3s
214 setTags(Object.assign(meta, {
215 title: arr.at(-1)?.slice(0, -4), // last part, without extension
216 artist: arr.filter(x => !isNumeric(x)).at(-2), // previous part, if any and not numeric
217 ...await getId3Tags(location.pathname + cur.n).catch(() => {})
218 }))
219 if (m) Object.assign(m, meta)
220 }
221 hfsEvent('showPlay', {
222 entry: cur,
223 meta,
224 setCover(src: any) {
225 if (typeof src !== 'string') return
226 setCover(src)
227 if (m) navigator.mediaSession.metadata = new MediaMetadata(Object.assign(meta, { artwork: [{ src }] }))
228 }
229 })
230 }
231 }),
232 tags && h('div', { className: 'meta-tags' },
233 h('div', {}, // extra div for allowing position:relative+absolute
234 ...['title','artist','album','year'].map(k => h('div', { key: k, className: `meta-${k}` }, tags[k])) ) ),
235 ),
236 hIcon('', { className: navClass, style: { left: 0 }, onClick: goPrev }),
237 hIcon('', { className: navClass, style: { right: 0 }, onClick: goNext }),
238 ),
239 )
240
241 function goPrev() { go(-1) }
242
243 function goNext() { go(+1) }
244
245 function onError() {
246 const mediaError = (document.querySelector('.showing-container .showing') as any)?.error?.code // only present in video/audio elements
247 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
248 if (cur !== lastGood.current)
249 return go()
250 setLoading(false)
251 setFailed(cur.n)
252 }
253
254 function playFailed(err?: Error) {
255 console.debug(err)
256 if (err?.name !== 'NotAllowedError') return // browser won't allow automatic audio playing without user interaction...
257 if (!playMsgOnce) return
258 playMsgOnce = false
259 const el = getShowElement()
260 if (!(el instanceof HTMLMediaElement)) return
261 const mel = el as HTMLMediaElement
262 const dlg = newDialog({ // ...so we offer a simple dialog with a button
263 onClose: () => playMsgOnce = true,
264 Content: () => h(Btn, {
265 autoFocus: true,
266 icon: 'play',
267 label: "Click here to play",
268 onClick: () => {
269 mel.play().catch(playFailed)
270 dlg.close()
271 }
272 })
273 })
274 }
275
276 function go(dir=1) {
277 if (getCur() !== cur) return // this was fired with a stale state (closure), cancel. To reproduce: hold right-arrow on the keyboard
278 const { list } = state
279 /* this is a lazy approach to shuffling: since list is not fully available from the start, it's best to wait,
280 or the shuffle will be limited to a few entries. Benchmark: _.shuffle of 1M entries takes 10ms on a M1 pro. */
281 let workingShuffle = shuffle // in case we setShuffle, we need to do the rest of job with fresh data
282 // if playing shuffle, and going forward, where never played before, and got new entries since last time, then shuffle again
283 if (shuffle && dir > 0 && shuffle.length < list.length) {
284 const shuffleIdx = _.findIndex(shuffle, { n: cur.n })
285 if (shuffleIdx >= shufflePlayed.current) {
286 const ofs = 1 + shuffleIdx
287 const played = shuffle.slice(0, ofs) // keep the part already played, shuffle the rest
288 setShuffle(workingShuffle = played.concat(_.shuffle(_.difference(list, played)))) // list has unstable order (when searching), so we use difference
289 }
290 }
291 if (dir)
292 moving.current = dir
293 let e = cur
294 while (1) {
295 e = e.getSibling(moving.current, workingShuffle)
296 if (anyGood()) break
297 if (e) continue // try next
298 // reached last/first
299 if (dir! > 0) {
300 if (getRepeat()) {
301 e = workingShuffle?.[0] || list[0]
302 if (anyGood()) break
303 continue
304 }
305 setAutoPlaying(false)
306 }
307 goTo(lastGood.current) // revert to last known supported file
308 return restartAnimation(document.body, '.2s blink')
309 }
310 goTo(e)
311 if (shuffle) {
312 const playingIdx = _.findIndex(workingShuffle, { n: e.n })
313 shufflePlayed.current = Math.max(shufflePlayed.current, playingIdx)
314 }
315
316 function anyGood() {
317 return e && !e.isFolder && getShowComponent(e)
318 }
319 }
320
321 function goTo(to: typeof cur) {
322 setFailed(false)
323 setLoading(to !== lastGood.current)
324 setCur(to)
325 }
326
327 function toggleFullScreen() {
328 if (!document.fullscreenEnabled)
329 return alertDialog(t`Full-screen not supported`, 'error')
330 if (document.fullscreenElement)
331 document.exitFullscreen()
332 else
333 mainRef.current?.requestFullscreen()
334 }
335
336 function switchZoomMode() {
337 setMode(x => x ? x - 1 : ZoomMode.contain)
338 }
339
340 function toggleShuffle(force?: boolean) {
341 setShuffle(x => (force ?? !x) ? _.shuffle(state.list) : undefined)
342 }
343
344 function toggleRepeat() {
345 setRepeat(x => !x)
346 }
347
348 function toggleAutoPlay() {
349 setAutoPlaying(x => !x)
350 }
351
352 function scrollY(dy: number) {
353 containerRef.current?.scrollBy(0, dy * .5 * containerRef.current?.clientHeight)
354 }
355
356 function configAutoPlay() {
357 newDialog({
358 title: t`Auto-play`,
359 Content() {
360 const { auto_play_seconds } = useSnapState()
361 return h(FlexV, {},
362 autoPlaySecondsLabel,
363 h('input', {
364 type: 'number',
365 min: 1,
366 max: 10000,
367 value: auto_play_seconds,
368 style: { width: '4em' },
369 onChange: ev => state.auto_play_seconds = Number(ev.target.value)
370 })
371 )
372 }
373 })
374 }
375 }
376 })
377 return true
378 }
379
380 export function getShowComponent(entry: DirEntry) {
381 const type = ext2type(entry.ext)
382 const Component = type === 'audio' ? Audio
383 : type === 'video' ? Video
384 : type === 'image' ? 'img'
385 : ''
386 const params = { entry, Component }
387 const res = hfsEvent('fileShow', params).findLast(Boolean)
388 return res || params.Component
389 }
390
391 export const Audio = forwardRef<HTMLVideoElement, any>(({ onLoad, ...rest }: any, ref) =>
392 h('audio', { ref, onLoadedData: onLoad, controls: true, ...rest }) )
393
394 export const Video = forwardRef<HTMLVideoElement, any>(({ onLoad, ...rest }: any, ref) =>
395 h('video', { ref, onLoadedData: onLoad, controls: true, ...rest }) )
396
397 function showHelp() {
398 newDialog({
399 title: t`File Show help`,
400 className: 'file-show-help',
401 Content: () => h(Fragment, {},
402 t('showHelpMain', {}, "You can use the keyboard for some actions:"),
403 _.map({
404 "←/→": t('showHelp_←/→_body', "Go to previous/next file"),
405 "↑/↓": t('showHelp_↑/↓_body', "Scroll tall images"),
406 "space": t`Select`,
407 "D": t`Download`,
408 "Z": t`Switch zoom mode`,
409 "F": t`Full screen`,
410 "S": t`Shuffle`,
411 "R": t`Repeat`,
412 "A": t`Auto-play`,
413 }, (v,k) => h('div', { key: k }, h('kbd', {}, t('showHelp_' + k, k)), ' ', v) ),
414 h('div', { style: { marginTop: '1em' } },
415 t('showHelpListShortcut', { key: isMac ? 'SHIFT' : 'WIN' }, "From the file list, click while holding {key} to Show")
416 )
417 )
418 })
419 }