Files: play video and audio in the viewer, as the file is; reads answer Range requests
The viewer hands a video or audio file to the browser untouched — container, codec and all — and the browser plays what it can; when it can't, the viewer says so and offers the download. What the server adds is the part a player needs: /api/files/read now goes through http.ServeContent for a real file of known size, so Range requests (seeking) and HEAD work, and a download can resume. The root backend's helper takes an offset for that, and a Seeker opens the backend once, at the range asked for, instead of streaming from the top. Audio files are a preview kind too: their cover art is the thumbnail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c24f6f5cde
commit
a9bcb88212
@@ -0,0 +1,62 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlab/internal/files"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A file read answers Range requests — what a <video> needs to seek — and HEAD, through
|
||||||
|
// the same handler that streams everything else.
|
||||||
|
func TestFilesReadRanges(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "clip.mp4")
|
||||||
|
data := make([]byte, 10000)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte(i % 251)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := &Server{fsLocal: files.Local{}}
|
||||||
|
get := func(method, extra string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest(method, "/api/files/read?path="+url.QueryEscape(p)+extra, nil)
|
||||||
|
for k, v := range hdr {
|
||||||
|
r.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.filesRead(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
w := get("GET", "", nil)
|
||||||
|
if w.Code != 200 || w.Body.Len() != 10000 || w.Header().Get("Content-Type") != "video/mp4" || w.Header().Get("Accept-Ranges") != "bytes" {
|
||||||
|
t.Fatalf("whole: %d %d %q %q", w.Code, w.Body.Len(), w.Header().Get("Content-Type"), w.Header().Get("Accept-Ranges"))
|
||||||
|
}
|
||||||
|
w = get("GET", "", map[string]string{"Range": "bytes=100-199"})
|
||||||
|
if w.Code != 206 || w.Header().Get("Content-Range") != "bytes 100-199/10000" || w.Body.Len() != 100 || w.Body.Bytes()[0] != data[100] {
|
||||||
|
t.Fatalf("range: %d %q %d", w.Code, w.Header().Get("Content-Range"), w.Body.Len())
|
||||||
|
}
|
||||||
|
w = get("GET", "", map[string]string{"Range": "bytes=9990-"})
|
||||||
|
if w.Code != 206 || w.Body.Len() != 10 || w.Body.Bytes()[9] != data[9999] {
|
||||||
|
t.Fatalf("tail range: %d %d", w.Code, w.Body.Len())
|
||||||
|
}
|
||||||
|
w = get("HEAD", "", nil)
|
||||||
|
if w.Code != 200 || w.Body.Len() != 0 || w.Header().Get("Content-Length") != "10000" {
|
||||||
|
t.Fatalf("head: %d %d %q", w.Code, w.Body.Len(), w.Header().Get("Content-Length"))
|
||||||
|
}
|
||||||
|
if w := get("GET", "&max=50", nil); w.Code != 413 {
|
||||||
|
t.Fatalf("the editor's cap: %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := get("GET", "", map[string]string{"Range": "bytes=20000-"}); w.Code != 416 {
|
||||||
|
t.Fatalf("a range past the end: %d", w.Code)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(p, 0); err == nil && os.Geteuid() != 0 {
|
||||||
|
if w := get("GET", "", nil); w.Code != 403 {
|
||||||
|
t.Fatalf("unreadable: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-11
@@ -1928,16 +1928,6 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, 413, "file too large")
|
jsonErr(w, 413, "file too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
f, err := fsys.Open(r.Context(), p)
|
|
||||||
if err != nil {
|
|
||||||
fsErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
var body io.Reader = f
|
|
||||||
if mx > 0 { // stat can lie (size 0 on /proc); the cap must hold on the bytes actually sent
|
|
||||||
body = io.LimitReader(f, mx+1)
|
|
||||||
}
|
|
||||||
name := filepath.Base(p)
|
name := filepath.Base(p)
|
||||||
ct := mime.TypeByExtension(filepath.Ext(name))
|
ct := mime.TypeByExtension(filepath.Ext(name))
|
||||||
if ct == "" {
|
if ct == "" {
|
||||||
@@ -1956,7 +1946,28 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name))
|
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name))
|
||||||
}
|
}
|
||||||
if in.Size > 0 && mx == 0 {
|
if in.Size > 0 && mx == 0 {
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(in.Size, 10))
|
// A real file of known size goes through net/http, which speaks Range: a video player
|
||||||
|
// can seek and a download can resume. /proc files say size 0 and take the plain path.
|
||||||
|
rs := files.NewSeeker(r.Context(), fsys, p, in.Size)
|
||||||
|
defer rs.Close()
|
||||||
|
if _, local := fsys.(files.Local); local { // cheap here, and turns "can't open" into a status rather than a cut-off body
|
||||||
|
if err := rs.Check(); err != nil {
|
||||||
|
fsErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.ServeContent(w, r, name, time.UnixMilli(in.Mtime), rs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := fsys.Open(r.Context(), p)
|
||||||
|
if err != nil {
|
||||||
|
fsErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var body io.Reader = f
|
||||||
|
if mx > 0 { // stat can lie (size 0 on /proc); the cap must hold on the bytes actually sent
|
||||||
|
body = io.LimitReader(f, mx+1)
|
||||||
}
|
}
|
||||||
_, _ = io.Copy(w, body)
|
_, _ = io.Copy(w, body)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-2
@@ -59,6 +59,7 @@ type FS interface {
|
|||||||
List(ctx context.Context, path string) (Listing, error)
|
List(ctx context.Context, path string) (Listing, error)
|
||||||
Stat(ctx context.Context, path string) (Info, error)
|
Stat(ctx context.Context, path string) (Info, error)
|
||||||
Open(ctx context.Context, path string) (io.ReadCloser, error)
|
Open(ctx context.Context, path string) (io.ReadCloser, error)
|
||||||
|
OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) // Open from a byte offset: a range request, a player seeking
|
||||||
Write(ctx context.Context, path string, r io.Reader) error
|
Write(ctx context.Context, path string, r io.Reader) error
|
||||||
Mkdir(ctx context.Context, path string) error
|
Mkdir(ctx context.Context, path string) error
|
||||||
Touch(ctx context.Context, path string) error
|
Touch(ctx context.Context, path string) error
|
||||||
@@ -231,6 +232,20 @@ func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
|||||||
return os.Open(path)
|
return os.Open(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l Local) OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) {
|
||||||
|
f, err := l.Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if offset > 0 {
|
||||||
|
if _, err := f.(*os.File).Seek(offset, io.SeekStart); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Write replaces the file's contents, keeping its mode; new files get 0644 (umask).
|
// Write replaces the file's contents, keeping its mode; new files get 0644 (umask).
|
||||||
func (Local) Write(_ context.Context, path string, r io.Reader) error {
|
func (Local) Write(_ context.Context, path string, r io.Reader) error {
|
||||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
@@ -336,7 +351,15 @@ func (r *cmdReader) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s Sudo) Open(ctx context.Context, path string) (io.ReadCloser, error) {
|
func (s Sudo) Open(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||||
c := s.cmd(ctx, "read", path)
|
return s.open(ctx, "read", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Sudo) OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) {
|
||||||
|
return s.open(ctx, "read", "--offset", strconv.FormatInt(offset, 10), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Sudo) open(ctx context.Context, args ...string) (io.ReadCloser, error) {
|
||||||
|
c := s.cmd(ctx, args...)
|
||||||
errb := &strings.Builder{}
|
errb := &strings.Builder{}
|
||||||
c.Stderr = errb
|
c.Stderr = errb
|
||||||
out, err := c.StdoutPipe()
|
out, err := c.StdoutPipe()
|
||||||
@@ -430,6 +453,11 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
var l Local
|
var l Local
|
||||||
op := args[0]
|
op := args[0]
|
||||||
|
var offset int64
|
||||||
|
if op == "read" && len(args) >= 4 && args[1] == "--offset" { // read --offset N <path>
|
||||||
|
offset, _ = strconv.ParseInt(args[2], 10, 64)
|
||||||
|
args = append([]string{"read"}, args[3:]...)
|
||||||
|
}
|
||||||
clean := func(i int) (string, error) {
|
clean := func(i int) (string, error) {
|
||||||
if i >= len(args) {
|
if i >= len(args) {
|
||||||
return "", errors.New("path required")
|
return "", errors.New("path required")
|
||||||
@@ -456,7 +484,7 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
|
|||||||
}
|
}
|
||||||
return emit(stdout, out, stderr)
|
return emit(stdout, out, stderr)
|
||||||
case "read":
|
case "read":
|
||||||
f, err := l.Open(ctx, p)
|
f, err := l.OpenAt(ctx, p, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seeker is an FS file as http.ServeContent wants one — something to seek in — for a file
|
||||||
|
// whose size is already known from a stat. ServeContent seeks to the end for the size, to
|
||||||
|
// the start of the range asked for, then reads: so the backend is opened once, at that
|
||||||
|
// position, and only ever read forward. The Sudo backend's helper is started at the offset
|
||||||
|
// rather than streamed from the top and skipped.
|
||||||
|
type Seeker struct {
|
||||||
|
ctx context.Context
|
||||||
|
fsys FS
|
||||||
|
path string
|
||||||
|
size int64
|
||||||
|
pos int64 // where the next Read reads from
|
||||||
|
at int64 // where cur is positioned, when open
|
||||||
|
cur io.ReadCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSeeker(ctx context.Context, fsys FS, path string, size int64) *Seeker {
|
||||||
|
return &Seeker{ctx: ctx, fsys: fsys, path: path, size: size}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check opens the file now, from the start, so a file that cannot be read is known before
|
||||||
|
// any headers go out. A later seek elsewhere simply reopens.
|
||||||
|
func (s *Seeker) Check() error {
|
||||||
|
if s.cur != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, err := s.fsys.OpenAt(s.ctx, s.path, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.cur, s.at = f, 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Seeker) Seek(offset int64, whence int) (int64, error) {
|
||||||
|
var pos int64
|
||||||
|
switch whence {
|
||||||
|
case io.SeekStart:
|
||||||
|
pos = offset
|
||||||
|
case io.SeekCurrent:
|
||||||
|
pos = s.pos + offset
|
||||||
|
case io.SeekEnd:
|
||||||
|
pos = s.size + offset
|
||||||
|
default:
|
||||||
|
return 0, errors.New("bad whence")
|
||||||
|
}
|
||||||
|
if pos < 0 {
|
||||||
|
return 0, errors.New("seek before start")
|
||||||
|
}
|
||||||
|
if s.cur != nil && pos != s.at {
|
||||||
|
s.cur.Close()
|
||||||
|
s.cur = nil
|
||||||
|
}
|
||||||
|
s.pos = pos
|
||||||
|
return pos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Seeker) Read(p []byte) (int, error) {
|
||||||
|
if s.pos >= s.size {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
if s.cur == nil {
|
||||||
|
f, err := s.fsys.OpenAt(s.ctx, s.path, s.pos)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
s.cur, s.at = f, s.pos
|
||||||
|
}
|
||||||
|
n, err := s.cur.Read(p)
|
||||||
|
s.pos += int64(n)
|
||||||
|
s.at += int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Seeker) Close() error {
|
||||||
|
if s.cur == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := s.cur.Close()
|
||||||
|
s.cur = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type stringsBuilder = strings.Builder
|
||||||
|
|
||||||
|
type countingFS struct {
|
||||||
|
Local
|
||||||
|
opens []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingFS) OpenAt(ctx context.Context, path string, off int64) (io.ReadCloser, error) {
|
||||||
|
c.opens = append(c.opens, off)
|
||||||
|
return c.Local.OpenAt(ctx, path, off)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeContent's moves — size from the end, seek to the range, read forward — open the
|
||||||
|
// backend once, at the range; a seek to where the stream already is costs nothing.
|
||||||
|
func TestSeeker(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "f")
|
||||||
|
data := make([]byte, 1000)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte(i)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fs := &countingFS{}
|
||||||
|
s := NewSeeker(context.Background(), fs, p, int64(len(data)))
|
||||||
|
if n, _ := s.Seek(0, io.SeekEnd); n != 1000 {
|
||||||
|
t.Fatalf("end = %d", n)
|
||||||
|
}
|
||||||
|
if _, err := s.Seek(100, io.SeekStart); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 50)
|
||||||
|
if n, err := io.ReadFull(s, buf); err != nil || n != 50 || buf[0] != 100 || buf[49] != 149 {
|
||||||
|
t.Fatalf("read at 100: %d %v %v", n, err, buf[:2])
|
||||||
|
}
|
||||||
|
if _, err := s.Seek(150, io.SeekStart); err != nil { // where it already is
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, _ := io.ReadFull(s, buf); n != 50 || buf[0] != 150 {
|
||||||
|
t.Fatal("read after a no-op seek")
|
||||||
|
}
|
||||||
|
if len(fs.opens) != 1 || fs.opens[0] != 100 {
|
||||||
|
t.Fatalf("opens = %v, want [100]", fs.opens)
|
||||||
|
}
|
||||||
|
if _, err := s.Seek(0, io.SeekStart); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rest, err := io.ReadAll(s)
|
||||||
|
if err != nil || len(rest) != 1000 || rest[999] != data[999] {
|
||||||
|
t.Fatalf("read all from the start: %d %v", len(rest), err)
|
||||||
|
}
|
||||||
|
if len(fs.opens) != 2 || fs.opens[1] != 0 {
|
||||||
|
t.Fatalf("opens = %v, want [100 0]", fs.opens)
|
||||||
|
}
|
||||||
|
if n, err := s.Read(buf); n != 0 || err != io.EOF {
|
||||||
|
t.Fatal("past the end is EOF")
|
||||||
|
}
|
||||||
|
if _, err := s.Seek(-1, io.SeekStart); err == nil {
|
||||||
|
t.Fatal("before the start")
|
||||||
|
}
|
||||||
|
if err := s.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Check opens at the start and keeps that stream for a read from the start
|
||||||
|
s2 := NewSeeker(context.Background(), fs, p, 1000)
|
||||||
|
if err := s2.Check(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s2.Seek(0, io.SeekEnd)
|
||||||
|
s2.Seek(0, io.SeekStart)
|
||||||
|
if n, _ := io.ReadFull(s2, buf); n != 50 || buf[0] != 0 {
|
||||||
|
t.Fatal("read from the start after Check")
|
||||||
|
}
|
||||||
|
if len(fs.opens) != 3 {
|
||||||
|
t.Fatalf("Check's open was not reused: %v", fs.opens)
|
||||||
|
}
|
||||||
|
s2.Close()
|
||||||
|
if err := NewSeeker(context.Background(), fs, filepath.Join(t.TempDir(), "nope"), 5).Check(); err == nil {
|
||||||
|
t.Fatal("Check must report a file that can't be opened")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The helper's read takes an offset, for the Sudo backend.
|
||||||
|
func TestHelperReadOffset(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "f")
|
||||||
|
if err := os.WriteFile(p, []byte("0123456789"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out, errb stringsBuilder
|
||||||
|
if rc := Helper([]string{"read", "--offset", "7", p}, nil, &out, &errb); rc != 0 {
|
||||||
|
t.Fatalf("rc %d: %s", rc, errb.String())
|
||||||
|
}
|
||||||
|
if out.String() != "789" {
|
||||||
|
t.Fatalf("got %q", out.String())
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
if rc := Helper([]string{"read", p}, nil, &out, &errb); rc != 0 || out.String() != "0123456789" {
|
||||||
|
t.Fatalf("plain read: rc %d %q", rc, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ func (e *DecodeError) Error() string { return e.Msg }
|
|||||||
|
|
||||||
var imageExt = set("png jpg jpeg jpe jfif gif webp bmp tif tiff avif heic heif hif jxl svg ico psd tga thm")
|
var imageExt = set("png jpg jpeg jpe jfif gif webp bmp tif tiff avif heic heif hif jxl svg ico psd tga thm")
|
||||||
var videoExt = set("mp4 m4v mkv webm mov avi wmv flv mpg mpeg mpe m2v ts m2ts mts 3gp 3g2 ogv ogm mxf vob asf rm rmvb divx f4v mjpeg mjpg 360 osv insv lrf")
|
var videoExt = set("mp4 m4v mkv webm mov avi wmv flv mpg mpeg mpe m2v ts m2ts mts 3gp 3g2 ogv ogm mxf vob asf rm rmvb divx f4v mjpeg mjpg 360 osv insv lrf")
|
||||||
|
var audioExt = set("mp3 flac m4a m4b aac ogg oga opus wav wma aiff aif alac ape wv mka")
|
||||||
|
|
||||||
func set(s string) map[string]bool {
|
func set(s string) map[string]bool {
|
||||||
m := map[string]bool{}
|
m := map[string]bool{}
|
||||||
@@ -102,7 +103,8 @@ func set(s string) map[string]bool {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kind is "image", "video" or "" for a file name.
|
// Kind is "image", "video", "audio" or "" for a file name. An audio file's preview is its
|
||||||
|
// cover art, when it carries any.
|
||||||
func Kind(name string) string {
|
func Kind(name string) string {
|
||||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
|
||||||
switch {
|
switch {
|
||||||
@@ -110,6 +112,8 @@ func Kind(name string) string {
|
|||||||
return "image"
|
return "image"
|
||||||
case videoExt[ext]:
|
case videoExt[ext]:
|
||||||
return "video"
|
return "video"
|
||||||
|
case audioExt[ext]:
|
||||||
|
return "audio"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-3
@@ -17,6 +17,7 @@ function icon(e: FileEntry): string {
|
|||||||
if (e.type === 'other') return '⚙️'
|
if (e.type === 'other') return '⚙️'
|
||||||
if (e.preview === 'image' || isImage(e.name)) return '🖼️'
|
if (e.preview === 'image' || isImage(e.name)) return '🖼️'
|
||||||
if (e.preview === 'video') return '🎞️'
|
if (e.preview === 'video') return '🎞️'
|
||||||
|
if (e.preview === 'audio') return '🎵'
|
||||||
if (/\.(zip|gz|bz2|xz|zst|tar|tgz|7z|rar)$/i.test(e.name)) return '🗜️'
|
if (/\.(zip|gz|bz2|xz|zst|tar|tgz|7z|rar)$/i.test(e.name)) return '🗜️'
|
||||||
if (/\.(sh|bash|zsh|py|go|js|ts|tsx|rb|pl|php|rs|c|h|cpp|java|lua)$/i.test(e.name) || e.mode.includes('x')) return '📜'
|
if (/\.(sh|bash|zsh|py|go|js|ts|tsx|rb|pl|php|rs|c|h|cpp|java|lua)$/i.test(e.name) || e.mode.includes('x')) return '📜'
|
||||||
if (/\.(log|txt|md|json|ya?ml|toml|ini|conf|cfg|env|service|timer)$/i.test(e.name)) return '📄'
|
if (/\.(log|txt|md|json|ya?ml|toml|ini|conf|cfg|env|service|timer)$/i.test(e.name)) return '📄'
|
||||||
@@ -132,7 +133,7 @@ class Near {
|
|||||||
private forget(el: Element) { this.cbs.delete(el); this.obs.unobserve(el) }
|
private forget(el: Element) { this.cbs.delete(el); this.obs.unobserve(el) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Thumb({ url, kind, near, fallback }: { url: string; kind: 'image' | 'video'; near: Near | null; fallback: string }) {
|
function Thumb({ url, kind, near, fallback }: { url: string; kind: 'image' | 'video' | 'audio'; near: Near | null; fallback: string }) {
|
||||||
const ref = useRef<HTMLSpanElement>(null)
|
const ref = useRef<HTMLSpanElement>(null)
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
const [failed, setFailed] = useState('')
|
const [failed, setFailed] = useState('')
|
||||||
@@ -189,11 +190,12 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
|||||||
const [orig, setOrig] = useState('')
|
const [orig, setOrig] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [cantPlay, setCantPlay] = useState(false) // the browser tried the file as it is and gave up
|
||||||
const name = path.split('/').pop() ?? path
|
const name = path.split('/').pop() ?? path
|
||||||
const dirty = text !== null && text !== orig
|
const dirty = text !== null && text !== orig
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInfo(null); setText(null); setErr('')
|
setInfo(null); setText(null); setErr(''); setCantPlay(false)
|
||||||
let stop = false
|
let stop = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -251,7 +253,17 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
|||||||
<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"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
|
||||||
{info && text === null && !isImage(name) && !err && (
|
{/* Video and audio go to the browser as they are, codec and all, and it plays what it can. */}
|
||||||
|
{info && text === null && !isImage(name) && info.preview === 'video' && !cantPlay && (
|
||||||
|
<div className="preview"><video controls playsInline preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{info && text === null && !isImage(name) && cantPlay && (
|
||||||
|
<div className="notice">This browser can't play {name} as it is — the container or codec isn't one it decodes. Download it instead.</div>
|
||||||
|
)}
|
||||||
|
{info && text === null && !isImage(name) && !info.preview && !err && (
|
||||||
<div className="notice">{info.type === 'dir' ? 'This is a directory.' : info.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div>
|
<div className="notice">{info.type === 'dir' ? 'This is a directory.' : info.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -249,7 +249,7 @@ export interface ARCDetail {
|
|||||||
}
|
}
|
||||||
export interface ARCView { detail: ARCDetail; live: ARCLive | null }
|
export interface ARCView { detail: ARCDetail; live: ARCLive | null }
|
||||||
|
|
||||||
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean; preview?: 'image' | 'video' }
|
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean; preview?: 'image' | 'video' | 'audio' }
|
||||||
export interface ThumbGPU { api: string; device: string; name?: string; codecs?: string[]; reason?: string; permission?: boolean }
|
export interface ThumbGPU { api: string; device: string; name?: string; codecs?: string[]; reason?: string; permission?: boolean }
|
||||||
// How previews get made on the machine: ok with an engine, or why not and what would fix it.
|
// How previews get made on the machine: ok with an engine, or why not and what would fix it.
|
||||||
export interface ThumbStatus { ok: boolean; engine: 'gpu' | 'cpu' | ''; summary: string; ffmpeg?: string; gpus: ThumbGPU[]; unusable?: ThumbGPU[]; error?: string; install?: { pm: string; package: string }; gpu_fix?: boolean; user: string; cache: { path: string; entries: number; bytes: number } }
|
export interface ThumbStatus { ok: boolean; engine: 'gpu' | 'cpu' | ''; summary: string; ffmpeg?: string; gpus: ThumbGPU[]; unusable?: ThumbGPU[]; error?: string; install?: { pm: string; package: string }; gpu_fix?: boolean; user: string; cache: { path: string; entries: number; bytes: number } }
|
||||||
|
|||||||
+7
-1
@@ -364,11 +364,17 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.file-editor { flex: 1; min-height: 0; border: 0; border-radius: 0; resize: none; font-size: 13px; }
|
.file-editor { flex: 1; min-height: 0; border: 0; border-radius: 0; resize: none; font-size: 13px; }
|
||||||
.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.audio { align-items: flex-start; }
|
||||||
|
.audio-box { display: flex; flex-direction: column; align-items: center; gap: 10px; width: min(560px, 100%); margin-top: 8vh; }
|
||||||
|
.audio-box .ficon { font-size: 48px; }
|
||||||
|
.audio-box audio { width: 100%; }
|
||||||
/* previews: the icon column becomes a thumbnail box for images and videos */
|
/* previews: the icon column becomes a thumbnail box for images and videos */
|
||||||
.file-list.thumbs .file-row { grid-template-columns: 72px 1fr 80px 80px 130px 32px; padding-top: 4px; padding-bottom: 4px; min-height: 52px; }
|
.file-list.thumbs .file-row { grid-template-columns: 72px 1fr 80px 80px 130px 32px; padding-top: 4px; padding-bottom: 4px; min-height: 52px; }
|
||||||
.fthumb { position: relative; width: 72px; height: 44px; display: flex; align-items: center; justify-content: center; background: var(--bg3); border-radius: 4px; overflow: hidden; }
|
.fthumb { position: relative; width: 72px; height: 44px; display: flex; align-items: center; justify-content: center; background: var(--bg3); border-radius: 4px; overflow: hidden; }
|
||||||
.fthumb img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
|
.fthumb img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
|
||||||
.fthumb.video::after { content: '▶'; position: absolute; right: 2px; bottom: 2px; font-size: 9px; line-height: 1; padding: 2px 3px; border-radius: 3px; background: rgba(0,0,0,.6); color: #fff; }
|
.fthumb.video::after, .fthumb.audio::after { content: '▶'; position: absolute; right: 2px; bottom: 2px; font-size: 9px; line-height: 1; padding: 2px 3px; border-radius: 3px; background: rgba(0,0,0,.6); color: #fff; }
|
||||||
|
.fthumb.audio::after { content: '♪'; }
|
||||||
.fthumb .ficon { font-size: 18px; }
|
.fthumb .ficon { font-size: 18px; }
|
||||||
.thumb-note { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 10px 0; }
|
.thumb-note { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 10px 0; }
|
||||||
/* move: the folder picker */
|
/* move: the folder picker */
|
||||||
|
|||||||
Reference in New Issue
Block a user