diff options
| -rw-r--r-- | internal/api/handlers.go | 4 | ||||
| -rw-r--r-- | internal/api/handlers_admin.go | 7 | ||||
| -rw-r--r-- | internal/api/handlers_test.go | 27 | ||||
| -rw-r--r-- | internal/api/server.go | 2 | ||||
| -rw-r--r-- | internal/scanner/scanner.go | 17 | ||||
| -rw-r--r-- | internal/service/admin.go | 10 | ||||
| -rw-r--r-- | internal/service/media_browse.go | 50 | ||||
| -rw-r--r-- | internal/service/media_test.go | 62 | ||||
| -rw-r--r-- | internal/service/mock.go | 23 | ||||
| -rw-r--r-- | internal/service/service.go | 23 | ||||
| -rwxr-xr-x | start.sh | 13 | ||||
| -rw-r--r-- | web/css/layout.css | 34 | ||||
| -rw-r--r-- | web/css/player.css | 87 | ||||
| -rw-r--r-- | web/detach.html | 42 | ||||
| -rw-r--r-- | web/index.html | 17 | ||||
| -rw-r--r-- | web/js/api.js | 1 | ||||
| -rw-r--r-- | web/js/app.js | 35 | ||||
| -rw-r--r-- | web/js/detach.js | 198 | ||||
| -rw-r--r-- | web/js/keyboard.js | 4 | ||||
| -rw-r--r-- | web/js/player.js | 209 |
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. @@ -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">▶</div> + </div> + </div> + <div class="controls"> + <button id="btn-toggle-stage" class="icon-btn" aria-label="Toggle stage">⛺</button> + <button id="btn-play" class="icon-btn" aria-label="Play/Pause">▶</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">⏮</button> + <button id="btn-next" class="icon-btn" aria-label="Next">⏭</button> + <div class="volume-control"> + <button id="btn-mute" class="icon-btn" aria-label="Mute">🔊</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">⛺</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>↑</kbd> <kbd>↓</kbd> <kbd>k</kbd> <kbd>j</kbd> navigate · <kbd>Enter</kbd> copy · - <kbd>Delete</kbd> <kbd>d</kbd> revoke · - <kbd>Esc</kbd> close + <kbd>Delete</kbd> revoke · + <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'), |
