summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-01 15:57:13 +0300
committerPaul Buetow <paul@buetow.org>2026-05-01 15:57:13 +0300
commit65ee2e4d7f2be8036457e3cdca0e26e09eb56e0b (patch)
treecbd8143db9e81baed51239a628dfedae2dbf375f
parent56666ca87f11e72521dcda00de72438e13899b95 (diff)
add readme and so on
-rw-r--r--.gitignore2
-rw-r--r--AGENTS.md6
-rw-r--r--Dockerfile6
-rw-r--r--Magefile.go2
-rw-r--r--README.md5
-rw-r--r--internal/api/handlers.go49
-rw-r--r--internal/repository/media.go6
-rw-r--r--internal/repository/repository.go1
-rw-r--r--internal/scanner/scanner.go42
-rw-r--r--internal/scanner/scanner_test.go18
-rw-r--r--internal/service/admin.go2
-rw-r--r--internal/service/admin_test.go10
-rwxr-xr-xstart.sh76
-rw-r--r--web/css/layout.css31
-rw-r--r--web/css/player.css9
-rw-r--r--web/index.html5
-rw-r--r--web/js/app.js92
-rw-r--r--web/js/keyboard.js31
-rw-r--r--web/js/player.js20
-rw-r--r--web/js/selection.js70
-rw-r--r--web/js/state.js1
21 files changed, 386 insertions, 98 deletions
diff --git a/.gitignore b/.gitignore
index 4b6e52d..dd0bad5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,6 @@
# Binaries
mediaplayer
-play
+player
*.exe
*.dll
*.so
diff --git a/AGENTS.md b/AGENTS.md
index bc84a36..37026a5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 |
diff --git a/Dockerfile b/Dockerfile
index 5654691..84dd02f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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")
},
diff --git a/start.sh b/start.sh
index 1d777cf..f218d32 100755
--- a/start.sh
+++ b/start.sh
@@ -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