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