Git Repository

miniaudiolib

miniaudiolib scannt lokale Musik- und Hörbuchordner, liest Audio-Metadaten und Cover, verwaltet Favoriten und Playlists und streamt Titel direkt an den Browser.

Projektseite ↗
HTTPShttps://zanvex.de/git/miniaudiolib.git

internal/musicweb/web.go

Zum Verzeichnis
package musicweb

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"html/template"
	"io"
	"log/slog"
	"miniaudiolib/internal/config"
	"miniaudiolib/internal/ffmpeginstall"
	"miniaudiolib/internal/musiclib"
	"miniaudiolib/internal/musicmetadata"
	"miniaudiolib/internal/musicscanner"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
)

type App struct {
	lib             *musiclib.Service
	db              *sql.DB
	scan            *musicscanner.Scanner
	cfg             config.Config
	configPath      string
	log             *slog.Logger
	tpl             *template.Template
	metadata        *musicmetadata.Service
	installMu       sync.RWMutex
	installing      bool
	installErr      string
	installed       bool
	installProgress ffmpeginstall.Progress
}
type page struct {
	Title, View, Search, Artist, Album string
	Tracks                             []musiclib.Track
	Albums                             []musiclib.Album
	Audiobooks                         []musiclib.Audiobook
	Chapters                           []musiclib.Chapter
	Playlists                          []musiclib.Playlist
	PlaylistID                         int64
	Book                               string
	Stats                              musiclib.Stats
	Config                             config.Config
	Scanning                           bool
}

func New(lib *musiclib.Service, db *sql.DB, scan *musicscanner.Scanner, cfg config.Config, configPath string, log *slog.Logger) (http.Handler, error) {
	t, e := template.New("page").Funcs(template.FuncMap{"duration": duration, "json": func(v any) template.JS { b, _ := json.Marshal(v); return template.JS(b) }}).Parse(html)
	if e != nil {
		return nil, e
	}
	a := &App{lib: lib, db: db, scan: scan, cfg: cfg, configPath: configPath, log: log, tpl: t, metadata: musicmetadata.New(db, cfg.DatabasePath)}
	r := chi.NewRouter()
	r.Use(middleware.Recoverer)
	r.Get("/", a.home)
	r.Get("/stream/{id}", a.stream)
	r.Get("/cover/{id}", a.cover)
	r.Get("/audiobook-cover/{id}", a.audiobookCover)
	r.Get("/audiobook-stream/{id}", a.audiobookStream)
	r.Put("/api/audiobook-chapters/{id}/progress", a.audiobookProgress)
	r.Post("/api/tracks/{id}/favorite", a.favorite)
	r.Put("/api/tracks/{id}/progress", a.progress)
	r.Post("/api/scan", a.scanNow)
	r.Get("/api/scan/status", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, a.scan.Status()) })
	r.Post("/api/ffmpeg/install", a.installFFmpeg)
	r.Get("/api/ffmpeg/status", a.ffmpegStatus)
	r.Get("/api/musicbrainz/search", a.musicbrainzSearch)
	r.Post("/api/musicbrainz/apply", a.musicbrainzApply)
	r.Get("/api/playlists", a.listPlaylists)
	r.Post("/api/playlists", a.createPlaylist)
	r.Post("/api/playlists/{id}/tracks", a.addPlaylistTrack)
	r.Delete("/api/playlists/{id}/tracks/{track}", a.removePlaylistTrack)
	r.Delete("/api/playlists/{id}", a.deletePlaylist)
	r.Get("/settings", a.settings)
	r.Post("/settings", a.saveSettings)
	r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, map[string]any{"ok": true, "scanning": a.scan.Running()})
	})
	return r, nil
}
func (a *App) musicbrainzSearch(w http.ResponseWriter, r *http.Request) {
	results, err := a.metadata.Search(r.Context(), r.URL.Query().Get("album"), r.URL.Query().Get("artist"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	writeJSON(w, results)
}
func (a *App) musicbrainzApply(w http.ResponseWriter, r *http.Request) {
	var x struct {
		Album  string               `json:"album"`
		Artist string               `json:"artist"`
		Result musicmetadata.Result `json:"result"`
	}
	if json.NewDecoder(io.LimitReader(r.Body, 32<<10)).Decode(&x) != nil {
		http.Error(w, "invalid JSON", 400)
		return
	}
	if err := a.metadata.Apply(r.Context(), x.Album, x.Artist, x.Result); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	writeJSON(w, map[string]bool{"ok": true})
}
func (a *App) installFFmpeg(w http.ResponseWriter, r *http.Request) {
	a.installMu.Lock()
	if a.installing {
		a.installMu.Unlock()
		writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "installation already running"})
		return
	}
	a.installing, a.installed, a.installErr = true, false, ""
	a.installProgress = ffmpeginstall.Progress{Phase: "starting"}
	a.installMu.Unlock()
	go func() {
		destination, _ := filepath.Abs("ffmpeg")
		result, err := ffmpeginstall.InstallWithProgress(context.Background(), destination, func(progress ffmpeginstall.Progress) {
			a.installMu.Lock()
			a.installProgress = progress
			a.installMu.Unlock()
		})
		a.installMu.Lock()
		defer a.installMu.Unlock()
		a.installing = false
		if err != nil {
			a.installErr = err.Error()
			a.log.Error("install FFmpeg", "error", err)
			return
		}
		a.cfg.FFmpegPath, a.cfg.FFprobePath = result.FFmpeg, result.FFprobe
		if err = config.Save(a.configPath, a.cfg); err != nil {
			a.installErr = err.Error()
			return
		}
		a.scan.SetConfig(a.cfg)
		a.installed = true
		a.installProgress.Phase = "complete"
		a.log.Info("FFmpeg installed", "directory", destination)
	}()
	writeJSONStatus(w, http.StatusAccepted, map[string]bool{"started": true})
}
func (a *App) ffmpegStatus(w http.ResponseWriter, r *http.Request) {
	a.installMu.RLock()
	defer a.installMu.RUnlock()
	writeJSON(w, map[string]any{"installing": a.installing, "installed": a.installed, "error": a.installErr, "progress": a.installProgress})
}
func (a *App) home(w http.ResponseWriter, r *http.Request) {
	view := r.URL.Query().Get("view")
	if view == "" {
		view = "albums"
	}
	if view == "audiobooks" {
		books, e := a.lib.Audiobooks(r.Context())
		if e != nil {
			http.Error(w, e.Error(), 500)
			return
		}
		book := r.URL.Query().Get("book")
		var chapters []musiclib.Chapter
		if book != "" {
			chapters, e = a.lib.AudiobookChapters(r.Context(), book)
			if e != nil {
				http.Error(w, e.Error(), 500)
				return
			}
		}
		a.render(w, page{Title: "Hörbücher", View: view, Audiobooks: books, Chapters: chapters, Book: book})
		return
	}
	if view == "playlists" {
		lists, e := a.lib.Playlists(r.Context())
		if e != nil {
			http.Error(w, e.Error(), 500)
			return
		}
		id, _ := strconv.ParseInt(r.URL.Query().Get("playlist"), 10, 64)
		var tracks []musiclib.Track
		if id > 0 {
			tracks, e = a.lib.PlaylistTracks(r.Context(), id)
			if e != nil {
				http.Error(w, e.Error(), 500)
				return
			}
		}
		a.render(w, page{Title: "Playlisten", View: view, Playlists: lists, PlaylistID: id, Tracks: tracks})
		return
	}
	q, artist, album := r.URL.Query().Get("q"), r.URL.Query().Get("artist"), r.URL.Query().Get("album")
	tracks, e := a.lib.List(r.Context(), q, artist, album)
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	albums, _ := a.lib.Albums(r.Context())
	stats, _ := a.lib.Stats(r.Context())
	a.render(w, page{Title: "Bibliothek", View: view, Search: q, Artist: artist, Album: album, Tracks: tracks, Albums: albums, Stats: stats, Scanning: a.scan.Running()})
}
func (a *App) listPlaylists(w http.ResponseWriter, r *http.Request) {
	x, e := a.lib.Playlists(r.Context())
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	writeJSON(w, x)
}
func (a *App) createPlaylist(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	id, e := a.lib.CreatePlaylist(r.Context(), r.FormValue("name"))
	if e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	writeJSONStatus(w, 201, map[string]any{"id": id})
}
func playlistParam(r *http.Request, name string) (int64, error) {
	return strconv.ParseInt(chi.URLParam(r, name), 10, 64)
}
func (a *App) addPlaylistTrack(w http.ResponseWriter, r *http.Request) {
	id, e := playlistParam(r, "id")
	if e != nil {
		http.Error(w, "invalid playlist", 400)
		return
	}
	_ = r.ParseForm()
	track, e := strconv.ParseInt(r.FormValue("track_id"), 10, 64)
	if e != nil {
		http.Error(w, "invalid track", 400)
		return
	}
	if e = a.lib.AddToPlaylist(r.Context(), id, track); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	writeJSON(w, map[string]bool{"ok": true})
}
func (a *App) removePlaylistTrack(w http.ResponseWriter, r *http.Request) {
	id, e := playlistParam(r, "id")
	if e != nil {
		http.Error(w, "invalid playlist", 400)
		return
	}
	track, e := playlistParam(r, "track")
	if e != nil {
		http.Error(w, "invalid track", 400)
		return
	}
	if e = a.lib.RemoveFromPlaylist(r.Context(), id, track); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	writeJSON(w, map[string]bool{"ok": true})
}
func (a *App) deletePlaylist(w http.ResponseWriter, r *http.Request) {
	id, e := playlistParam(r, "id")
	if e != nil {
		http.Error(w, "invalid playlist", 400)
		return
	}
	if e = a.lib.DeletePlaylist(r.Context(), id); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	writeJSON(w, map[string]bool{"ok": true})
}
func (a *App) settings(w http.ResponseWriter, r *http.Request) {
	a.render(w, page{Title: "Einstellungen", View: "settings", Config: a.cfg, Scanning: a.scan.Running()})
}
func (a *App) saveSettings(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	a.cfg.Libraries = splitLines(r.FormValue("libraries"))
	a.cfg.AudiobookLibraries = splitLines(r.FormValue("audiobook_libraries"))
	if v := r.FormValue("listen_address"); v != "" {
		a.cfg.ListenAddress = v
	}
	if v := r.FormValue("ffmpeg_path"); v != "" {
		a.cfg.FFmpegPath = v
	}
	if v := r.FormValue("ffprobe_path"); v != "" {
		a.cfg.FFprobePath = v
	}
	if e := config.Save(a.configPath, a.cfg); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	a.scan.SetConfig(a.cfg)
	http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
}
func (a *App) audiobookChapter(w http.ResponseWriter, r *http.Request) (musiclib.Chapter, bool) {
	id, e := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if e != nil {
		return musiclib.Chapter{}, false
	}
	c, e := a.lib.AudiobookChapter(r.Context(), id)
	if e != nil {
		http.NotFound(w, r)
		return c, false
	}
	return c, true
}
func (a *App) audiobookStream(w http.ResponseWriter, r *http.Request) {
	c, ok := a.audiobookChapter(w, r)
	if !ok {
		return
	}
	f, e := os.Open(c.Path)
	if e != nil {
		http.NotFound(w, r)
		return
	}
	defer f.Close()
	st, e := f.Stat()
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	w.Header().Set("Content-Type", audioMIME(c.Container))
	w.Header().Set("Accept-Ranges", "bytes")
	http.ServeContent(w, r, c.Filename, st.ModTime(), f)
}
func (a *App) audiobookCover(w http.ResponseWriter, r *http.Request) {
	c, ok := a.audiobookChapter(w, r)
	if !ok || c.Cover == "" {
		http.NotFound(w, r)
		return
	}
	w.Header().Set("Cache-Control", "public,max-age=86400")
	http.ServeFile(w, r, c.Cover)
}
func (a *App) audiobookProgress(w http.ResponseWriter, r *http.Request) {
	c, ok := a.audiobookChapter(w, r)
	if !ok {
		return
	}
	var p struct {
		Position  float64 `json:"position"`
		Completed bool    `json:"completed"`
	}
	if json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&p) != nil {
		http.Error(w, "invalid JSON", 400)
		return
	}
	if e := a.lib.AudiobookProgress(r.Context(), c.ID, p.Position, p.Completed); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	writeJSON(w, map[string]bool{"ok": true})
}
func (a *App) scanNow(w http.ResponseWriter, r *http.Request) {
	if a.scan.Running() {
		http.Error(w, "scan läuft bereits", 409)
		return
	}
	go func() {
		if e := a.scan.Run(context.Background()); e != nil {
			a.log.Warn("scan", "error", e)
		}
	}()
	writeJSON(w, map[string]bool{"started": true})
}
func (a *App) track(w http.ResponseWriter, r *http.Request) (musiclib.Track, bool) {
	id, e := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if e != nil {
		return musiclib.Track{}, false
	}
	t, e := a.lib.Get(r.Context(), id)
	if e != nil {
		http.NotFound(w, r)
		return t, false
	}
	return t, true
}
func (a *App) stream(w http.ResponseWriter, r *http.Request) {
	t, ok := a.track(w, r)
	if !ok {
		return
	}
	f, e := os.Open(t.Path)
	if e != nil {
		http.NotFound(w, r)
		return
	}
	defer f.Close()
	st, e := f.Stat()
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	w.Header().Set("Content-Type", audioMIME(t.Container))
	w.Header().Set("Accept-Ranges", "bytes")
	http.ServeContent(w, r, t.Filename, st.ModTime(), f)
}
func (a *App) cover(w http.ResponseWriter, r *http.Request) {
	t, ok := a.track(w, r)
	if !ok || t.Cover == "" {
		http.NotFound(w, r)
		return
	}
	w.Header().Set("Cache-Control", "public,max-age=86400")
	http.ServeFile(w, r, t.Cover)
}
func (a *App) favorite(w http.ResponseWriter, r *http.Request) {
	t, ok := a.track(w, r)
	if !ok {
		return
	}
	if e := a.lib.Favorite(r.Context(), t.ID); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	writeJSON(w, map[string]bool{"ok": true})
}
func (a *App) progress(w http.ResponseWriter, r *http.Request) {
	t, ok := a.track(w, r)
	if !ok {
		return
	}
	var p struct {
		Position  float64 `json:"position"`
		Completed bool    `json:"completed"`
	}
	if json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&p) != nil {
		http.Error(w, "invalid JSON", 400)
		return
	}
	_ = a.lib.Played(r.Context(), t.ID, p.Position, p.Completed)
	writeJSON(w, map[string]bool{"ok": true})
}
func (a *App) render(w http.ResponseWriter, p page) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if e := a.tpl.Execute(w, p); e != nil {
		a.log.Error("template", "error", e)
	}
}
func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(v)
}
func writeJSONStatus(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}
func splitLines(s string) []string {
	var out []string
	for _, v := range strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == '\r' }) {
		if x := strings.TrimSpace(v); x != "" {
			out = append(out, x)
		}
	}
	return out
}
func duration(v float64) string {
	m := int(v) / 60
	s := int(v) % 60
	if int(v) >= 3600 {
		return fmt.Sprintf("%d:%02d:%02d", int(v)/3600, m%60, s)
	}
	return fmt.Sprintf("%d:%02d", m, s)
}
func audioMIME(c string) string {
	switch strings.ToLower(c) {
	case "mp3":
		return "audio/mpeg"
	case "flac":
		return "audio/flac"
	case "ogg", "opus":
		return "audio/ogg"
	case "wav":
		return "audio/wav"
	case "mov,mp4,m4a,3gp,3g2,mj2":
		return "audio/mp4"
	}
	return "application/octet-stream"
}

var _ = filepath.Base

const html = `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · miniaudiolib</title><style>
:root{--bg:#0c0d12;--panel:#151720;--soft:#202331;--line:#292d3b;--text:#f6f7fb;--muted:#9ba2b5;--accent:#9cff57;--accent2:#48d7ff}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% -20%,#243330 0,transparent 35%),var(--bg);color:var(--text);font:15px system-ui,sans-serif;padding-bottom:110px}header{height:72px;display:flex;align-items:center;gap:38px;padding:0 5vw;border-bottom:1px solid #ffffff12;position:sticky;top:0;background:#0c0d12e8;backdrop-filter:blur(15px);z-index:4}.brand{font-weight:900;font-size:20px;color:var(--text);text-decoration:none}.brand b{color:var(--accent)}nav{display:flex;gap:8px}nav a{color:var(--muted);text-decoration:none;padding:10px 14px;border-radius:10px}nav a:hover{background:var(--soft);color:white}.scan{margin-left:auto}button,.button{border:1px solid var(--line);border-radius:10px;padding:10px 16px;background:var(--accent);color:#10140d;font-weight:800;cursor:pointer}button:disabled{opacity:.55;cursor:default}main{max-width:1500px;margin:auto;padding:42px 5vw}.hero{display:flex;align-items:end;justify-content:space-between;margin-bottom:32px}h1{font-size:clamp(38px,6vw,76px);letter-spacing:-.06em;margin:0}.stats{color:var(--muted)}.search{display:flex;gap:10px;margin:22px 0 32px}.search input,input,textarea{width:100%;background:var(--panel);border:1px solid #ffffff18;border-radius:12px;color:white;padding:14px}.albums{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:24px}.album{text-decoration:none;color:white}.cover{aspect-ratio:1;background:linear-gradient(135deg,#272b3b,#12141d);border-radius:18px;overflow:hidden;display:grid;place-items:center;font-size:50px;box-shadow:0 18px 50px #0008}.cover img{width:100%;height:100%;object-fit:cover}.album h2{font-size:16px;margin:12px 0 4px}.album p,.track small{color:var(--muted);margin:0}.track{display:grid;grid-template-columns:46px minmax(180px,2fr) minmax(130px,1fr) 70px 40px;align-items:center;gap:15px;padding:10px;border-bottom:1px solid #ffffff0c;border-radius:10px}.track:hover{background:var(--panel)}.track .mini{width:46px;height:46px;border-radius:8px;background:var(--soft);object-fit:cover}.play{background:none;color:white;font-size:18px;padding:5px}.fav{background:none;color:var(--muted);font-size:18px;padding:5px}.fav.on{color:var(--accent)}.player{position:fixed;z-index:8;bottom:0;left:0;right:0;height:92px;background:#151720f4;border-top:1px solid #ffffff1b;backdrop-filter:blur(20px);display:grid;grid-template-columns:minmax(180px,1fr) 2fr 1fr;align-items:center;padding:12px 5vw;gap:24px}.now strong,.now span{display:block}.now span{color:var(--muted)}audio{width:100%}.empty{padding:80px;text-align:center;color:var(--muted);border:1px dashed #ffffff22;border-radius:20px}.settings-shell{display:grid;grid-template-columns:190px minmax(0,800px);gap:55px;justify-content:center}.settings-tabs{display:flex;flex-direction:column;gap:5px;position:sticky;top:105px;height:max-content}.settings-tabs p{padding:0 12px;margin:0 0 8px;font-size:12px;letter-spacing:.2em;color:var(--accent);font-weight:800}.settings-tabs button{text-align:left;background:transparent;border-color:transparent;color:var(--muted)}.settings-tabs button:hover,.settings-tabs button.active{background:var(--panel);color:white;border-color:var(--line)}.settings-content{width:100%}.settings-content h1{font-size:clamp(38px,6vw,68px);line-height:1;letter-spacing:-.05em}.settings-content h2{margin-top:35px}.settings-content label{display:flex;flex-direction:column;gap:7px;margin:22px 0;font-weight:700}.settings-content textarea{min-height:150px}.twocol{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.hint{color:var(--muted)}hr{border:0;border-top:1px solid var(--line);margin:40px 0}.scan-status{margin-top:25px;padding:16px;border:1px solid var(--line);border-radius:10px;background:var(--panel)}@media(max-width:700px){header{gap:8px;padding:0 14px}.brand span{display:none}nav a{padding:8px}.track{grid-template-columns:40px 1fr 44px}.track .albumcol,.track .time{display:none}.player{grid-template-columns:1fr;height:120px}.player audio{grid-column:1}.now{display:none}.settings-shell{grid-template-columns:1fr;gap:20px}.settings-tabs{position:static;display:grid;grid-template-columns:repeat(3,1fr)}.settings-tabs p{display:none}.settings-tabs button{text-align:center;padding:9px 4px}.twocol{grid-template-columns:1fr}}
</style><style>.download-status{margin-top:25px;padding:16px;border:1px solid var(--line);border-radius:10px;background:var(--panel)}.progress-track{height:10px;margin:14px 0;background:#090a0e;border:1px solid var(--line);border-radius:20px;overflow:hidden}.progress-track i{display:block;width:0;height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2));transition:width .2s}.download-meta{display:flex;justify-content:space-between;gap:15px;color:var(--muted);font-size:13px}.chapter-list{margin-top:28px}.chapter{display:grid;grid-template-columns:42px 1fr auto;align-items:center;gap:14px;padding:12px;border-bottom:1px solid var(--line)}.book-progress{color:var(--muted);font-size:13px}</style></head><body><header><a class="brand" href="/"><b>♫</b> <span>miniaudiolib</span></a><nav><a href="/?view=albums">Alben</a><a href="/?view=tracks">Titel</a><a href="/?view=favorites">Favoriten</a><a href="/?view=audiobooks">Hörbücher</a><a href="/?view=playlists">Playlisten</a><a href="/settings">Einstellungen</a></nav><button class="scan" onclick="scan()">{{if .Scanning}}Scan läuft…{{else}}Scannen{{end}}</button></header><main>
{{if eq .View "playlists"}}<section class="hero"><div><small>DEINE SAMMLUNG</small><h1>{{if .PlaylistID}}{{range .Playlists}}{{if eq .ID $.PlaylistID}}{{.Name}}{{end}}{{end}}{{else}}Playlisten{{end}}</h1></div></section>{{if .PlaylistID}}<p><a class="hint" href="/?view=playlists">← Alle Playlisten</a> <button type="button" onclick="deletePlaylist({{.PlaylistID}})">Playlist löschen</button></p><section class="chapter-list">{{range .Tracks}}<article class="chapter"><button class="play" data-track='{{json .}}' onclick="play(this)">▶</button><div><strong>{{.Title}}</strong><small>{{.Artist}} · {{.Album}}</small></div><button type="button" onclick="removeFromPlaylist({{$.PlaylistID}},{{.ID}})">Entfernen</button></article>{{else}}<div class="empty">Diese Playlist ist noch leer.</div>{{end}}</section>{{else}}<form id="create-playlist" class="search"><input name="name" required placeholder="Name der neuen Playlist"><button>Playlist erstellen</button></form>{{if .Playlists}}<section class="albums">{{range .Playlists}}<a class="album" href="/?view=playlists&playlist={{.ID}}"><div class="cover">♫</div><h2>{{.Name}}</h2><p>{{.Tracks}} Titel · {{duration .Duration}}</p></a>{{end}}</section>{{else}}<div class="empty">Noch keine Playlisten vorhanden.</div>{{end}}{{end}}
{{else if eq .View "audiobooks"}}<section class="hero"><div><small>DEINE HÖRBÜCHER</small><h1>{{if .Book}}{{range .Audiobooks}}{{if eq .Path $.Book}}{{.Title}}{{end}}{{end}}{{else}}Hörbücher{{end}}</h1></div></section>{{if .Book}}<a class="hint" href="/?view=audiobooks">← Alle Hörbücher</a><section class="chapter-list">{{range .Chapters}}<article class="chapter"><button class="play" data-chapter='{{json .}}' onclick="playChapter(this)">▶</button><div><strong>{{.Title}}</strong><small>{{.Filename}}</small></div><span>{{duration .Duration}}</span></article>{{else}}<div class="empty">Dieses Hörbuch enthält keine unterstützten Audiodateien.</div>{{end}}</section>{{else}}{{if .Audiobooks}}<section class="albums">{{range .Audiobooks}}<a class="album" href="/?view=audiobooks&book={{urlquery .Path}}"><div class="cover">{{if .Cover}}<img src="/audiobook-cover/{{.ID}}" alt="">{{else}}🎧{{end}}</div><h2>{{.Title}}</h2><p>{{.Chapters}} Kapitel · {{duration .Duration}}</p><span class="book-progress">{{.Completed}} von {{.Chapters}} abgeschlossen</span></a>{{end}}</section>{{else}}<div class="empty">Noch keine Hörbücher gefunden. Füge in den Einstellungen einen Hörbuchordner hinzu.</div>{{end}}{{end}}
{{else if eq .View "settings"}}<section class="settings-shell"><aside class="settings-tabs"><p>EINSTELLUNGEN</p><button data-tab="library">Bibliothek</button><button data-tab="ffmpeg">FFmpeg</button><button data-tab="scan">Scan</button></aside><div class="settings-content"><section data-panel="library"><h1>Bibliothek</h1><p class="hint">Musik- und Hörbuchordner sowie die lokale Serverkonfiguration verwalten.</p><form method="post"><label>Musikordner <small class="hint">Ein absoluter Pfad pro Zeile</small><textarea name="libraries">{{range .Config.Libraries}}{{.}}&#10;{{end}}</textarea></label><label>Hörbuchordner <small class="hint">Jeder enthaltene Ordner wird als ein Hörbuch behandelt</small><textarea name="audiobook_libraries">{{range .Config.AudiobookLibraries}}{{.}}&#10;{{end}}</textarea></label><div class="twocol"><label>Adresse<input name="listen_address" value="{{.Config.ListenAddress}}"></label><label>FFmpeg-Pfad<input name="ffmpeg_path" value="{{.Config.FFmpegPath}}"></label><label>FFprobe-Pfad<input name="ffprobe_path" value="{{.Config.FFprobePath}}"></label></div><button>Speichern</button></form></section><section data-panel="ffmpeg" hidden><h1>FFmpeg</h1><p class="hint">Lädt unter Windows den empfohlenen Essentials Build, prüft die SHA-256-Prüfsumme und installiert FFmpeg und FFprobe lokal in <code>./ffmpeg</code>.</p><button id="install-ffmpeg" type="button" onclick="installFFmpeg()">FFmpeg + FFprobe installieren</button><p id="install-status" class="hint"></p></section><section data-panel="scan" hidden><h1>Scan</h1><p class="hint">Neue und geänderte Musik sowie Hörbücher erfassen, Metadaten lesen und eingebettete Cover extrahieren.</p><button id="settings-scan" type="button" onclick="scanFromSettings()" {{if .Scanning}}disabled{{end}}>Bibliotheken jetzt scannen</button><div class="scan-status"><strong id="scan-title">{{if .Scanning}}Scan läuft …{{else}}Bereit{{end}}</strong><p id="scan-status" class="hint">{{if .Scanning}}Der aktuelle Fortschritt wird abgefragt.{{else}}Noch kein Scan gestartet.{{end}}</p></div></section></div></section>
{{else}}<section class="hero"><div><small>DEINE LOKALE SAMMLUNG</small><h1>{{if .Album}}{{.Album}}{{else if eq .View "tracks"}}Alle Titel{{else if eq .View "favorites"}}Favoriten{{else}}Alben{{end}}</h1></div><p class="stats">{{.Stats.Tracks}} Titel · {{.Stats.Artists}} Künstler · {{.Stats.Albums}} Alben</p></section><form class="search"><input name="q" value="{{.Search}}" placeholder="Titel, Künstler, Album oder Genre suchen…"><input type="hidden" name="view" value="{{.View}}"><button>Suchen</button></form>
{{if and (eq .View "albums") (not .Album)}}{{if .Albums}}<section class="albums">{{range .Albums}}<a class="album" href="/?view=tracks&artist={{urlquery .Artist}}&album={{urlquery .Title}}"><div class="cover">{{if .Cover}}<img src="/cover/{{.ID}}" alt="">{{else}}♫{{end}}</div><h2>{{.Title}}</h2><p>{{.Artist}} · {{.Tracks}} Titel</p></a>{{end}}</section>{{else}}<div class="empty">Noch keine Musik gefunden. Öffne die Einstellungen und füge einen Musikordner hinzu.</div>{{end}}
{{else}}<section>{{range .Tracks}}{{if or (ne $.View "favorites") .Favorite}}<div class="track"><button class="play" data-track='{{json .}}' onclick="play(this)">▶</button><div><strong>{{.Title}}</strong><small>{{.Artist}}</small></div><span class="albumcol">{{.Album}}</span><span class="time">{{duration .Duration}}</span><button class="fav {{if .Favorite}}on{{end}}" onclick="favorite({{.ID}},this)">★</button></div>{{end}}{{else}}<div class="empty">Keine passenden Titel gefunden.</div>{{end}}</section>{{end}}{{end}}</main>
<aside class="player"><div class="now"><div class="player-art">♫</div><div><strong id="nowTitle">Nichts läuft</strong><span id="nowArtist">Wähle einen Titel</span></div></div><div class="player-center"><div class="transport"><button id="player-prev" type="button" aria-label="Zurück">◀</button><button id="player-toggle" class="player-toggle" type="button" aria-label="Wiedergabe">▶</button><button id="player-next" type="button" aria-label="Weiter">▶</button></div><div class="timeline"><time id="player-current">0:00</time><input id="player-seek" type="range" min="0" max="100" value="0" step="0.1" aria-label="Position"><time id="player-duration">0:00</time></div></div><div class="player-volume"><span>🔊</span><input id="player-volume" type="range" min="0" max="1" value="0.8" step="0.01" aria-label="Lautstärke"></div><audio id="audio" preload="metadata"></audio></aside><script>
const audio=document.querySelector('#audio');let current=null;function play(btn){current=JSON.parse(btn.dataset.track);audio.src='/stream/'+current.id;document.querySelector('#nowTitle').textContent=current.Title;document.querySelector('#nowArtist').textContent=current.Artist+' · '+current.Album;audio.play()}function favorite(id,b){fetch('/api/tracks/'+id+'/favorite',{method:'POST'}).then(()=>b.classList.toggle('on'))}function scan(){fetch('/api/scan',{method:'POST'}).then(()=>location.reload())}const tabs=[...document.querySelectorAll('[data-tab]')],panels=[...document.querySelectorAll('[data-panel]')];function selectTab(name){tabs.forEach(x=>x.classList.toggle('active',x.dataset.tab===name));panels.forEach(x=>x.hidden=x.dataset.panel!==name);if(tabs.length)location.hash=name}tabs.forEach(x=>x.onclick=()=>selectTab(x.dataset.tab));if(tabs.length)selectTab(location.hash.slice(1)||'library');async function scanFromSettings(){const b=document.querySelector('#settings-scan'),title=document.querySelector('#scan-title'),status=document.querySelector('#scan-status');b.disabled=true;title.textContent='Scan wird gestartet …';const r=await fetch('/api/scan',{method:'POST'});if(!r.ok){status.textContent='Der Scan läuft bereits oder konnte nicht gestartet werden.'}updateScanStatus()}async function updateScanStatus(){const title=document.querySelector('#scan-title'),status=document.querySelector('#scan-status'),b=document.querySelector('#settings-scan');if(!title)return;const x=await(await fetch('/api/scan/status')).json();title.textContent=x.running?'Scan läuft …':'Scan abgeschlossen';status.textContent=x.processed+' von '+x.discovered+' Titeln verarbeitet · '+x.updated+' aktualisiert · '+x.errors+' Fehler';b.disabled=x.running;if(x.running)setTimeout(updateScanStatus,1000)}if(document.querySelector('#settings-scan')&&document.querySelector('#settings-scan').disabled)updateScanStatus();async function installFFmpeg(){const b=document.querySelector('#install-ffmpeg'),s=document.querySelector('#install-status');b.disabled=true;s.textContent='Download und Installation laufen …';const res=await fetch('/api/ffmpeg/install',{method:'POST'});if(!res.ok){s.textContent='Installation läuft bereits oder konnte nicht gestartet werden.';b.disabled=false;return}const timer=setInterval(async()=>{const x=await(await fetch('/api/ffmpeg/status')).json();if(x.installing)return;clearInterval(timer);if(x.installed){s.textContent='Installiert und sofort aktiviert. Die Pfade wurden gespeichert.'}else{s.textContent='Fehler: '+x.error;b.disabled=false}},1500)}if(audio){setInterval(()=>{if(current&&!audio.paused)fetch('/api/tracks/'+current.id+'/progress',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({position:audio.currentTime,completed:false})})},15000);audio.addEventListener('ended',()=>{if(current)fetch('/api/tracks/'+current.id+'/progress',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({position:0,completed:true})})})}
</script><script>
function formatBytes(n){if(!n||n<0)return '–';const units=['B','KiB','MiB','GiB'];let i=0;while(n>=1024&&i<units.length-1){n/=1024;i++}return n.toFixed(i?1:0)+' '+units[i]}
function renderDownload(x){const s=document.querySelector('#install-status');if(!s)return;let box=document.querySelector('#download-progress');if(!box){box=document.createElement('div');box.id='download-progress';box.className='download-status';box.innerHTML='<strong id="download-title">Download wird vorbereitet …</strong><div class="progress-track"><i id="download-bar"></i></div><div class="download-meta"><span id="download-size">–</span><span id="download-speed">–</span></div>';s.replaceChildren(box)}const p=x.progress||{},percent=p.total>0?Math.min(100,p.downloaded/p.total*100):0;document.querySelector('#download-bar').style.width=percent+'%';document.querySelector('#download-size').textContent=formatBytes(p.downloaded)+(p.total>0?' / '+formatBytes(p.total)+' · '+percent.toFixed(1)+' %':'');document.querySelector('#download-speed').textContent=p.phase==='downloading'?formatBytes(p.bytes_per_second)+'/s':'';const phases={starting:'Download wird vorbereitet …',downloading:'FFmpeg wird heruntergeladen …',verifying:'Prüfsumme wird geprüft …',extracting:'FFmpeg wird entpackt …',complete:'Installation abgeschlossen'};document.querySelector('#download-title').textContent=phases[p.phase]||'Installation läuft …'}
async function installFFmpeg(){const b=document.querySelector('#install-ffmpeg');b.disabled=true;renderDownload({progress:{phase:'starting'}});const res=await fetch('/api/ffmpeg/install',{method:'POST'});if(!res.ok){document.querySelector('#download-title').textContent='Installation läuft bereits oder konnte nicht gestartet werden.';b.disabled=false;return}const poll=async()=>{const x=await(await fetch('/api/ffmpeg/status')).json();renderDownload(x);if(x.installing){setTimeout(poll,250);return}if(!x.installed){document.querySelector('#download-title').textContent='Fehler: '+x.error;b.disabled=false}};poll()}
</script><script>
let audiobookQueue={{if .Chapters}}{{json .Chapters}}{{else}}[]{{end}};let audiobookIndex=-1,audioChapter=null;
function startAudiobookChapter(index){if(!audio||index<0||index>=audiobookQueue.length)return;audiobookIndex=index;audioChapter=audiobookQueue[index];audio.src='/audiobook-stream/'+audioChapter.id;document.querySelector('#nowTitle').textContent=audioChapter.Title;document.querySelector('#nowArtist').textContent=audioChapter.BookTitle+' · Kapitel '+(index+1)+' von '+audiobookQueue.length;audio.addEventListener('loadedmetadata',function resume(){audio.removeEventListener('loadedmetadata',resume);if(audioChapter.PlaybackPosition>0&&audioChapter.PlaybackPosition<audio.duration-5)audio.currentTime=audioChapter.PlaybackPosition;audio.play()})}
function playChapter(button){const chapter=JSON.parse(button.dataset.chapter);startAudiobookChapter(audiobookQueue.findIndex(x=>x.id===chapter.id))}
if(audio&&audiobookQueue.length){setInterval(()=>{if(audioChapter&&!audio.paused)fetch('/api/audiobook-chapters/'+audioChapter.id+'/progress',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({position:audio.currentTime,completed:false})})},10000);audio.addEventListener('ended',()=>{if(!audioChapter)return;fetch('/api/audiobook-chapters/'+audioChapter.id+'/progress',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({position:0,completed:true})});if(audiobookIndex+1<audiobookQueue.length)startAudiobookChapter(audiobookIndex+1)})}
</script><script>
{{if .Album}}(function(){const album={{json .Album}},artist={{json .Artist}};const main=document.querySelector('main'),tracks=main.querySelector('section:last-of-type');if(!tracks)return;const panel=document.createElement('section');panel.className='scan-status';panel.innerHTML='<h2>Online-Metadaten</h2><p class="hint">Albumdaten und Cover über MusicBrainz und das Cover Art Archive suchen.</p><button id="mb-search" type="button">MusicBrainz durchsuchen</button><div id="mb-results"></div>';tracks.before(panel);document.querySelector('#mb-search').onclick=async function(){const box=document.querySelector('#mb-results');this.disabled=true;box.innerHTML='<p class="hint">MusicBrainz wird durchsucht …</p>';try{const r=await fetch('/api/musicbrainz/search?album='+encodeURIComponent(album)+'&artist='+encodeURIComponent(artist));if(!r.ok)throw new Error(await r.text());const xs=await r.json();box.replaceChildren();if(!xs.length){box.innerHTML='<p class="hint">Keine Treffer gefunden.</p>';return}for(const x of xs){const row=document.createElement('article');row.className='chapter';const info=document.createElement('div');const title=document.createElement('strong');title.textContent=x.Title;const meta=document.createElement('small');meta.textContent=[x.Artist,x.Date,x.PrimaryType,'Treffer '+x.Score+' %'].filter(Boolean).join(' · ');info.append(title,meta);const apply=document.createElement('button');apply.type='button';apply.textContent='Übernehmen';apply.onclick=async()=>{apply.disabled=true;const res=await fetch('/api/musicbrainz/apply',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({album,artist,result:x})});if(res.ok)location.reload();else{alert(await res.text());apply.disabled=false}};row.append(info,apply);box.append(row)}}catch(e){box.textContent='Fehler: '+e.message}finally{this.disabled=false}}})(){{end}}
</script><script>
const playerStateKey='miniMusicPlayerState';let lastPlayerSave=0,musicQueue=[];
function hydrateDynamicPage(){audiobookQueue=[...document.querySelectorAll('[data-chapter]')].map(x=>{try{return JSON.parse(x.dataset.chapter)}catch(e){return null}}).filter(Boolean);musicQueue=[...document.querySelectorAll('[data-track]')].map(x=>{try{return JSON.parse(x.dataset.track)}catch(e){return null}}).filter(Boolean);document.querySelectorAll('.track').forEach(row=>{if(row.querySelector('.playlist-add'))return;const source=row.querySelector('[data-track]');if(!source)return;row.style.position='relative';const b=document.createElement('button');b.type='button';b.className='fav playlist-add';b.textContent='+';b.title='Zu Playlist hinzufügen';b.style.cssText='position:absolute;right:-42px;top:12px';b.onclick=()=>addTrackToPlaylist(JSON.parse(source.dataset.track).id);row.append(b)})}
async function navigateWithoutStopping(url,push=true){const response=await fetch(url,{headers:{'X-Requested-With':'MiniMusicLibrary'}});if(!response.ok){location.href=url;return}const doc=new DOMParser().parseFromString(await response.text(),'text/html'),next=doc.querySelector('main');if(!next){location.href=url;return}document.querySelector('main').replaceWith(next);document.title=doc.title;if(push)history.pushState({},'',url);hydrateDynamicPage();window.scrollTo({top:0,behavior:'instant'})}
document.addEventListener('click',e=>{const link=e.target.closest('a');if(!link||e.defaultPrevented||e.button!==0||e.ctrlKey||e.metaKey||e.shiftKey||e.altKey)return;const url=new URL(link.href,location.href);if(url.origin!==location.origin||url.pathname==='/settings')return;if(url.pathname==='/'){e.preventDefault();navigateWithoutStopping(url.href)}});
document.addEventListener('submit',e=>{if(!e.target.matches('form.search'))return;e.preventDefault();const url=new URL(e.target.action||location.href);url.search=new URLSearchParams(new FormData(e.target));navigateWithoutStopping(url.href)});window.addEventListener('popstate',()=>{if(location.pathname==='/settings')return;navigateWithoutStopping(location.href,false)});
document.addEventListener('submit',async e=>{if(!e.target.matches('#create-playlist'))return;e.preventDefault();const r=await fetch('/api/playlists',{method:'POST',body:new FormData(e.target)});if(r.ok)navigateWithoutStopping('/?view=playlists',false);else alert(await r.text())});
async function addTrackToPlaylist(trackID){const lists=await(await fetch('/api/playlists')).json();if(!lists.length){alert('Bitte zuerst unter Playlisten eine Playlist erstellen.');return}const answer=prompt('Playlist auswählen:\n'+lists.map((x,i)=>(i+1)+'. '+x.Name).join('\n'));const selected=lists[Number(answer)-1];if(!selected)return;const body=new URLSearchParams({track_id:String(trackID)});const r=await fetch('/api/playlists/'+selected.ID+'/tracks',{method:'POST',body});if(r.ok)alert('Zur Playlist „'+selected.Name+'“ hinzugefügt.');else alert(await r.text())}
async function removeFromPlaylist(playlistID,trackID){await fetch('/api/playlists/'+playlistID+'/tracks/'+trackID,{method:'DELETE'});navigateWithoutStopping(location.href,false)}async function deletePlaylist(id){if(!confirm('Playlist wirklich löschen?'))return;await fetch('/api/playlists/'+id,{method:'DELETE'});navigateWithoutStopping('/?view=playlists',false)}
function savePlayerState(){if(!audio||!audio.src)return;const now=Date.now();if(now-lastPlayerSave<500)return;lastPlayerSave=now;const state={src:audio.getAttribute('src'),position:audio.currentTime||0,playing:!audio.paused,title:document.querySelector('#nowTitle').textContent,subtitle:document.querySelector('#nowArtist').textContent,current:current,chapter:audioChapter,queue:audioChapter?audiobookQueue:[],index:audiobookIndex};sessionStorage.setItem(playerStateKey,JSON.stringify(state))}
function restorePlayerState(){if(!audio)return;let state;try{state=JSON.parse(sessionStorage.getItem(playerStateKey))}catch(e){}if(!state||!state.src)return;current=state.current||null;audioChapter=state.chapter||null;audiobookIndex=Number.isInteger(state.index)?state.index:-1;audio.src=state.src;document.querySelector('#nowTitle').textContent=state.title||'Nichts läuft';document.querySelector('#nowArtist').textContent=state.subtitle||'';audio.addEventListener('loadedmetadata',function restore(){audio.removeEventListener('loadedmetadata',restore);if(state.position>0&&state.position<audio.duration)audio.currentTime=state.position;if(state.playing)audio.play().catch(()=>{})});if(audioChapter&&!audiobookQueue.length&&Array.isArray(state.queue)){audio.addEventListener('ended',()=>{const next=audiobookIndex+1;if(next>=state.queue.length)return;const chapter=state.queue[next];audiobookIndex=next;audioChapter=chapter;audio.src='/audiobook-stream/'+chapter.id;document.querySelector('#nowTitle').textContent=chapter.Title;document.querySelector('#nowArtist').textContent=chapter.BookTitle+' · Kapitel '+(next+1)+' von '+state.queue.length;audio.play()})}}
if(audio){hydrateDynamicPage();audio.addEventListener('play',savePlayerState);audio.addEventListener('pause',savePlayerState);audio.addEventListener('timeupdate',savePlayerState);window.addEventListener('pagehide',()=>{lastPlayerSave=0;savePlayerState()});restorePlayerState()}
</script><script>
const toggle=document.querySelector('#player-toggle'),seek=document.querySelector('#player-seek'),volume=document.querySelector('#player-volume'),currentTimeLabel=document.querySelector('#player-current'),durationLabel=document.querySelector('#player-duration'),playerArt=document.querySelector('.player-art');
function clock(seconds){if(!Number.isFinite(seconds))return '0:00';const m=Math.floor(seconds/60),s=Math.floor(seconds%60);return m+':'+String(s).padStart(2,'0')}
function showTrack(track){current=track;audioChapter=null;audio.src='/stream/'+track.id;document.querySelector('#nowTitle').textContent=track.Title;document.querySelector('#nowArtist').textContent=track.Artist+' · '+track.Album;playerArt.style.backgroundImage=track.Cover?'url(/cover/'+track.id+')':'';audio.play()}
window.play=function(button){showTrack(JSON.parse(button.dataset.track))};
toggle.onclick=()=>{if(!audio.src)return;audio.paused?audio.play():audio.pause()};volume.oninput=()=>audio.volume=Number(volume.value);audio.volume=Number(volume.value);seek.oninput=()=>{if(Number.isFinite(audio.duration))audio.currentTime=Number(seek.value)};
audio.addEventListener('play',()=>{toggle.textContent='❚❚'});audio.addEventListener('pause',()=>{toggle.textContent='▶'});audio.addEventListener('loadedmetadata',()=>{seek.max=audio.duration||0;durationLabel.textContent=clock(audio.duration)});audio.addEventListener('timeupdate',()=>{if(!seek.matches(':active'))seek.value=audio.currentTime||0;currentTimeLabel.textContent=clock(audio.currentTime)});
document.querySelector('#player-next').onclick=()=>{if(audioChapter){if(audiobookIndex+1<audiobookQueue.length)startAudiobookChapter(audiobookIndex+1);return}const i=musicQueue.findIndex(x=>current&&x.id===current.id);if(i>=0&&i+1<musicQueue.length)showTrack(musicQueue[i+1])};document.querySelector('#player-prev').onclick=()=>{if(audio.currentTime>5){audio.currentTime=0;return}if(audioChapter){if(audiobookIndex>0)startAudiobookChapter(audiobookIndex-1);return}const i=musicQueue.findIndex(x=>current&&x.id===current.id);if(i>0)showTrack(musicQueue[i-1])};
</script><style>.player{position:fixed;z-index:20;bottom:0;left:0;right:0;height:92px;border:0;border-top:1px solid var(--line);border-radius:0;box-shadow:0 -8px 30px #0007;background:#111216fa;display:grid;grid-template-columns:minmax(230px,1fr) minmax(360px,1.4fr) minmax(180px,1fr);align-items:center;padding:10px 28px;gap:24px}.player audio{display:none}.player .now{display:flex;align-items:center;gap:13px;min-width:0}.player .now>div:last-child{min-width:0}.player .now strong,.player .now span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.player-art{width:58px;height:58px;flex:none;border-radius:5px;background:#252832 center/cover;display:grid;place-items:center;color:var(--accent);font-size:24px}.player-center{display:flex;flex-direction:column;align-items:center;gap:7px}.transport{display:flex;align-items:center;gap:20px}.transport button{padding:3px 8px;border:0;background:transparent;color:var(--muted);font-size:14px}.transport button:hover{color:white}.transport .player-toggle{width:34px;height:34px;padding:0;border-radius:50%;background:white;color:#08090b;font-size:14px}.timeline{width:100%;display:grid;grid-template-columns:38px 1fr 38px;align-items:center;gap:9px}.timeline time{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}.player input[type=range]{width:100%;height:4px;padding:0;border:0;accent-color:white}.player-volume{display:flex;align-items:center;justify-content:flex-end;gap:9px}.player-volume input{max-width:120px}@media(max-width:750px){.player{height:82px;grid-template-columns:minmax(100px,1fr) 2fr;padding:8px 14px;gap:12px}.player-volume{display:none}.player-art{display:none}.timeline{grid-template-columns:32px 1fr 32px}.transport{gap:12px}}</style></body></html>`