summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/api/handlers.go4
-rw-r--r--internal/api/handlers_admin.go7
-rw-r--r--internal/api/handlers_test.go27
-rw-r--r--internal/api/server.go2
-rw-r--r--internal/scanner/scanner.go17
-rw-r--r--internal/service/admin.go10
-rw-r--r--internal/service/media_browse.go50
-rw-r--r--internal/service/media_test.go62
-rw-r--r--internal/service/mock.go23
-rw-r--r--internal/service/service.go23
-rwxr-xr-xstart.sh13
-rw-r--r--web/css/layout.css34
-rw-r--r--web/css/player.css87
-rw-r--r--web/detach.html42
-rw-r--r--web/index.html17
-rw-r--r--web/js/api.js1
-rw-r--r--web/js/app.js35
-rw-r--r--web/js/detach.js198
-rw-r--r--web/js/keyboard.js4
-rw-r--r--web/js/player.js209
20 files changed, 795 insertions, 70 deletions
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 41c4197..0436247 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -103,6 +103,10 @@ func (s *Server) serveBootstrap(w http.ResponseWriter, r *http.Request) {
s.serveFile(w, r, "bootstrap.html")
}
+func (s *Server) serveDetach(w http.ResponseWriter, r *http.Request) {
+ s.serveFile(w, r, "detach.html")
+}
+
// ------------------------------------------------------------------
// File serving helpers
// ------------------------------------------------------------------
diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go
index 246a466..e9935d4 100644
--- a/internal/api/handlers_admin.go
+++ b/internal/api/handlers_admin.go
@@ -33,6 +33,13 @@ func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+func (s *Server) handleScanProgress(w http.ResponseWriter, r *http.Request) {
+ if !requireService(w, s.adminSvc) {
+ return
+ }
+ writeJSON(w, http.StatusOK, s.adminSvc.ScanProgress(r.Context()))
+}
+
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
if !requireService(w, s.adminSvc) {
return
diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go
index 2201897..0003b88 100644
--- a/internal/api/handlers_test.go
+++ b/internal/api/handlers_test.go
@@ -679,8 +679,8 @@ func TestServer_MediaList(t *testing.T) {
},
{
name: "with query params",
- query: "?set_id=1&type=video&search=foo&tags=bar,baz&favorites=true&min_duration=10&max_duration=100&sort=name&limit=5&offset=10",
- filter: repository.MediaFilter{SetID: intPtr(1), Type: (*model.MediaType)(func() *string { s := "video"; return &s }()), Search: "foo", Tags: []string{"bar", "baz"}, Favorites: true, MinDuration: floatPtr(10), MaxDuration: floatPtr(100), Sort: "name", Limit: 5, Offset: 10},
+ query: "?set_id=1&type=video&search=foo&tags=bar,baz&favorites=true&min_duration=10&max_duration=100&sort=name&limit=5&offset=10",
+ filter: repository.MediaFilter{SetID: intPtr(1), Type: (*model.MediaType)(func() *string { s := "video"; return &s }()), Search: "foo", Tags: []string{"bar", "baz"}, Favorites: true, MinDuration: floatPtr(10), MaxDuration: floatPtr(100), Sort: "name", Limit: 5, Offset: 10},
listResult: []model.Media{},
wantCode: http.StatusOK,
},
@@ -716,14 +716,14 @@ func TestServer_MediaList(t *testing.T) {
func TestServer_MediaDetail(t *testing.T) {
tests := []struct {
- name string
- id string
- result *service.MediaDetail
- err error
- wantCode int
- wantMedia bool
- wantResumeFrom float64
- wantProgressNil bool
+ name string
+ id string
+ result *service.MediaDetail
+ err error
+ wantCode int
+ wantMedia bool
+ wantResumeFrom float64
+ wantProgressNil bool
}{
{
name: "ok with progress",
@@ -762,8 +762,8 @@ func TestServer_MediaDetail(t *testing.T) {
return
}
var resp struct {
- Media *model.Media `json:"media"`
- Progress *model.PlaybackProgress `json:"progress"`
+ Media *model.Media `json:"media"`
+ Progress *model.PlaybackProgress `json:"progress"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal detail: %v", err)
@@ -1083,7 +1083,7 @@ func TestServer_Shares(t *testing.T) {
},
GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) {
return &service.GetSharedMediaResult{
- Media: &service.SharedMediaView{ID: 1, FileName: "x.mp4", Type: model.MediaTypeVideo, Duration: 120},
+ Media: &service.SharedMediaView{ID: 1, FileName: "x.mp4", Type: model.MediaTypeVideo, Duration: 120},
StreamURL: "/s/abc/stream",
ThumbURL: "/s/abc/thumbnail",
}, nil
@@ -1190,6 +1190,7 @@ func TestServer_AdminRoutes(t *testing.T) {
}{
{"list trash", "GET", "/api/admin/trash", "", http.StatusOK},
{"rescan", "POST", "/api/admin/rescan", "", http.StatusOK},
+ {"scan progress", "GET", "/api/admin/scan-progress", "", http.StatusOK},
{"list users", "GET", "/api/admin/users", "", http.StatusOK},
{"create user", "POST", "/api/admin/users", `{"username":"bob","password":"pass","is_admin":false}`, http.StatusOK},
{"delete user", "DELETE", "/api/admin/users/2", "", http.StatusOK},
diff --git a/internal/api/server.go b/internal/api/server.go
index 4d18d76..11518df 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -107,6 +107,7 @@ func (s *Server) routes() {
s.mux.Handle("/bootstrap.html", http.HandlerFunc(s.serveBootstrap))
s.mux.Handle("/", s.mw.RequireSession(http.HandlerFunc(s.serveIndex)))
s.mux.Handle("GET /index.html", s.mw.RequireSession(http.HandlerFunc(s.serveIndex)))
+ s.mux.Handle("GET /detach.html", s.mw.RequireSession(http.HandlerFunc(s.serveDetach)))
// Session-required routes
s.mux.Handle("POST /api/logout", s.mw.RequireSession(http.HandlerFunc(s.handleLogout)))
@@ -148,6 +149,7 @@ func (s *Server) routes() {
// Admin routes
s.mux.Handle("GET /api/admin/trash", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleListTrash))))
s.mux.Handle("POST /api/admin/rescan", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleRescan))))
+ s.mux.Handle("GET /api/admin/scan-progress", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleScanProgress))))
s.mux.Handle("GET /api/admin/users", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleListUsers))))
s.mux.Handle("POST /api/admin/users", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleCreateUser))))
s.mux.Handle("DELETE /api/admin/users/{id}", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleDeleteUser))))
diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go
index 15d0d53..4372f91 100644
--- a/internal/scanner/scanner.go
+++ b/internal/scanner/scanner.go
@@ -59,6 +59,7 @@ func (s *FSScanner) Scan(ctx context.Context, root string, progress *model.ScanP
if progress != nil {
progress.Start(setCount)
}
+ fmt.Printf("[scanner] scan started root=%q sets=%d\n", root, setCount)
for _, entry := range entries {
if !entry.IsDir() {
@@ -72,11 +73,13 @@ func (s *FSScanner) Scan(ctx context.Context, root string, progress *model.ScanP
progress.IncrementSet()
}
}
+ fmt.Printf("[scanner] scan finished root=%q sets=%d\n", root, setCount)
return nil
}
func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress *model.ScanProgress) error {
setName := filepath.Base(setPath)
+ fmt.Printf("[scanner] set started name=%q path=%q\n", setName, setPath)
if progress != nil {
progress.SetCurrentSet(setName)
}
@@ -122,6 +125,7 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
for _, m := range mediaList {
existing[m.RelPath] = m
}
+ newFiles := 0
// First pass: gather images per directory.
coverImages := make(map[string]string)
@@ -153,7 +157,12 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
return fmt.Errorf("rel path for %q: %w", path, err)
}
relPath = filepath.ToSlash(relPath)
- if _, alreadyExists := existing[relPath]; alreadyExists {
+ _, alreadyExists := existing[relPath]
+ fmt.Printf("[scanner] file set=%q path=%q existing=%t\n", setName, relPath, alreadyExists)
+ if progress != nil {
+ progress.IncrementFile()
+ }
+ if alreadyExists {
return nil
}
@@ -204,8 +213,9 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
if _, err := s.store.CreateMedia(ctx, media); err != nil {
return fmt.Errorf("create media %q: %w", path, err)
}
- if progress != nil {
- progress.IncrementFile()
+ newFiles++
+ if newFiles == 1 || newFiles%25 == 0 {
+ fmt.Printf("[scanner] set progress name=%q new_media=%d latest=%q\n", setName, newFiles, relPath)
}
return nil
})
@@ -226,6 +236,7 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress
}
}
+ fmt.Printf("[scanner] set completed name=%q existing_media=%d new_media=%d\n", setName, len(existing), newFiles)
return nil
}
diff --git a/internal/service/admin.go b/internal/service/admin.go
index ebae686..9b32dd3 100644
--- a/internal/service/admin.go
+++ b/internal/service/admin.go
@@ -19,6 +19,7 @@ type adminService struct {
hasher auth.Hasher
scanner scanner.Scanner
mediaRoot string
+ progress *model.ScanProgress
}
// NewAdminService creates a concrete AdminService.
@@ -29,6 +30,7 @@ func NewAdminService(store repository.AdminServiceStore, clk clock.Clock, hasher
hasher: hasher,
scanner: sc,
mediaRoot: mediaRoot,
+ progress: &model.ScanProgress{},
}
}
@@ -45,15 +47,21 @@ 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, nil); err != nil {
+ if err := s.scanner.Scan(ctx, s.mediaRoot, s.progress); err != nil {
+ s.progress.Done(err)
fmt.Printf("[rescan] scan failed: %v\n", err)
} else {
+ s.progress.Done(nil)
fmt.Printf("[rescan] scan completed\n")
}
}()
return nil
}
+func (s *adminService) ScanProgress(ctx context.Context) model.ScanProgress {
+ return s.progress.Copy()
+}
+
func (s *adminService) ListUsers(ctx context.Context) ([]model.User, error) {
return s.store.ListUsers(ctx)
}
diff --git a/internal/service/media_browse.go b/internal/service/media_browse.go
index 79c9da1..0e3a7de 100644
--- a/internal/service/media_browse.go
+++ b/internal/service/media_browse.go
@@ -270,13 +270,29 @@ func (s *mediaService) GetSetCover(ctx context.Context, setID int64, folder stri
coverPath := filepath.Join(filepath.Clean(baseDir), ".cover.jpg")
info, err := os.Stat(coverPath)
+ if err == nil {
+ return &FileResult{
+ Path: coverPath,
+ FileName: filepath.Base(coverPath),
+ FileSize: info.Size(),
+ }, nil
+ }
+
+ media, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID})
if err != nil {
+ return nil, fmt.Errorf("list media: %w", err)
+ }
+ candidate := randomFolderThumbnail(media, prefix)
+ if candidate == "" {
return nil, fmt.Errorf("stat cover: %w", err)
}
-
+ info, err = os.Stat(candidate)
+ if err != nil {
+ return nil, fmt.Errorf("stat thumbnail cover: %w", err)
+ }
return &FileResult{
- Path: coverPath,
- FileName: filepath.Base(coverPath),
+ Path: candidate,
+ FileName: filepath.Base(candidate),
FileSize: info.Size(),
}, nil
}
@@ -399,7 +415,8 @@ func (s *mediaService) BrowseSet(ctx context.Context, setID, userID int64, paren
subPath := filepath.Join(parent, name)
coverPath := filepath.Join(filepath.Clean(filepath.Join(s.mediaRoot, set.RootPath, filepath.FromSlash(subPath))), ".cover.jpg")
_, err := os.Stat(coverPath)
- folders = append(folders, BrowseFolder{Name: name, HasCover: err == nil})
+ hasCover := err == nil || randomFolderThumbnail(media, filepath.ToSlash(subPath)) != ""
+ folders = append(folders, BrowseFolder{Name: name, HasCover: hasCover})
}
}
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
@@ -410,3 +427,28 @@ func (s *mediaService) BrowseSet(ctx context.Context, setID, userID int64, paren
Media: items,
}, nil
}
+
+func randomFolderThumbnail(media []model.Media, folder string) string {
+ prefix := filepath.ToSlash(strings.Trim(folder, "/"))
+ if prefix != "" {
+ prefix += "/"
+ }
+ var candidates []string
+ for _, m := range media {
+ if m.DeletedAt != nil || m.ThumbnailPath == "" {
+ continue
+ }
+ rel := filepath.ToSlash(m.RelPath)
+ if prefix != "" && !strings.HasPrefix(rel, prefix) {
+ continue
+ }
+ if prefix == "" && strings.Contains(rel, "/") {
+ continue
+ }
+ candidates = append(candidates, m.ThumbnailPath)
+ }
+ if len(candidates) == 0 {
+ return ""
+ }
+ return candidates[mrand.Intn(len(candidates))]
+}
diff --git a/internal/service/media_test.go b/internal/service/media_test.go
index 27f93dc..4163066 100644
--- a/internal/service/media_test.go
+++ b/internal/service/media_test.go
@@ -999,7 +999,6 @@ func TestMediaService_StreamSharedMedia(t *testing.T) {
}
}
-
func TestMediaService_UploadMedia_ProbeAndThumbnail(t *testing.T) {
ctx := context.Background()
makeStore := func() *repository.MockStore {
@@ -1253,7 +1252,7 @@ func TestMediaService_ViewerCannotMutate(t *testing.T) {
return nil, nil
},
SoftDeleteMediaFunc: func(ctx context.Context, id int64) error { return nil },
- RestoreMediaFunc: func(ctx context.Context, id int64) error { return nil },
+ RestoreMediaFunc: func(ctx context.Context, id int64) error { return nil },
},
UserRepo: repository.MockUserRepo{
GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
@@ -1905,6 +1904,65 @@ func TestMediaService_RegenerateSetCover(t *testing.T) {
})
}
+func TestMediaService_FolderThumbnailFallback(t *testing.T) {
+ ctx := context.Background()
+ tmpDir := t.TempDir()
+ thumbPath := filepath.Join(tmpDir, "thumb.jpg")
+ if err := os.WriteFile(thumbPath, []byte("thumb"), 0o644); err != nil {
+ t.Fatalf("write thumb: %v", err)
+ }
+
+ set := &model.Set{ID: 1, RootPath: "library"}
+ media := []model.Media{
+ {ID: 1, SetID: 1, RelPath: "Rock/a.mp4", ThumbnailPath: thumbPath, Type: model.MediaTypeVideo},
+ {ID: 2, SetID: 1, RelPath: "Rock/b.mp4", Type: model.MediaTypeVideo},
+ }
+ store := &repository.MockStore{
+ SetRepo: repository.MockSetRepo{
+ GetSetByIDFunc: func(ctx context.Context, id int64) (*model.Set, error) {
+ if id == set.ID {
+ return set, nil
+ }
+ return nil, nil
+ },
+ },
+ UserRepo: repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ },
+ MediaRepo: repository.MockMediaRepo{
+ ListMediaFunc: func(ctx context.Context, filter repository.MediaFilter) ([]model.Media, error) {
+ return media, nil
+ },
+ },
+ }
+ svc := NewMediaService(store, newMockClock(), tmpDir, nil, nil)
+
+ t.Run("browse marks folder as having thumbnail", func(t *testing.T) {
+ res, err := svc.BrowseSet(ctx, 1, 1, "")
+ if err != nil {
+ t.Fatalf("browse set: %v", err)
+ }
+ if len(res.Folders) != 1 {
+ t.Fatalf("expected one folder, got %d", len(res.Folders))
+ }
+ if res.Folders[0].Name != "Rock" || !res.Folders[0].HasCover {
+ t.Fatalf("unexpected folder: %+v", res.Folders[0])
+ }
+ })
+
+ t.Run("cover endpoint falls back to media thumbnail", func(t *testing.T) {
+ fr, err := svc.GetSetCover(ctx, 1, "Rock", 1)
+ if err != nil {
+ t.Fatalf("get set cover: %v", err)
+ }
+ if fr.Path != thumbPath {
+ t.Fatalf("expected fallback thumb %q, got %q", thumbPath, fr.Path)
+ }
+ })
+}
+
func TestMediaService_GetThumbnail(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
diff --git a/internal/service/mock.go b/internal/service/mock.go
index 2743ac8..33be6b3 100644
--- a/internal/service/mock.go
+++ b/internal/service/mock.go
@@ -11,14 +11,14 @@ import (
)
var (
- _ MediaBrowseService = (*MockMediaService)(nil)
- _ MediaWriteService = (*MockMediaService)(nil)
- _ MediaShareService = (*MockMediaService)(nil)
- _ MediaTagService = (*MockMediaService)(nil)
- _ MediaFavoriteService = (*MockMediaService)(nil)
- _ MediaNoteService = (*MockMediaService)(nil)
- _ MediaService = (*MockMediaService)(nil)
- _ AuthService = (*MockAuthService)(nil)
+ _ MediaBrowseService = (*MockMediaService)(nil)
+ _ MediaWriteService = (*MockMediaService)(nil)
+ _ MediaShareService = (*MockMediaService)(nil)
+ _ MediaTagService = (*MockMediaService)(nil)
+ _ MediaFavoriteService = (*MockMediaService)(nil)
+ _ MediaNoteService = (*MockMediaService)(nil)
+ _ MediaService = (*MockMediaService)(nil)
+ _ AuthService = (*MockAuthService)(nil)
)
// MockMediaService is a fake MediaService for testing.
@@ -219,6 +219,7 @@ func (m *MockMediaService) DeleteNote(ctx context.Context, mediaID, userID int64
type MockAdminService struct {
ListTrashFunc func(ctx context.Context) ([]model.Media, error)
TriggerRescanFunc func(ctx context.Context) error
+ ScanProgressFunc func(ctx context.Context) model.ScanProgress
ListUsersFunc func(ctx context.Context) ([]model.User, error)
CreateUserFunc func(ctx context.Context, username, password string, isAdmin bool) (*model.User, error)
DeleteUserFunc func(ctx context.Context, id int64) error
@@ -239,6 +240,12 @@ func (m *MockAdminService) TriggerRescan(ctx context.Context) error {
}
return nil
}
+func (m *MockAdminService) ScanProgress(ctx context.Context) model.ScanProgress {
+ if m.ScanProgressFunc != nil {
+ return m.ScanProgressFunc(ctx)
+ }
+ return model.ScanProgress{}
+}
func (m *MockAdminService) ListUsers(ctx context.Context) ([]model.User, error) {
if m.ListUsersFunc != nil {
return m.ListUsersFunc(ctx)
diff --git a/internal/service/service.go b/internal/service/service.go
index b0be1f4..e31f6db 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -33,15 +33,15 @@ type MediaWriteService interface {
// BrowseFolder is a named folder within a set's directory tree.
type BrowseFolder struct {
- Name string `json:"name"`
- HasCover bool `json:"has_cover"`
+ Name string `json:"name"`
+ HasCover bool `json:"has_cover"`
}
// BrowseResult is the content of one directory inside a set.
type BrowseResult struct {
- CurrentPath string `json:"current_path"`
+ CurrentPath string `json:"current_path"`
Folders []BrowseFolder `json:"folders"`
- Media []model.Media `json:"media"`
+ Media []model.Media `json:"media"`
}
// SharedMediaView exposes only the metadata fields needed for a public share page.
@@ -121,6 +121,7 @@ type MediaService interface {
type AdminService interface {
ListTrash(ctx context.Context) ([]model.Media, error)
TriggerRescan(ctx context.Context) error
+ ScanProgress(ctx context.Context) model.ScanProgress
ListUsers(ctx context.Context) ([]model.User, error)
CreateUser(ctx context.Context, username, password string, isAdmin bool) (*model.User, error)
DeleteUser(ctx context.Context, id int64) error
@@ -148,8 +149,8 @@ type ProgressService interface {
// PermissionsMatrix is the shape returned by ListPermissions.
type PermissionsMatrix struct {
- Sets []model.Set `json:"sets"`
- Users []model.User `json:"users"`
+ Sets []model.Set `json:"sets"`
+ Users []model.User `json:"users"`
Permissions []model.SetPermission `json:"permissions"`
}
@@ -162,11 +163,11 @@ type FileResult struct {
// MediaDetail combines media with related data.
type MediaDetail struct {
- Media *model.Media `json:"media"`
- Tags []model.Tag `json:"tags"`
- Favorite bool `json:"favorite"`
- Note *model.Note `json:"note,omitempty"`
- Progress *model.PlaybackProgress `json:"progress,omitempty"`
+ Media *model.Media `json:"media"`
+ Tags []model.Tag `json:"tags"`
+ Favorite bool `json:"favorite"`
+ Note *model.Note `json:"note,omitempty"`
+ Progress *model.PlaybackProgress `json:"progress,omitempty"`
}
// ResumeFrom returns the saved playback position in seconds, or 0 if none.
diff --git a/start.sh b/start.sh
index 4887f54..b68a234 100755
--- a/start.sh
+++ b/start.sh
@@ -9,6 +9,7 @@ PORT="${PORT:-18080}"
PIDFILE="/tmp/player.pid"
COOKIE="/tmp/player_cookies.txt"
LOGFILE="/tmp/player.log"
+TAIL_PID=""
BASEURL="http://localhost:${PORT}"
BINARY="${BINARY:-./player}"
@@ -70,10 +71,12 @@ fi
# Start server in foreground so Ctrl+C kills it directly.
export MEDIA_ROOT DB_PATH PORT LOG_LEVEL="${LOG_LEVEL:-debug}"
-"$BINARY" &
+: > "$LOGFILE"
+"$BINARY" > "$LOGFILE" 2>&1 &
SERVER_PID=$!
echo "$SERVER_PID" > "$PIDFILE"
echo "Server started (PID $SERVER_PID) at ${BASEURL}"
+echo "Server log: $LOGFILE"
sleep 2
@@ -83,6 +86,11 @@ echo "Library rescan starting..."
curl -fs -X POST "${BASEURL}/api/admin/rescan" -b "$COOKIE" > /dev/null 2>&1 || true
echo "Rescan running in background (check tail -f $LOGFILE)"
+if [ "$BACKGROUND" != "true" ]; then
+ tail -n +1 -f "$LOGFILE" &
+ TAIL_PID=$!
+fi
+
echo ""
echo "Ready. Open ${BASEURL} in your browser."
echo "Login: test / test123"
@@ -101,6 +109,9 @@ fi
shutdown_server() {
echo ""
echo "Shutting down..."
+ if [ -n "$TAIL_PID" ]; then
+ kill "$TAIL_PID" 2>/dev/null || true
+ fi
kill -TERM "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
rm -f "$PIDFILE"
diff --git a/web/css/layout.css b/web/css/layout.css
index e52a083..73fd9f5 100644
--- a/web/css/layout.css
+++ b/web/css/layout.css
@@ -32,6 +32,40 @@ body {
}
.site-header .spacer { flex: 1 1 auto; }
+.scan-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ max-width: min(22rem, 45vw);
+ padding: 0.25rem 0.55rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-elevated);
+ color: var(--text-secondary);
+ font-size: 0.78rem;
+ white-space: nowrap;
+}
+.scan-indicator.hidden {
+ display: none;
+}
+.scan-indicator span:last-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.scan-dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ flex: 0 0 auto;
+ border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 0 0 var(--accent-soft);
+ animation: scanPulse 1.4s ease-out infinite;
+}
+@keyframes scanPulse {
+ 0% { box-shadow: 0 0 0 0 var(--accent-soft); }
+ 100% { box-shadow: 0 0 0 0.45rem transparent; }
+}
+
/* Sidebar */
.sidebar {
position: fixed;
diff --git a/web/css/player.css b/web/css/player.css
index 5628dfd..f3d93ef 100644
--- a/web/css/player.css
+++ b/web/css/player.css
@@ -7,7 +7,7 @@
border-top: 1px solid var(--border);
z-index: 60;
display: none; /* hidden by default until something plays */
- transition: inset var(--transition-base);
+ transition: inset var(--transition-base), width var(--transition-base), height var(--transition-base), box-shadow var(--transition-base);
}
.player.open {
display: block;
@@ -15,8 +15,36 @@
.page.has-sidebar .player {
inset: auto 0 0 var(--sidebar-width);
}
-.page.has-sidebar .player.collapsed {
- inset: auto auto 0 0;
+
+/* Collapsed: floating bottom-left card sized to video aspect ratio */
+.player.collapsed {
+ inset: auto auto 1rem 1rem;
+ width: var(--collapsed-w, 320px);
+ height: var(--collapsed-h, 240px);
+ border-radius: 0.5rem;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.4);
+ border: 1px solid var(--border);
+ overflow: hidden;
+}
+.player.open.collapsed {
+ display: flex;
+ flex-direction: column;
+}
+.player.collapsed .stage {
+ flex: 1 1 auto;
+ height: auto !important;
+ overflow: hidden;
+}
+.player.collapsed .controls {
+ flex: 0 0 auto;
+ padding: 0.35rem 0.5rem;
+ gap: 0.35rem;
+ border-top: 1px solid var(--border);
+}
+.player.collapsed .big-play .play-icon {
+ width: 2rem;
+ height: 2rem;
+ font-size: 0.8rem;
}
.player.is-fullscreen {
inset: 0;
@@ -26,6 +54,35 @@
justify-content: center;
}
+.detach-page {
+ margin: 0;
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+ background: var(--bg-body);
+}
+
+/* Detached popup: fill the whole popup window */
+.player.detached {
+ position: relative;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: 0;
+ border: none;
+ box-shadow: none;
+ display: flex;
+ flex-direction: column;
+}
+.player.detached .stage {
+ flex: 1 1 auto;
+ height: auto !important;
+ overflow: hidden;
+}
+.player.detached .controls {
+ flex: 0 0 auto;
+}
+
.player .stage {
position: relative;
background: var(--player-stage-bg);
@@ -65,15 +122,6 @@
background: var(--player-stage-bg);
}
-/* Collapsed stage (controls still visible) */
-.player.collapsed .stage {
- height: 0 !important;
- overflow: hidden;
-}
-.player.collapsed .big-play {
- display: none;
-}
-
/* Overlay controls */
.player .controls {
display: flex;
@@ -156,6 +204,21 @@
accent-color: var(--accent);
}
+.detached-bar {
+ position: fixed;
+ inset: auto 0 0 0;
+ background: var(--player-bg);
+ color: var(--text-primary);
+ border-top: 1px solid var(--border);
+ z-index: 59;
+ padding: 0.5rem 0.75rem;
+ text-align: center;
+ font-size: 0.85rem;
+}
+.detached-bar.hidden {
+ display: none;
+}
+
/* Big play button */
.big-play {
position: absolute;
diff --git a/web/detach.html b/web/detach.html
new file mode 100644
index 0000000..7ecb154
--- /dev/null
+++ b/web/detach.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Player — Detached</title>
+<link rel="stylesheet" href="/css/theme.css">
+<link rel="stylesheet" href="/css/layout.css">
+<link rel="stylesheet" href="/css/components.css">
+<link rel="stylesheet" href="/css/player.css">
+<script type="module" src="/js/detach.js"></script>
+</head>
+<body class="detach-page">
+<aside id="player" class="player open detached" aria-label="Detached player">
+ <div class="stage">
+ <video id="media-video" playsinline 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">&#9654;</div>
+ </div>
+ </div>
+ <div class="controls">
+ <button id="btn-toggle-stage" class="icon-btn" aria-label="Toggle stage">&#9978;</button>
+ <button id="btn-play" class="icon-btn" aria-label="Play/Pause">&#9654;</button>
+ <div class="time" id="time-elapsed">0:00</div>
+ <div id="progress-track" class="progress-track" tabindex="0" aria-label="Seek">
+ <div id="progress-fill" class="progress-fill"></div>
+ <div id="progress-thumb" class="progress-thumb" aria-hidden="true"></div>
+ </div>
+ <div class="time" id="time-total">0:00</div>
+ <button id="btn-prev" class="icon-btn" aria-label="Previous">&#9198;</button>
+ <button id="btn-next" class="icon-btn" aria-label="Next">&#9197;</button>
+ <div class="volume-control">
+ <button id="btn-mute" class="icon-btn" aria-label="Mute">&#128266;</button>
+ <input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1" class="volume-slider" aria-label="Volume">
+ </div>
+ <button id="btn-fullscreen" class="icon-btn" aria-label="Fullscreen">&#9978;</button>
+ </div>
+</aside>
+</body>
+</html>
diff --git a/web/index.html b/web/index.html
index dc8f1f0..23ae0f0 100644
--- a/web/index.html
+++ b/web/index.html
@@ -15,6 +15,10 @@
<header class="site-header" role="banner">
<a href="/" class="logo">Player</a>
<div class="spacer"></div>
+ <div id="scan-indicator" class="scan-indicator hidden" role="status" aria-live="polite">
+ <span class="scan-dot" aria-hidden="true"></span>
+ <span id="scan-indicator-text">Scanning</span>
+ </div>
<button id="help-toggle" class="icon-btn" title="Help (?)">?</button>
<button id="admin-toggle" class="icon-btn hidden" title="Admin panel">⚙</button>
<button id="logout-btn" class="btn btn-ghost btn-sm">Log out</button>
@@ -100,6 +104,11 @@
</div>
</aside>
+<!-- Detached player placeholder bar -->
+<div id="detached-bar" class="detached-bar hidden" role="status" aria-label="Player detached">
+ <span>Player detached — press <kbd>d</kbd> to reattach</span>
+</div>
+
<footer class="toast" id="toast" role="status" aria-live="polite"></footer>
<!-- Help Modal -->
@@ -120,6 +129,8 @@
<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>d</kbd></td><td>Detach / attach player popup</td></tr>
+ <tr><td><kbd>D</kbd></td><td>Download selected media</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Exit fullscreen, deselect item</td></tr>
<tr><td><kbd>Backspace</kbd></td><td>Go up one folder</td></tr>
<tr><td><kbd>r</kbd></td><td>Toggle shuffle</td></tr>
@@ -127,7 +138,7 @@
<tr><td><kbd>S</kbd></td><td>Share selected media</td></tr>
<tr><td><kbd>L</kbd></td><td>My Shares (opens list)</td></tr>
</table>
- <p class="text-muted text-xs mt-2">Inside the <strong>My Shares</strong> list: <kbd>↑</kbd> / <kbd>↓</kbd> or <kbd>k</kbd> / <kbd>j</kbd> to navigate; <kbd>Enter</kbd> to copy the selected link; <kbd>Delete</kbd> or <kbd>d</kbd> to revoke it; <kbd>Esc</kbd> to close.</p>
+ <p class="text-muted text-xs mt-2">Inside the <strong>My Shares</strong> list: <kbd>↑</kbd> / <kbd>↓</kbd> or <kbd>k</kbd> / <kbd>j</kbd> to navigate; <kbd>Enter</kbd> to copy the selected link; <kbd>Delete</kbd> to revoke it; <kbd>Esc</kbd> to close.</p>
</div>
</div>
</div>
@@ -223,8 +234,8 @@
<div class="shares-hint">
<kbd>&#8593;</kbd> <kbd>&#8595;</kbd> <kbd>k</kbd> <kbd>j</kbd> navigate &middot;
<kbd>Enter</kbd> copy &middot;
- <kbd>Delete</kbd> <kbd>d</kbd> revoke &middot;
- <kbd>Esc</kbd> close
+ <kbd>Delete</kbd> revoke &middot;
+ <kbd>Esc</kbd> close
</div>
</div>
</div>
diff --git a/web/js/api.js b/web/js/api.js
index 518a58b..3d2586b 100644
--- a/web/js/api.js
+++ b/web/js/api.js
@@ -78,6 +78,7 @@ export const API = {
restore: (id) => api(`/api/media/${id}/restore`, { method: 'POST' }),
trash: () => api('/api/admin/trash'),
users: () => api('/api/admin/users'),
+ scanProgress: () => api('/api/admin/scan-progress'),
createUser: (body) => api('/api/admin/users', { method: 'POST', body }),
deleteUser: (id) => api(`/api/admin/users/${id}`, { method: 'DELETE' }),
permissions: () => api('/api/admin/permissions'),
diff --git a/web/js/app.js b/web/js/app.js
index ebb9017..104c89e 100644
--- a/web/js/app.js
+++ b/