summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-01 12:40:30 +0300
committerPaul Buetow <paul@buetow.org>2026-05-01 12:40:30 +0300
commit27dbaf2b2d5895a091d04aa1c852abb91a619e2d (patch)
tree846ba964ee6f0fba52ccb2b1750d44b21c454efb
parentbadc2543eaa319657ecc97453fbdca58679ecb87 (diff)
Minimalist UI: hide all elements until activated; add help modal and keyboard shortcuts
- Redesign UI to be invisible by default: only header + '?' button shown - Press m to show sidebar, t for toolbar, / for search, ? for help - Add help modal with all keyboard shortcuts - Fix scanner to skip corrupt/unprobeable files instead of aborting - Fix scanner to skip thumbnail errors instead of aborting - Fix rescan to use background context so it completes after HTTP response - Rename project from KISS Media Player to Player
-rw-r--r--AGENTS.md8
-rw-r--r--Magefile.go4
-rw-r--r--cmd/mediaplayer/main.go2
-rw-r--r--internal/api/middleware.go15
-rw-r--r--internal/scanner/scanner.go14
-rw-r--r--internal/scanner/scanner_test.go13
-rw-r--r--internal/service/admin.go3
-rw-r--r--k8s/deployment.yaml16
-rw-r--r--k8s/pvc-db.yaml4
-rw-r--r--k8s/pvc-media.yaml4
-rw-r--r--k8s/secret.yaml4
-rw-r--r--k8s/service.yaml6
-rw-r--r--web/bootstrap.html2
-rw-r--r--web/css/layout.css71
-rw-r--r--web/css/player.css9
-rw-r--r--web/index.html80
-rw-r--r--web/js/app.js70
-rw-r--r--web/js/keyboard.js5
-rw-r--r--web/login.html4
-rw-r--r--web/manifest.json4
20 files changed, 271 insertions, 67 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 74fe1ae..bc84a36 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
-# KISS Media Player β€” Agent Documentation
+# Player β€” Agent Documentation
-This file is written for coding agents working on the `kiss-media-player` project.
+This file is written for coding agents working on the `player` project.
---
@@ -81,8 +81,8 @@ Available targets (from `Magefile.go`):
| `mage test` | Run `go test ./...` |
| `mage install` | Build and copy `play` to `$GOPATH/bin` (or `~/go/bin`) |
| `mage clean` | Remove the `play` binary |
-| `mage docker-build` | Build container image as `kiss-media-player:latest` |
-| `mage docker-push` | Push `kiss-media-player:latest` to registry |
+| `mage docker-build` | Build container image as `player:latest` |
+| `mage docker-push` | Push `player:latest` to registry |
---
diff --git a/Magefile.go b/Magefile.go
index 5303b20..7895e48 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -67,10 +67,10 @@ func Clean() error {
// DockerBuild builds the container image.
func DockerBuild() error {
- return sh.RunV("docker", "build", "-t", "kiss-media-player:latest", ".")
+ return sh.RunV("docker", "build", "-t", "player:latest", ".")
}
// DockerPush pushes the container image to the registry.
func DockerPush() error {
- return sh.RunV("docker", "push", "kiss-media-player:latest")
+ return sh.RunV("docker", "push", "player:latest")
}
diff --git a/cmd/mediaplayer/main.go b/cmd/mediaplayer/main.go
index 7e5df5c..33927c0 100644
--- a/cmd/mediaplayer/main.go
+++ b/cmd/mediaplayer/main.go
@@ -95,7 +95,7 @@ func run(args []string) error {
gs := api.NewGracefulServer(server, cfg)
- log.Printf("kiss-media-player %s starting on %s", internal.Version, gs.Server.Addr)
+ log.Printf("player %s starting on %s", internal.Version, gs.Server.Addr)
go func() {
if err := gs.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
index ee1d6c9..fd779b5 100644
--- a/internal/api/middleware.go
+++ b/internal/api/middleware.go
@@ -29,15 +29,24 @@ func NewMiddleware(store repository.Store, sm *auth.SessionManager) *Middleware
}
// RequireSession validates the session cookie and injects the session into request context.
+// For HTML page requests (Accept: text/html), redirects to /login.html instead of returning 401.
func (mw *Middleware) RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
+ if wantsHTML(r) {
+ http.Redirect(w, r, "/login.html", http.StatusTemporaryRedirect)
+ return
+ }
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sess, err := mw.sm.ValidateSession(r.Context(), cookie.Value)
if err != nil || sess == nil {
+ if wantsHTML(r) {
+ http.Redirect(w, r, "/login.html", http.StatusTemporaryRedirect)
+ return
+ }
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
@@ -46,6 +55,12 @@ func (mw *Middleware) RequireSession(next http.Handler) http.Handler {
})
}
+// wantsHTML returns true if the request appears to be from a browser expecting an HTML page.
+func wantsHTML(r *http.Request) bool {
+ accept := r.Header.Get("Accept")
+ return strings.Contains(accept, "text/html")
+}
+
// RequireAdmin ensures the authenticated user is an admin.
func (mw *Middleware) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go
index a1effd7..2e82fbe 100644
--- a/internal/scanner/scanner.go
+++ b/internal/scanner/scanner.go
@@ -132,7 +132,9 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error {
meta, err := s.prober.Probe(ctx, path)
if err != nil {
- return fmt.Errorf("probe %q: %w", path, err)
+ // Skip unprobeable/corrupt files instead of aborting the whole scan.
+ fmt.Printf("[scanner] skipping unprobeable file %q: %v\n", path, err)
+ return nil
}
meta.FileSizeBytes = info.Size()
@@ -146,7 +148,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 {
- return fmt.Errorf("thumbnail %q: %w", path, err)
+ // Skip thumbnail generation errors (e.g., corrupt or audio-only video files)
+ // and continue scanning without a thumbnail.
+ fmt.Printf("[scanner] skipping thumbnail for %q: %v\n", path, err)
+ thumbnailPath = ""
}
}
@@ -182,6 +187,11 @@ var mediaExtensions = map[string]struct{}{
}
func isMediaFile(path string) bool {
+ base := filepath.Base(path)
+ // Skip macOS resource fork files (._*)
+ if strings.HasPrefix(base, "._") {
+ return false
+ }
ext := strings.ToLower(filepath.Ext(path))
_, ok := mediaExtensions[ext]
return ok
diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go
index 8da32a4..f6299dd 100644
--- a/internal/scanner/scanner_test.go
+++ b/internal/scanner/scanner_test.go
@@ -345,9 +345,13 @@ 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")
- if err == nil {
- t.Fatal("expected error for probe failure")
+ if err != nil {
+ t.Fatalf("unexpected error for probe failure; expected skip, got: %v", err)
+ }
+ if store.MediaRepo.CreateMediaFunc != nil {
+ // no media should have been created for the bad file
}
})
@@ -381,9 +385,10 @@ 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")
- if err == nil {
- t.Fatal("expected error for thumbnail failure")
+ if err != nil {
+ t.Fatalf("unexpected error for thumbnail failure; expected skip, got: %v", err)
}
})
diff --git a/internal/service/admin.go b/internal/service/admin.go
index afccc78..f16037a 100644
--- a/internal/service/admin.go
+++ b/internal/service/admin.go
@@ -39,7 +39,8 @@ func (s *adminService) TriggerRescan(ctx context.Context) error {
if s.scanner == nil {
return fmt.Errorf("scanner not configured")
}
- if err := s.scanner.Scan(ctx, s.mediaRoot); err != nil {
+ // Use a background context so the scan isn't canceled when the HTTP request finishes.
+ if err := s.scanner.Scan(context.Background(), s.mediaRoot); err != nil {
return fmt.Errorf("scan failed: %w", err)
}
return nil
diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml
index c4c605e..56bc11b 100644
--- a/k8s/deployment.yaml
+++ b/k8s/deployment.yaml
@@ -1,25 +1,25 @@
apiVersion: apps/v1
kind: Deployment
metadata:
- name: kiss-media-player
+ name: player
labels:
- app: kiss-media-player
+ app: player
spec:
replicas: 1
selector:
matchLabels:
- app: kiss-media-player
+ app: player
template:
metadata:
labels:
- app: kiss-media-player
+ app: player
spec:
securityContext:
runAsNonRoot: true
fsGroup: 65534
containers:
- name: app
- image: kiss-media-player:latest
+ image: player:latest
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 65534
@@ -31,7 +31,7 @@ spec:
name: http
envFrom:
- secretRef:
- name: kiss-media-player-secret
+ name: player-secret
optional: true
env:
- name: PORT
@@ -67,7 +67,7 @@ spec:
volumes:
- name: data
persistentVolumeClaim:
- claimName: kiss-media-player-db
+ claimName: player-db
- name: media
persistentVolumeClaim:
- claimName: kiss-media-player-media
+ claimName: player-media
diff --git a/k8s/pvc-db.yaml b/k8s/pvc-db.yaml
index 87fad81..3328172 100644
--- a/k8s/pvc-db.yaml
+++ b/k8s/pvc-db.yaml
@@ -1,9 +1,9 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
- name: kiss-media-player-db
+ name: player-db
labels:
- app: kiss-media-player
+ app: player
spec:
accessModes:
- ReadWriteOnce
diff --git a/k8s/pvc-media.yaml b/k8s/pvc-media.yaml
index 76e2d41..f75e5df 100644
--- a/k8s/pvc-media.yaml
+++ b/k8s/pvc-media.yaml
@@ -1,9 +1,9 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
- name: kiss-media-player-media
+ name: player-media
labels:
- app: kiss-media-player
+ app: player
spec:
accessModes:
- ReadWriteMany
diff --git a/k8s/secret.yaml b/k8s/secret.yaml
index 68b21fc..bbde6dd 100644
--- a/k8s/secret.yaml
+++ b/k8s/secret.yaml
@@ -1,9 +1,9 @@
apiVersion: v1
kind: Secret
metadata:
- name: kiss-media-player-secret
+ name: player-secret
labels:
- app: kiss-media-player
+ app: player
type: Opaque
stringData:
# Set this to the desired admin password before applying.
diff --git a/k8s/service.yaml b/k8s/service.yaml
index c3da918..bf0cbd9 100644
--- a/k8s/service.yaml
+++ b/k8s/service.yaml
@@ -1,13 +1,13 @@
apiVersion: v1
kind: Service
metadata:
- name: kiss-media-player
+ name: player
labels:
- app: kiss-media-player
+ app: player
spec:
type: ClusterIP
selector:
- app: kiss-media-player
+ app: player
ports:
- port: 8080
targetPort: http
diff --git a/web/bootstrap.html b/web/bootstrap.html
index 440a55f..3f028d2 100644
--- a/web/bootstrap.html
+++ b/web/bootstrap.html
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>Bootstrap β€” KISS Media Player</title>
+<title>Bootstrap β€” Player</title>
<link rel="stylesheet" href="/css/theme.css">
<link rel="stylesheet" href="/css/components.css">
<link rel="stylesheet" href="/css/login.css">
diff --git a/web/css/layout.css b/web/css/layout.css
index 7dd70a4..ca5acd8 100644
--- a/web/css/layout.css
+++ b/web/css/layout.css
@@ -41,11 +41,14 @@ body {
border-right: 1px solid var(--border);
overflow: auto;
padding: 0.75rem 0.5rem;
- display: flex;
+ display: none; /* hidden by default */
flex-direction: column;
gap: 0.25rem;
z-index: 40;
}
+.sidebar.open {
+ display: flex;
+}
.sidebar h2 {
font-size: 0.75rem;
text-transform: uppercase;
@@ -75,13 +78,20 @@ body {
color: var(--accent);
font-weight: 600;
}
+.sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
/* Main layout */
.page {
padding-top: var(--header-height);
- padding-left: var(--sidebar-width);
min-height: 100vh;
}
+.page.has-sidebar {
+ padding-left: var(--sidebar-width);
+}
.main-content {
padding: var(--gap);
max-width: 100%;
@@ -255,6 +265,35 @@ body {
.modal-header h3 { margin: 0; font-size: 1.1rem; }
.modal-header .spacer { flex: 1 1 auto; }
+/* Help modal */
+.help-content {
+ overflow: auto;
+}
+.help-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+}
+.help-table td {
+ padding: 0.4rem 0.5rem;
+ border-bottom: 1px solid var(--border);
+}
+.help-table td:first-child {
+ white-space: nowrap;
+ width: 1%;
+ font-family: var(--font-mono);
+}
+.help-table kbd {
+ display: inline-block;
+ padding: 0.15rem 0.4rem;
+ border-radius: var(--radius-sm);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-strong);
+ font-size: 0.8rem;
+ min-width: 1.6rem;
+ text-align: center;
+}
+
/* Responsive */
@media (max-width: 860px) {
:root {
@@ -282,6 +321,7 @@ body {
}
.sidebar.open { display: flex; }
.page { padding-left: 0; }
+ .page.has-sidebar { padding-left: 0; }
.player { inset: auto 0 0 0 !important; }
.toolbar {
flex-wrap: wrap;
@@ -361,6 +401,32 @@ body {
margin-bottom: 0.75rem;
}
+/* Empty state hint */
+.empty-hint {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 50vh;
+ gap: 0.5rem;
+ text-align: center;
+}
+.empty-hint p {
+ margin: 0;
+ font-size: 1.1rem;
+ color: var(--text-secondary);
+}
+.empty-hint kbd {
+ display: inline-block;
+ padding: 0.15rem 0.4rem;
+ border-radius: var(--radius-sm);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-strong);
+ font-family: var(--font-mono);
+ font-size: 0.85rem;
+}
+.empty-hint.hidden { display: none !important; }
+
/* Modal sizes */
.modal-wide { width: min(56rem, 96vw); }
@@ -383,4 +449,3 @@ body {
.admin-list li.is-admin {
background: var(--accent-soft);
}
-
diff --git a/web/css/player.css b/web/css/player.css
index 706667d..29baf30 100644
--- a/web/css/player.css
+++ b/web/css/player.css
@@ -1,13 +1,20 @@
/* Player */
.player {
position: fixed;
- inset: auto 0 0 var(--sidebar-width);
+ inset: auto 0 0 0;
background: var(--player-bg);
color: var(--text-primary);
border-top: 1px solid var(--border);
z-index: 60;
+ display: none; /* hidden by default until something plays */
transition: inset var(--transition-base);
}
+.player.open {
+ display: block;
+}
+.page.has-sidebar .player {
+ inset: auto 0 0 var(--sidebar-width);
+}
.player.is-fullscreen {
inset: 0;
background: var(--player-stage-bg);
diff --git a/web/index.html b/web/index.html
index 1fc583e..33478ad 100644
--- a/web/index.html
+++ b/web/index.html
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>KISS Media Player</title>
+<title>Player</title>
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/css/theme.css">
<link rel="stylesheet" href="/css/layout.css">
@@ -13,36 +13,33 @@
</head>
<body>
<header class="site-header" role="banner">
- <a href="/" class="logo">KISS Media</a>
- <div class="search-bar" role="search">
- <span aria-hidden="true">πŸ”Ž</span>
- <input id="search-input" type="search" placeholder="Search… (press /)" autocomplete="off" aria-label="Search media">
- <button id="search-clear" class="icon-btn btn-sm" title="Clear">βœ•</button>
- </div>
+ <a href="/" class="logo">Player</a>
<div class="spacer"></div>
- <button id="shuffle-toggle" class="icon-btn" title="Shuffle (r)">πŸ”€</button>
- <button id="admin-toggle" class="icon-btn hidden" title="Admin panel (a)">βš™</button>
- <button id="theme-toggle" class="icon-btn" title="Toggle theme">β˜€</button>
- <span id="user-name" class="text-secondary text-xs"></span>
- <button id="upload-toggle" class="icon-btn" title="Upload">⬆</button>
+ <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>
- <button id="menu-btn" class="icon-btn" title="Menu" aria-label="Open sets">☰</button>
</header>
+<!-- Sidebar: hidden by default, toggled via keyboard or menu -->
<nav id="sidebar" class="sidebar" aria-label="Sets">
- <h2>Sets</h2>
+ <div class="sidebar-header">
+ <h2>Sets</h2>
+ <button id="menu-close" class="icon-btn btn-sm" title="Close">βœ•</button>
+ </div>
<div id="set-list"></div>
</nav>
+<!-- Main page area -->
<div class="page" role="main">
<div class="main-content">
- <div class="toolbar">
+ <!-- Toolbar: hidden by default, shown on 't' key or when searching -->
+ <div id="toolbar" class="toolbar hidden">
<select id="filter-type" aria-label="Filter by type">
<option value="">All</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
</select>
- <button id="filter-favorites" class="btn btn-ghost btn-sm">❀ Favorites</button>
+ <button id="filter-favorites" class="btn btn-ghost btn-sm">β™₯ Favorites</button>
<div class="spacer"></div>
<button id="filter-toggle" class="btn btn-ghost btn-sm" title="More filters">Filters β–Ύ</button>
<span id="result-count" class="text-muted text-xs"></span>
@@ -54,11 +51,27 @@
<input id="filter-min-filesize" type="number" min="0" placeholder="Min MB" aria-label="Min file size MB" class="input-sm">
<input id="filter-max-filesize" type="number" min="0" placeholder="Max MB" aria-label="Max file size MB" class="input-sm">
</div>
- <div id="media-grid" class="media-grid" tabindex="0" aria-label="Media grid"></div>
+
+ <!-- Search bar: hidden by default, shown on '/' key -->
+ <div id="search-bar" class="search-bar hidden" role="search">
+ <span aria-hidden="true">πŸ”Ž</span>
+ <input id="search-input" type="search" placeholder="Search… (press /)" autocomplete="off" aria-label="Search media">
+ <button id="search-clear" class="icon-btn btn-sm" title="Clear">βœ•</button>
+ </div>
+
+ <!-- Empty state hint: shown when no set is selected -->
+ <div id="empty-hint" class="empty-hint">
+ <p>Welcome to Player</p>
+ <p class="text-muted text-sm">Press <kbd>m</kbd> to browse sets, <kbd>/</kbd> to search, or <kbd>?</kbd> for help.</p>
+ </div>
+
+ <!-- Media grid: hidden until a set is loaded or search is active -->
+ <div id="media-grid" class="media-grid hidden" tabindex="0" aria-label="Media grid"></div>
</div>
</div>
-<aside id="player" class="player open" aria-label="Player">
+<!-- Player: hidden until something plays -->
+<aside id="player" class="player" aria-label="Player">
<div class="stage">
<video id="media-video" playsinline controlslist="nodownload"></video>
<audio id="media-audio" class="hidden"></audio>
@@ -86,6 +99,37 @@
<footer class="toast" id="toast" role="status" aria-live="polite"></footer>
+<!-- Help Modal -->
+<div id="help-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="help-title">
+ <div class="modal">
+ <div class="modal-header">
+ <h3 id="help-title">Keyboard Shortcuts</h3>
+ <div class="spacer"></div>
+ <button id="help-close" class="icon-btn" aria-label="Close">βœ•</button>
+ </div>
+ <div class="help-content">
+ <table class="help-table">
+ <tr><td><kbd>↑</kbd> <kbd>↓</kbd></td><td>Navigate media list</td></tr>
+ <tr><td><kbd>k</kbd> <kbd>j</kbd></td><td>Navigate media list (vim-style)</td></tr>
+ <tr><td><kbd>←</kbd> <kbd>β†’</kbd></td><td>Switch sets / pages</td></tr>
+ <tr><td><kbd>h</kbd> <kbd>l</kbd></td><td>Switch sets / pages (vim-style)</td></tr>
+ <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>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>
+ <tr><td><kbd>/</kbd></td><td>Focus search bar</td></tr>
+ <tr><td><kbd>n</kbd></td><td>Open notes for selected media</td></tr>
+ <tr><td><kbd>t</kbd></td><td>Toggle toolbar / filters</td></tr>
+ <tr><td><kbd>m</kbd></td><td>Toggle sets sidebar</td></tr>
+ <tr><td><kbd>?</kbd></td><td>Show / hide this help</td></tr>
+ </table>
+ <p class="text-muted text-xs mt-2">Click the <strong>?</strong> button or press <kbd>?</kbd> anytime to show or hide this help.</p>
+ </div>
+ </div>
+</div>
+
<!-- Notes Modal -->
<div id="notes-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="notes-title">
<div class="modal">
diff --git a/web/js/app.js b/web/js/app.js
index e72b58e..884cdcf 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -92,18 +92,23 @@ async function initApp() {
},
playPause: () => togglePlay(),
fullscreen: () => toggleFullscreen(),
- escape: () => { exitFullscreenIfNeeded(); const el = currentElement(); if (el) el.classList.remove('selected'); },
+ escape: () => { exitFullscreenIfNeeded(); const el = currentElement(); if (el) el.classList.remove('selected'); closeAllModals(); },
shuffle: () => { toggleShuffle(); loadMedia(); },
share: () => shareSelected(),
- search: focusSearch,
+ search: () => showSearch(),
notes: openNotesForSelected,
tags: openTagsForSelected,
download: downloadSelected,
+ help: toggleHelp,
+ toolbar: toggleToolbar,
+ sidebar: toggleSidebar,
+ upload: () => showUpload(),
});
initNotes(() => toast('Note saved'));
initAdmin();
initPWA();
initUpload();
+ initHelp();
// Filters
document.getElementById('filter-type')?.addEventListener('change', (e) => { state.filters.type = e.target.value; loadMedia(); });
@@ -121,10 +126,12 @@ async function initApp() {
document.getElementById('filter-advanced')?.classList.toggle('hidden');
});
- // Menu toggle
- document.getElementById('menu-btn')?.addEventListener('click', () => {
- document.getElementById('sidebar')?.classList.toggle('open');
+ // Sidebar close
+ document.getElementById('menu-close')?.addEventListener('click', () => {
+ document.getElementById('sidebar')?.classList.remove('open');
+ document.querySelector('.page')?.classList.remove('has-sidebar');
});
+
document.getElementById('logout-btn')?.addEventListener('click', async () => {
try { await API.logout(); location.href = '/login.html'; } catch {}
});
@@ -135,7 +142,7 @@ async function initApp() {
// Admin check via admin users endpoint
API.users().then(() => { state.isAdmin = true; showAdmin(); }).catch(() => {});
renderSets();
- if (state.sets.length) { state.selectedSetId = state.sets[0].id; setActiveSet(state.sets[0].id); loadMedia(); }
+ // Do NOT auto-load a set by default; keep the page blank until the user chooses.
} catch (err) {
toast(err.message || 'Error loading sets', 'error');
}
@@ -196,7 +203,10 @@ function setSetByDelta(dx) {
async function loadMedia() {
const grid = document.getElementById('media-grid');
+ const hint = document.getElementById('empty-hint');
if (!grid) return;
+ grid.classList.remove('hidden');
+ hint?.classList.add('hidden');
grid.innerHTML = '<p class="text-muted text-sm grid-full">Loading...</p>';
try {
const params = {
@@ -409,8 +419,6 @@ function initUpload() {
const form = document.getElementById('upload-form');
const fileInput = document.getElementById('upload-file');
- // Open via an icon button in header (already added in HTML)
- document.getElementById('upload-toggle')?.addEventListener('click', () => modal?.classList.add('open'));
closeBtn?.addEventListener('click', () => modal?.classList.remove('open'));
modal?.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('open'); });
@@ -431,6 +439,52 @@ function initUpload() {
});
}
+function showUpload() {
+ const modal = document.getElementById('upload-modal');
+ modal?.classList.add('open');
+}
+
+// Help modal
+function initHelp() {
+ const modal = document.getElementById('help-modal');
+ const closeBtn = document.getElementById('help-close');
+ const toggleBtn = document.getElementById('help-toggle');
+
+ toggleBtn?.addEventListener('click', toggleHelp);
+ closeBtn?.addEventListener('click', () => modal?.classList.remove('open'));
+ modal?.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('open'); });
+}
+
+function toggleHelp() {
+ const modal = document.getElementById('help-modal');
+ if (!modal) return;
+ modal.classList.toggle('open');
+}
+
+// Toolbar / sidebar / search visibility toggles
+function toggleToolbar() {
+ const toolbar = document.getElementById('toolbar');
+ toolbar?.classList.toggle('hidden');
+}
+
+function toggleSidebar() {
+ const sidebar = document.getElementById('sidebar');
+ const page = document.querySelector('.page');
+ if (!sidebar) return;
+ const open = sidebar.classList.toggle('open');
+ page?.classList.toggle('has-sidebar', open);
+}
+
+function showSearch() {
+ const bar = document.getElementById('search-bar');
+ bar?.classList.remove('hidden');
+ focusSearch();
+}
+
+function closeAllModals() {
+ document.querySelectorAll('.modal-overlay.open').forEach((m) => m.classList.remove('open'));
+}
+
function showAdmin() {
const btn = document.getElementById('admin-toggle');
if (btn) btn.classList.remove('hidden');
diff --git a/web/js/keyboard.js b/web/js/keyboard.js
index dbf1ac0..7d4dd77 100644
--- a/web/js/keyboard.js
+++ b/web/js/keyboard.js
@@ -23,8 +23,11 @@ export function initKeyboard(handlers) {
case 's': handlers.share?.(e); break;
case '/': e.preventDefault(); handlers.search?.(e); break;
case 'n': handlers.notes?.(e); break;
- case 't': handlers.tags?.(e); break;
+ case 't': handlers.toolbar?.(e); break;
+ case 'm': handlers.sidebar?.(e); break;
case 'd': handlers.download?.(e); break;
+ case 'u': handlers.upload?.(e); break;
+ case '?': e.preventDefault(); handlers.help?.(e); break;
}
});
}
diff --git a/web/login.html b/web/login.html
index 6925e6f..970a004 100644
--- a/web/login.html
+++ b/web/login.html
@@ -3,14 +3,14 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>Login β€” KISS Media Player</title>
+<title>Login β€” Player</title>
<link rel="stylesheet" href="/css/theme.css">
<link rel="stylesheet" href="/css/components.css">
<link rel="stylesheet" href="/css/login.css">
</head>
<body>
<div class="login-container" role="main">
- <h1>KISS Media Player</h1>
+ <h1>Player</h1>
<form id="login-form" class="login-form" novalidate>
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
diff --git a/web/manifest.json b/web/manifest.json
index d3ddb9b..8d3cfd3 100644
--- a/web/manifest.json
+++ b/web/manifest.json
@@ -1,6 +1,6 @@
{
- "name": "KISS Media Player",
- "short_name": "KISS Media",
+ "name": "Player",
+ "short_name": "Player",
"start_url": "/",
"display": "standalone",
"background_color": "#0f1117",