diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-01 15:57:13 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-01 15:57:13 +0300 |
| commit | 65ee2e4d7f2be8036457e3cdca0e26e09eb56e0b (patch) | |
| tree | cbd8143db9e81baed51239a628dfedae2dbf375f | |
| parent | 56666ca87f11e72521dcda00de72438e13899b95 (diff) | |
add readme and so on
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | AGENTS.md | 6 | ||||
| -rw-r--r-- | Dockerfile | 6 | ||||
| -rw-r--r-- | Magefile.go | 2 | ||||
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | internal/api/handlers.go | 49 | ||||
| -rw-r--r-- | internal/repository/media.go | 6 | ||||
| -rw-r--r-- | internal/repository/repository.go | 1 | ||||
| -rw-r--r-- | internal/scanner/scanner.go | 42 | ||||
| -rw-r--r-- | internal/scanner/scanner_test.go | 18 | ||||
| -rw-r--r-- | internal/service/admin.go | 2 | ||||
| -rw-r--r-- | internal/service/admin_test.go | 10 | ||||
| -rwxr-xr-x | start.sh | 76 | ||||
| -rw-r--r-- | web/css/layout.css | 31 | ||||
| -rw-r--r-- | web/css/player.css | 9 | ||||
| -rw-r--r-- | web/index.html | 5 | ||||
| -rw-r--r-- | web/js/app.js | 92 | ||||
| -rw-r--r-- | web/js/keyboard.js | 31 | ||||
| -rw-r--r-- | web/js/player.js | 20 | ||||
| -rw-r--r-- | web/js/selection.js | 70 | ||||
| -rw-r--r-- | web/js/state.js | 1 |
21 files changed, 386 insertions, 98 deletions
@@ -1,6 +1,6 @@ # Binaries mediaplayer -play +player *.exe *.dll *.so @@ -77,10 +77,10 @@ Available targets (from `Magefile.go`): | Target | Description | |--------|-------------| | `mage` (default) | Same as `mage build` | -| `mage build` | Compile the binary (`go build -o play ./cmd/mediaplayer`) | +| `mage build` | Compile the binary (`go build -o player ./cmd/mediaplayer`) | | `mage test` | Run `go test ./...` | -| `mage install` | Build and copy `play` to `$GOPATH/bin` (or `~/go/bin`) | -| `mage clean` | Remove the `play` binary | +| `mage install` | Build and copy `player` to `$GOPATH/bin` (or `~/go/bin`) | +| `mage clean` | Remove the `player` binary | | `mage docker-build` | Build container image as `player:latest` | | `mage docker-push` | Push `player:latest` to registry | @@ -14,7 +14,7 @@ RUN go mod download COPY . . # Build the binary. -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o play ./cmd/mediaplayer +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o player ./cmd/mediaplayer # ---- Runtime stage ---- FROM alpine:3.21 @@ -28,10 +28,10 @@ USER 65534:65534 WORKDIR /app # Copy the binary and static assets from the builder. -COPY --from=builder --chown=65534:65534 /build/play /app/play +COPY --from=builder --chown=65534:65534 /build/player /app/player COPY --from=builder --chown=65534:65534 /build/web /app/web # Expose the default HTTP port. EXPOSE 8080 -ENTRYPOINT ["/app/play"] +ENTRYPOINT ["/app/player"] diff --git a/Magefile.go b/Magefile.go index 7895e48..3b0b1b1 100644 --- a/Magefile.go +++ b/Magefile.go @@ -12,7 +12,7 @@ import ( "github.com/magefile/mage/sh" ) -const binaryName = "play" +const binaryName = "player" // Default target runs Build. func Default() error { diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e71e55 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +player +====== + +Player is an opinionated KISS web media player. It is designed to be simple, lightweight, and easy to use and designed keyboard-first. + diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 97e5557..ad2e608 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -7,9 +7,11 @@ import ( "fmt" "io" "log/slog" + "mime" "net/http" "net/url" "os" + "path/filepath" "strconv" "strings" "time" @@ -348,6 +350,14 @@ func parseMediaListQuery(q url.Values) repository.MediaFilter { filter.SetID = &id } } + if v := q.Get("set_ids"); v != "" { + parts := strings.Split(v, ",") + for _, p := range parts { + if id, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64); err == nil { + filter.SetIDs = append(filter.SetIDs, id) + } + } + } if v := q.Get("type"); v != "" { t := model.MediaType(v) filter.Type = &t @@ -552,7 +562,10 @@ func (s *Server) serveFileResult(w http.ResponseWriter, r *http.Request, res *se disp := fmt.Sprintf("attachment; filename=%q", res.FileName) w.Header().Set("Content-Disposition", disp) } - + // Set Content-Type so browsers know how to decode the file without + // needing to sniff, which avoids buffering delays during streaming. + w.Header().Set("Content-Type", mimeTypeForFilename(res.FileName)) + w.Header().Set("Accept-Ranges", "bytes") http.ServeContent(w, r, res.FileName, stat.ModTime(), f) } @@ -983,3 +996,37 @@ func (s *Server) handleRevokePermission(w http.ResponseWriter, r *http.Request) } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } + +// mimeTypeForFilename returns an HTTP Content-Type based on the file extension. +func mimeTypeForFilename(name string) string { + ext := strings.ToLower(filepath.Ext(name)) + t := mime.TypeByExtension(ext) + if t != "" { + return t + } + switch ext { + case ".mp4", ".m4v": + return "video/mp4" + case ".mkv": + return "video/x-matroska" + case ".avi": + return "video/x-msvideo" + case ".mov": + return "video/quicktime" + case ".webm": + return "video/webm" + case ".mp3": + return "audio/mpeg" + case ".flac": + return "audio/flac" + case ".wav": + return "audio/wav" + case ".aac", ".m4a": + return "audio/mp4" + case ".ogg", ".opus": + return "audio/ogg" + case ".m4b": + return "audio/x-m4b" + } + return "application/octet-stream" +} diff --git a/internal/repository/media.go b/internal/repository/media.go index 1657c7a..1fdb365 100644 --- a/internal/repository/media.go +++ b/internal/repository/media.go @@ -164,6 +164,12 @@ func (s *SQLite) ListMedia(ctx context.Context, filter MediaFilter) ([]model.Med conds = append(conds, `media.set_id = ?`) args = append(args, *filter.SetID) } + if len(filter.SetIDs) > 0 { + conds = append(conds, "media.set_id IN ("+placeholders(len(filter.SetIDs))+")") + for _, id := range filter.SetIDs { + args = append(args, id) + } + } if len(filter.AllowedSetIDs) > 0 { conds = append(conds, "media.set_id IN ("+placeholders(len(filter.AllowedSetIDs))+")") for _, id := range filter.AllowedSetIDs { diff --git a/internal/repository/repository.go b/internal/repository/repository.go index bc44bf5..53635b4 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -93,6 +93,7 @@ type SetPermissionRepo interface { // MediaFilter defines query parameters for listing media. type MediaFilter struct { SetID *int64 + SetIDs []int64 // multi-set selection AllowedSetIDs []int64 Type *model.MediaType Search string diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 3e3f19b..569f181 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -17,7 +17,7 @@ import ( // Scanner defines the filesystem scanning contract. type Scanner interface { - Scan(ctx context.Context, root string) error + Scan(ctx context.Context, root string, progress *model.ScanProgress) error } // FSScanner recursively scans media root for sets and media files. @@ -43,26 +43,43 @@ func NewFSScanner(store repository.ScannerStore, prober probe.Prober, thumbGen t } // Scan walks immediate subdirectories of root, treating each as a set. -func (s *FSScanner) Scan(ctx context.Context, root string) error { +func (s *FSScanner) Scan(ctx context.Context, root string, progress *model.ScanProgress) error { entries, err := s.fs.ReadDir(root) if err != nil { return fmt.Errorf("read media root %q: %w", root, err) } + // Count total sets for progress. + var setCount int + for _, entry := range entries { + if entry.IsDir() { + setCount++ + } + } + if progress != nil { + progress.Start(setCount) + } + for _, entry := range entries { if !entry.IsDir() { continue } setPath := filepath.Join(root, entry.Name()) - if err := s.scanSet(ctx, root, setPath); err != nil { + if err := s.scanSet(ctx, root, setPath, progress); err != nil { return err } + if progress != nil { + progress.IncrementSet() + } } return nil } -func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error { +func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress *model.ScanProgress) error { setName := filepath.Base(setPath) + if progress != nil { + progress.SetCurrentSet(setName) + } relRoot, err := filepath.Rel(root, setPath) if err != nil { relRoot = setName @@ -106,8 +123,7 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error { existing[m.RelPath] = m } - // First pass: gather images per directory so we can pair them with audio files. - // Key: parent dir path (absolute); Value: relative path to the first image found there. + // First pass: gather images per directory. coverImages := make(map[string]string) _ = s.fs.WalkDir(setPath, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || !isImageFile(path) { @@ -121,7 +137,7 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error { return nil }) - // Second pass: walk set directory recursively for NEW media files. + // Second pass: walk for NEW media files. walkErr := s.fs.WalkDir(setPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return fmt.Errorf("walk %q: %w", path, err) @@ -147,7 +163,6 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error { meta, err := s.prober.Probe(ctx, path) if err != nil { - // Skip unprobeable/corrupt files instead of aborting the whole scan. fmt.Printf("[scanner] skipping unprobeable file %q: %v\n", path, err) return nil } @@ -163,12 +178,10 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error { thumbName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".jpg" thumbnailPath = filepath.Join(thumbDir, thumbName) if err := s.thumbGen.Generate(ctx, path, thumbnailPath, meta.Duration); err != nil { - // Skip thumbnail generation errors (e.g., corrupt or audio-only video files). fmt.Printf("[scanner] skipping thumbnail for %q: %v\n", path, err) thumbnailPath = "" } } else if mediaType == model.MediaTypeAudio { - // Use a sibling or ancestor image as the cover/thumbnail if one exists. thumbnailPath = findCoverImage(path, coverImages, setPath) } @@ -190,6 +203,9 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error { if _, err := s.store.CreateMedia(ctx, media); err != nil { return fmt.Errorf("create media %q: %w", path, err) } + if progress != nil { + progress.IncrementFile() + } return nil }) if walkErr != nil { @@ -212,8 +228,7 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error { return nil } -// findCoverImage walks up from a file's directory toward the set root, -// returning the first cover image found. +// findCoverImage walks up from a file's directory toward the set root. func findCoverImage(filePath string, coverImages map[string]string, setPath string) string { for dir := filepath.Dir(filePath); len(dir) >= len(setPath); dir = filepath.Dir(dir) { if coverRel, ok := coverImages[dir]; ok { @@ -228,7 +243,7 @@ func findCoverImage(filePath string, coverImages map[string]string, setPath stri var mediaExtensions = map[string]struct{}{ ".mp4": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".webm": {}, - ".mp3": {}, ".flac": {}, ".wav": {}, ".aac": {}, ".ogg": {}, ".m4a": {}, ".opus": {}, + ".mp3": {}, ".flac": {}, ".wav": {}, ".aac": {}, ".ogg": {}, ".m4a": {}, ".opus": {}, ".m4b": {}, } // imageExtensions lists file extensions recognized as cover/artwork images. @@ -248,7 +263,6 @@ func isImageFile(path string) bool { func isMediaFile(path string) bool { base := filepath.Base(path) - // Skip macOS resource fork files (._*) if strings.HasPrefix(base, "._") { return false } diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 1a105ec..8cc0d27 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -117,7 +117,7 @@ func TestFSScanner_Scan(t *testing.T) { store := repository.NewMockStore() store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { return nil, nil } s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs) - if err := s.Scan(ctx, "/media"); err != nil { + if err := s.Scan(ctx, "/media", nil); err != nil { t.Fatalf("unexpected error: %v", err) } }) @@ -180,7 +180,7 @@ func TestFSScanner_Scan(t *testing.T) { } s := newTestScanner(store, prober, gen, clk, mfs) - if err := s.Scan(ctx, "/media"); err != nil { + if err := s.Scan(ctx, "/media", nil); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -242,7 +242,7 @@ func TestFSScanner_Scan(t *testing.T) { } s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs) - if err := s.Scan(ctx, "/media"); err != nil { + if err := s.Scan(ctx, "/media", nil); err != nil { t.Fatalf("unexpected error: %v", err) } if created != 0 { @@ -282,7 +282,7 @@ func TestFSScanner_Scan(t *testing.T) { } s := newTestScanner(store, prober, &thumb.MockGenerator{}, clk, mfs) - if err := s.Scan(ctx, "/media"); err != nil { + if err := s.Scan(ctx, "/media", nil); err != nil { t.Fatalf("unexpected error: %v", err) } if created.RelPath != filepath.Join("season1", "ep1.mp4") { @@ -293,7 +293,7 @@ func TestFSScanner_Scan(t *testing.T) { t.Run("read dir error", func(t *testing.T) { mfs := &mockFS{entries: map[string][]os.DirEntry{}} s := newTestScanner(repository.NewMockStore(), &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs) - err := s.Scan(ctx, "/media") + err := s.Scan(ctx, "/media", nil) if err == nil { t.Fatal("expected error for missing root") } @@ -315,7 +315,7 @@ func TestFSScanner_Scan(t *testing.T) { store.MediaRepo.ListMediaFunc = func(_ context.Context, filter repository.MediaFilter) ([]model.Media, error) { return nil, nil } s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs) - err := s.Scan(ctx, "/media") + err := s.Scan(ctx, "/media", nil) if err == nil { t.Fatal("expected error for stat failure") } @@ -346,7 +346,7 @@ func TestFSScanner_Scan(t *testing.T) { s := newTestScanner(store, prober, &thumb.MockGenerator{}, clk, mfs) // Unprobeable files are skipped with a log instead of failing the whole scan. - err := s.Scan(ctx, "/media") + err := s.Scan(ctx, "/media", nil) if err != nil { t.Fatalf("unexpected error for probe failure; expected skip, got: %v", err) } @@ -386,7 +386,7 @@ func TestFSScanner_Scan(t *testing.T) { s := newTestScanner(store, prober, gen, clk, mfs) // Thumbnail generation errors are skipped so the scan continues. - err := s.Scan(ctx, "/media") + err := s.Scan(ctx, "/media", nil) if err != nil { t.Fatalf("unexpected error for thumbnail failure; expected skip, got: %v", err) } @@ -406,7 +406,7 @@ func TestFSScanner_Scan(t *testing.T) { store.MediaRepo.ListMediaFunc = func(_ context.Context, _ repository.MediaFilter) ([]model.Media, error) { return nil, nil } s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs) - err := s.Scan(ctx, "/media") + err := s.Scan(ctx, "/media", nil) if err == nil { t.Fatal("expected error for walk failure") } diff --git a/internal/service/admin.go b/internal/service/admin.go index 673bf37..ebae686 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -45,7 +45,7 @@ func (s *adminService) TriggerRescan(ctx context.Context) error { go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() - if err := s.scanner.Scan(ctx, s.mediaRoot); err != nil { + if err := s.scanner.Scan(ctx, s.mediaRoot, nil); err != nil { fmt.Printf("[rescan] scan failed: %v\n", err) } else { fmt.Printf("[rescan] scan completed\n") diff --git a/internal/service/admin_test.go b/internal/service/admin_test.go index ac73ce2..d4be649 100644 --- a/internal/service/admin_test.go +++ b/internal/service/admin_test.go @@ -10,12 +10,12 @@ import ( ) type fakeScanner struct { - scanFunc func(ctx context.Context, root string) error + scanFunc func(ctx context.Context, root string, progress *model.ScanProgress) error } -func (f *fakeScanner) Scan(ctx context.Context, root string) error { +func (f *fakeScanner) Scan(ctx context.Context, root string, progress *model.ScanProgress) error { if f.scanFunc != nil { - return f.scanFunc(ctx, root) + return f.scanFunc(ctx, root, progress) } return nil } @@ -59,7 +59,7 @@ func TestAdminService_TriggerRescan(t *testing.T) { var scannedRoot string done := make(chan struct{}) sc := &fakeScanner{ - scanFunc: func(_ context.Context, root string) error { + scanFunc: func(_ context.Context, root string, _ *model.ScanProgress) error { scannedRoot = root close(done) return nil @@ -80,7 +80,7 @@ func TestAdminService_TriggerRescan_Error(t *testing.T) { ctx := context.Background() done := make(chan struct{}) sc := &fakeScanner{ - scanFunc: func(_ context.Context, _ string) error { + scanFunc: func(_ context.Context, _ string, _ *model.ScanProgress) error { close(done) return errors.New("scan failed") }, @@ -1,22 +1,26 @@ #!/usr/bin/env sh # Quick-start script for Player testing -# Usage: ./start.sh [restart] +# Usage: ./start.sh [restart|stop|status] set -e MEDIA_ROOT="${MEDIA_ROOT:-/data/nfs/earthdata/playtest}" DB_PATH="${DB_PATH:-/tmp/playtest.db}" PORT="${PORT:-18080}" -PIDFILE="/tmp/play.pid" -COOKIE="/tmp/play_cookies.txt" +PIDFILE="/tmp/player.pid" +COOKIE="/tmp/player_cookies.txt" +LOGFILE="/tmp/player.log" BASEURL="http://localhost:${PORT}" +BINARY="${BINARY:-./player}" stop() { if [ -f "$PIDFILE" ]; then pid=$(cat "$PIDFILE") if kill -0 "$pid" 2>/dev/null; then echo "Stopping server (PID $pid)..." - kill -9 "$pid" 2>/dev/null || true + kill -TERM "$pid" 2>/dev/null || true + sleep 1 + kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true fi rm -f "$PIDFILE" fi @@ -47,10 +51,6 @@ case "${1:-run}" in stop exit 0 ;; - restart) - stop - sleep 1 - ;; status) if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then echo "Running (PID $(cat "$PIDFILE"))" @@ -64,38 +64,34 @@ esac cd "$(dirname "$0")" # Build if needed -if [ ! -x ./play ]; then +if [ ! -x "$BINARY" ]; then echo "Building player binary..." - go build -o play ./cmd/mediaplayer + go build -o player ./cmd/mediaplayer fi -# Check if already running -if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then - echo "Server already running (PID $(cat "$PIDFILE")) at ${BASEURL}" - bootstrap_auth - if needs_rescan; then - echo "Library empty — triggering rescan..." - curl -fs -X POST "${BASEURL}/api/admin/rescan" -b "$COOKIE" > /dev/null 2>&1 || true - fi - echo "Ready. Open ${BASEURL} in your browser." - echo "Login: test / test123" - exit 0 +stop + +# Restart action backgrounds; default run blocks in foreground. +BACKGROUND=false +if [ "${1:-run}" = "restart" ]; then + BACKGROUND=true fi -# Start server -MEDIA_ROOT="$MEDIA_ROOT" DB_PATH="$DB_PATH" PORT="$PORT" \ - exec ./play > /tmp/play.log 2>&1 & -echo "$!" > "$PIDFILE" -echo "Server started (PID $!) at ${BASEURL}" +# Start server in foreground so Ctrl+C kills it directly. +export MEDIA_ROOT DB_PATH PORT LOG_LEVEL="${LOG_LEVEL:-debug}" +"$BINARY" & +SERVER_PID=$! +echo "$SERVER_PID" > "$PIDFILE" +echo "Server started (PID $SERVER_PID) at ${BASEURL}" sleep 2 bootstrap_auth if needs_rescan; then - echo "Library empty — triggering rescan..." + echo "Library empty -- triggering rescan..." curl -fs -X POST "${BASEURL}/api/admin/rescan" -b "$COOKIE" > /dev/null 2>&1 || true - echo "Rescan running in background (check tail -f /tmp/play.log)" + echo "Rescan running in background (check tail -f $LOGFILE)" fi echo "" @@ -103,7 +99,23 @@ echo "Ready. Open ${BASEURL} in your browser." echo "Login: test / test123" echo "" echo "Quick tips:" -echo " m — show/hide sets sidebar" -echo " t — show/hide toolbar" -echo " / — search" -echo " ? — help" +echo " m -- show/hide sets sidebar" +echo " t -- show/hide toolbar" +echo " / -- search" +echo " ? -- help" + +if [ "$BACKGROUND" = "true" ]; then + exit 0 +fi + +# Foreground mode: trap ctrl+c so when this script exits,the player also stops. +shutdown_server() { + echo "" + echo "Shutting down..." + kill -TERM "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + rm -f "$PIDFILE" + exit 0 +} +trap shutdown_server INT TERM EXIT +wait "$SERVER_PID" diff --git a/web/css/layout.css b/web/css/layout.css index 006f874..7f0e19e 100644 --- a/web/css/layout.css +++ b/web/css/layout.css @@ -78,6 +78,12 @@ body { color: var(--accent); font-weight: 600; } +.sidebar .set-item.selected { + border: 2px solid var(--accent); + background: var(--bg-elevated); + color: var(--text-primary); + font-weight: 600; +} .sidebar-header { display: flex; align-items: center; @@ -176,9 +182,21 @@ body { } .media-card .card-actions { + position: absolute; + bottom: 0.35rem; + left: 0; + right: 0; display: flex; gap: 0.25rem; - padding: 0 0.5rem 0.5rem; + justify-content: center; + opacity: 0; + transition: opacity var(--transition-fast); + z-index: 2; +} +.media-card:hover .card-actions, +.media-card.selected .card-actions, +.media-card.playing .card-actions { + opacity: 1; } /* Audio row variant */ @@ -239,6 +257,17 @@ body { background: var(--bg-elevated); } +/* Hide row action buttons unless hovered/selected/playing */ +.media-row button[data-action] { + opacity: 0; + transition: opacity var(--transition-fast); +} +.media-row:hover button[data-action], +.media-row.selected button[data-action], +.media-row.playing button[data-action] { + opacity: 1; +} + /* Modal */ .modal-overlay { position: fixed; diff --git a/web/css/player.css b/web/css/player.css index 29baf30..004e2bd 100644 --- a/web/css/player.css +++ b/web/css/player.css @@ -53,6 +53,15 @@ max-height: 100%; } +/* Cover art for audio playback */ +.player .cover-art { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + background: var(--player-stage-bg); +} + /* Overlay controls */ .player .controls { display: flex; diff --git a/web/index.html b/web/index.html index 33478ad..6152814 100644 --- a/web/index.html +++ b/web/index.html @@ -73,13 +73,15 @@ <!-- Player: hidden until something plays --> <aside id="player" class="player" aria-label="Player"> <div class="stage"> - <video id="media-video" playsinline controlslist="nodownload"></video> + <video id="media-video" playsinline preload="none" controlslist="nodownload"></video> <audio id="media-audio" class="hidden"></audio> + <img id="cover-art" class="cover-art hidden" alt="Cover" loading="lazy"> <div id="big-play" class="big-play" role="button" aria-label="Play" tabindex="0"> <div class="play-icon">▶</div> </div> </div> <div class="controls"> + <button id="btn-toggle-stage" class="icon-btn" aria-label="Toggle stage (c)">⛶</button> <button id="btn-play" class="icon-btn" aria-label="Play/Pause (Space/p)">▶</button> <div class="time" id="time-elapsed">0:00</div> <div id="progress-track" class="progress-track" tabindex="0" aria-label="Seek"> @@ -116,6 +118,7 @@ <tr><td><kbd>Enter</kbd></td><td>Open selected media</td></tr> <tr><td><kbd>Space</kbd> <kbd>p</kbd></td><td>Play / pause</td></tr> <tr><td><kbd>f</kbd></td><td>Toggle fullscreen</td></tr> + <tr><td><kbd>c</kbd></td><td>Collapse / expand player stage</td></tr> <tr><td><kbd>Esc</kbd></td><td>Exit fullscreen, deselect item</td></tr> <tr><td><kbd>r</kbd></td><td>Toggle shuffle</td></tr> <tr><td><kbd>s</kbd></td><td>Share selected media</td></tr> diff --git a/web/js/app.js b/web/js/app.js index e4150ad..8f8ca84 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -1,7 +1,7 @@ import { API } from './api.js'; import { initKeyboard } from './keyboard.js'; -import { initSelection, select, selectByElement, next, prev, currentIndex, currentElement } from './selection.js'; -import { initPlayer, togglePlay, toggleFullscreen, exitFullscreenIfNeeded, currentMediaId, selectAndPlay, playPrevious, playNext } from './player.js'; +import { initSelection, select, selectByElement, next, prev, currentIndex, currentElement, navUp, navDown, navLeft, navRight } from './selection.js'; +import { initPlayer, togglePlay, toggleFullscreen, toggleStage, exitFullscreenIfNeeded, currentMediaId, selectAndPlay, playPrevious, playNext } from './player.js'; import { initSearch, focusSearch, trigger as triggerSearch } from './search.js'; import { initShuffle, toggle as toggleShuffle, isOn as isShuffle } from './shuffle.js'; import { initThemes } from './themes.js'; @@ -82,16 +82,25 @@ async function initApp() { onChange: () => loadMedia(), }); initKeyboard({ - navUp: () => prev(), - navDown: () => next(), - navLeft: () => setSetByDelta(-1), - navRight: () => setSetByDelta(1), + navUp: () => navUp(), + navDown: () => navDown(), + navLeft: () => navLeft(), + navRight: () => navRight(), + nextSet: () => setSetByDelta(1), + prevSet: () => setSetByDelta(-1), + isSidebarOpen: () => document.getElementById('sidebar')?.classList.contains('open'), + isSidebarFocused: () => { + const sidebar = document.getElementById('sidebar'); + return sidebar?.contains(document.activeElement); + }, + toggleSetSelect: () => toggleSetSelection(), enter: () => { const el = currentElement(); if (el) { selectByElement(el); playSelected(); } }, playPause: () => togglePlay(), fullscreen: () => toggleFullscreen(), + toggleStage: () => toggleStage(), escape: () => { exitFullscreenIfNeeded(); const el = currentElement(); if (el) el.classList.remove('selected'); closeAllModals(); }, shuffle: () => { toggleShuffle(); loadMedia(); }, share: () => shareSelected(), @@ -164,16 +173,20 @@ function renderSets() { const el = document.getElementById('set-list'); if (!el) return; el.innerHTML = state.sets.map((s) => - `<div class="set-row"> - <a href="#" class="set-item flex-1-18" data-id="${s.id}">${escapeHtml(s.name)}</a> + `<div class="set-row" tabindex="0" role="button" data-id="${s.id}" aria-label="Set ${escapeHtml(s.name)}"> + <span class="set-item flex-1-18" data-id="${s.id}">${escapeHtml(s.name)}</span> <button class="icon-btn btn-sm" data-cover-set="${s.id}" title="Regenerate cover">🔄</button> </div>` ).join(''); - el.querySelectorAll('.set-item').forEach((a) => { - a.addEventListener('click', (e) => { + el.querySelectorAll('.set-row').forEach((row) => { + row.addEventListener('click', (e) => { + if (e.target.closest('[data-cover-set]')) return; e.preventDefault(); - state.selectedSetId = parseInt(a.dataset.id, 10); - setActiveSet(state.selectedSetId); + // Single-select on click + const id = parseInt(row.dataset.id, 10); + state.selectedSetIds = [id]; + state.selectedSetId = id; + updateSetRowsUI(); loadMedia(); }); }); @@ -185,6 +198,39 @@ function renderSets() { catch (err) { toast(err.message || 'Cover failed', 'error'); } }); }); + updateSetRowsUI(); +} + +function updateSetRowsUI() { + document.querySelectorAll('#set-list .set-row').forEach((row) => { + const id = parseInt(row.dataset.id, 10); + const item = row.querySelector('.set-item'); + if (!item) return; + item.classList.toggle('active', id === state.selectedSetId); + item.classList.toggle('selected', state.selectedSetIds.includes(id)); + }); +} + +function toggleSetSelection() { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) return; + const focused = document.activeElement.closest('.set-row'); + if (!focused) return; + const id = parseInt(focused.dataset.id, 10); + const idx = state.selectedSetIds.indexOf(id); + if (idx >= 0) { + state.selectedSetIds.splice(idx, 1); + } else { + state.selectedSetIds.push(id); + } + // Also sync primary selectedSetId for media loading + if (state.selectedSetIds.length) { + state.selectedSetId = state.selectedSetIds[state.selectedSetIds.length - 1]; + } else { + state.selectedSetId = null; + } + updateSetRowsUI(); + loadMedia(); } function setActiveSet(id) { @@ -197,6 +243,7 @@ function setSetByDelta(dx) { if (idx < 0) idx = 0; const nextIdx = (idx + dx + state.sets.length) % state.sets.length; state.selectedSetId = state.sets[nextIdx].id; + state.selectedSetIds = [state.selectedSetId]; setActiveSet(state.selectedSetId); loadMedia(); } @@ -209,8 +256,11 @@ async function loadMedia() { hint?.classList.add('hidden'); grid.innerHTML = '<p class="text-muted text-sm grid-full">Loading...</p>'; try { + const setIds = state.selectedSetIds.length > 1 ? state.selectedSetIds.join(',') : ''; + const singleSetId = state.selectedSetIds.length === 1 ? String(state.selectedSetIds[0]) : (state.selectedSetId ?? ''); const params = { - set_id: state.selectedSetId ?? '', + set_id: setIds ? '' : singleSetId, + set_ids: setIds, type: state.filters.type, search: state.filters.search, favorites: state.filters.favorites ? 'true' : '', @@ -262,19 +312,19 @@ function renderItem(m, index) { <div class="thumb-wrap"> ${m.thumbnail_path ? `<img src="/api/media/${m.id}/thumbnail" alt="" loading="lazy">` : `<span class="placeholder">No image</span>`} <span class="badge">${fmtDur(m.duration)}${sizeText ? ' • ' + sizeText : ''}</span> + <div class="card-actions"> + <button class="icon-btn btn-sm" data-action="play" title="Play">▶</button> + <button class="icon-btn btn-sm" data-action="favorite" title="Favorite">♥</button> + <button class="icon-btn btn-sm" data-action="notes" title="Notes">📝</button> + <button class="icon-btn btn-sm" data-action="download" title="Download">⬇</button> + <button class="icon-btn btn-sm" data-action="tags" title="Tags">🏷</button> + <butto |
