Files viewer: previous/next — buttons, ← →, swipe on pictures; steps through media when media is open; audio autoplays
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
829617c932
commit
5edad2de6a
+58
-6
@@ -1,4 +1,4 @@
|
|||||||
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { DragEvent, PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api'
|
import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api'
|
||||||
import { fmtBytes } from './fmt'
|
import { fmtBytes } from './fmt'
|
||||||
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
||||||
@@ -22,6 +22,7 @@ export function resolvePath(input: string, here: string): string {
|
|||||||
}
|
}
|
||||||
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
||||||
const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)
|
const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)
|
||||||
|
const isMedia = (e: FileEntry) => !!e.preview || isImage(e.name)
|
||||||
const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
|
const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
|
||||||
const MAX_EDIT = 2 * 1024 * 1024
|
const MAX_EDIT = 2 * 1024 * 1024
|
||||||
|
|
||||||
@@ -198,7 +199,9 @@ function ThumbNote({ st, sudo, onChanged }: { st: ThumbStatus | null; sudo: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- editor / viewer ----
|
// ---- editor / viewer ----
|
||||||
function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolean; onClose: () => void; onChanged: () => void }) {
|
// steps: the files ← and → go through, in the list's order (only the pictures, videos and
|
||||||
|
// sound when one of those is open).
|
||||||
|
function Viewer({ path, sudo, steps, onStep, onClose, onChanged }: { path: string; sudo: boolean; steps: string[]; onStep: (p: string) => void; onClose: () => void; onChanged: () => void }) {
|
||||||
const [info, setInfo] = useState<FileInfo | null>(null)
|
const [info, setInfo] = useState<FileInfo | null>(null)
|
||||||
const [text, setText] = useState<string | null>(null)
|
const [text, setText] = useState<string | null>(null)
|
||||||
const [orig, setOrig] = useState('')
|
const [orig, setOrig] = useState('')
|
||||||
@@ -251,10 +254,49 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
|||||||
}, [dirty, name])
|
}, [dirty, name])
|
||||||
const dl = api.fileDownloadURL(path, sudo)
|
const dl = api.fileDownloadURL(path, sudo)
|
||||||
|
|
||||||
|
// Previous and next: the buttons, ← and → (unless typing, or the player has the focus and
|
||||||
|
// seeks with them), and a sideways swipe on a picture.
|
||||||
|
const at = steps.indexOf(path)
|
||||||
|
const prev = at > 0 ? steps[at - 1] : null
|
||||||
|
const next = at >= 0 && at < steps.length - 1 ? steps[at + 1] : null
|
||||||
|
const step = useCallback(async (to: string | null) => {
|
||||||
|
if (!to) return
|
||||||
|
if (dirty && !await askYes('Discard unsaved changes?', 'Discard')) return
|
||||||
|
setUnsaved(''); onStep(to)
|
||||||
|
}, [dirty, onStep])
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||||
|
const t = e.target as HTMLElement | null
|
||||||
|
if (t?.closest('input, textarea, select, [contenteditable], .vp')) return
|
||||||
|
if (e.key === 'ArrowLeft' && prev) { e.preventDefault(); step(prev) }
|
||||||
|
else if (e.key === 'ArrowRight' && next) { e.preventDefault(); step(next) }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', h)
|
||||||
|
return () => window.removeEventListener('keydown', h)
|
||||||
|
}, [prev, next, step])
|
||||||
|
// The next and previous pictures load now, so stepping onto them is instant.
|
||||||
|
useEffect(() => {
|
||||||
|
for (const p of [prev, next]) if (p && isImage(p)) new Image().src = api.fileReadURL(p, sudo)
|
||||||
|
}, [prev, next, sudo])
|
||||||
|
const swipe = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
const swipeEnd = (e: PointerEvent) => {
|
||||||
|
const s = swipe.current
|
||||||
|
swipe.current = null
|
||||||
|
if (!s) return
|
||||||
|
const dx = e.clientX - s.x, dy = e.clientY - s.y
|
||||||
|
if (Math.abs(dx) > 50 && Math.abs(dx) > 1.5 * Math.abs(dy)) step(dx < 0 ? next : prev)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="viewer">
|
<div className="viewer">
|
||||||
<div className="viewer-bar">
|
<div className="viewer-bar">
|
||||||
<button className="btn" onClick={close}>‹ Back</button>
|
<button className="btn" onClick={close}>‹ Back</button>
|
||||||
|
{at >= 0 && steps.length > 1 && <>
|
||||||
|
<button className="btn step" disabled={!prev} title="previous (←)" onClick={() => step(prev)}>←</button>
|
||||||
|
<span className="muted num">{at + 1}/{steps.length}</span>
|
||||||
|
<button className="btn step" disabled={!next} title="next (→)" onClick={() => step(next)}>→</button>
|
||||||
|
</>}
|
||||||
<span className="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
|
<span className="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
|
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
|
||||||
@@ -266,11 +308,17 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
|||||||
{info && text !== null && (
|
{info && text !== null && (
|
||||||
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
||||||
)}
|
)}
|
||||||
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
|
{info && text === null && isImage(name) && (
|
||||||
|
<div className="preview pic" onPointerDown={(e) => { if (e.pointerType !== 'mouse' && e.isPrimary) swipe.current = { x: e.clientX, y: e.clientY } }} onPointerUp={swipeEnd} onPointerCancel={() => { swipe.current = null }}>
|
||||||
|
<img src={api.fileReadURL(path, sudo)} alt={name} draggable={false} />
|
||||||
|
{prev && <button className="step-edge prev" title="previous (←)" onClick={() => step(prev)}>‹</button>}
|
||||||
|
{next && <button className="step-edge next" title="next (→)" onClick={() => step(next)}>›</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Video: the player (Auto, by hand, or the file as it is), made on the Transcode device. Audio as it is. */}
|
{/* Video: the player (Auto, by hand, or the file as it is), made on the Transcode device. Audio as it is. */}
|
||||||
{info && text === null && !isImage(name) && info.preview === 'video' && <Player path={path} sudo={sudo} name={name} />}
|
{info && text === null && !isImage(name) && info.preview === 'video' && <Player key={path} path={path} sudo={sudo} name={name} />}
|
||||||
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
|
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
|
||||||
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
|
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls autoPlay preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
|
||||||
)}
|
)}
|
||||||
{info && text === null && !isImage(name) && info.preview === 'audio' && cantPlay && (
|
{info && text === null && !isImage(name) && info.preview === 'audio' && cantPlay && (
|
||||||
<div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div>
|
<div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div>
|
||||||
@@ -399,7 +447,11 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
if (files.length) uploadFiles(files)
|
if (files.length) uploadFiles(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (open) return <div className="files"><Viewer path={open} sudo={sudo} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
|
if (open) {
|
||||||
|
const cur = shown.find((e) => join(path, e.name) === open)
|
||||||
|
const steps = shown.filter((e) => e.type !== 'dir' && e.type !== 'other' && (!cur || !isMedia(cur) || isMedia(e))).map((e) => join(path, e.name))
|
||||||
|
return <div className="files"><Viewer path={open} sudo={sudo} steps={steps} onStep={setOpen} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}>
|
<div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}>
|
||||||
|
|||||||
@@ -378,6 +378,10 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; }
|
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; }
|
||||||
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
.preview video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
|
.preview video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
|
||||||
|
.preview.pic { position: relative; touch-action: pan-y pinch-zoom; }
|
||||||
|
.step-edge { position: absolute; top: 50%; transform: translateY(-50%); width: 44px; height: 72px; border: 0; border-radius: 6px; background: rgba(0, 0, 0, .35); color: #fff; font-size: 34px; line-height: 1; cursor: pointer; opacity: .55; }
|
||||||
|
.step-edge:hover { opacity: 1; } .step-edge.prev { left: 8px; } .step-edge.next { right: 8px; }
|
||||||
|
.btn.step:disabled { opacity: .4; }
|
||||||
/* the video player (Player.tsx): Ham Share's, in the viewer; full screen is the browser's, or
|
/* the video player (Player.tsx): Ham Share's, in the viewer; full screen is the browser's, or
|
||||||
on an iPhone the whole window */
|
on an iPhone the whole window */
|
||||||
.vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; }
|
.vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; }
|
||||||
|
|||||||
Reference in New Issue
Block a user