Add proper playlist support including m3u files
This commit is contained in:
@@ -51,6 +51,45 @@ Play audio directly from URLs or M3U playlists:
|
||||
|
||||
For non-seekable HTTP streams, the UI shows `● Streaming` with a static seek bar, and seek keys are silently ignored.
|
||||
|
||||
## M3U Playlists
|
||||
|
||||
Load local or remote `.m3u`/`.m3u8` files with full EXTINF metadata support:
|
||||
|
||||
```sh
|
||||
./cliamp ~/radio-stations.m3u
|
||||
./cliamp http://radio.example.com/streams.m3u
|
||||
./cliamp ~/music.m3u local.mp3 # mix M3U with other files
|
||||
```
|
||||
|
||||
Titles from `#EXTINF` lines are displayed in the playlist. Relative paths in local M3U files resolve against the file's directory.
|
||||
|
||||
## Local Playlists
|
||||
|
||||
Create your own playlists as `.toml` files in `~/.config/cliamp/playlists/`:
|
||||
|
||||
```toml
|
||||
# ~/.config/cliamp/playlists/radio-stations.toml
|
||||
|
||||
[[track]]
|
||||
path = "http://station-1.com/stream"
|
||||
title = "Radio Station 1"
|
||||
|
||||
[[track]]
|
||||
path = "/home/user/Music/song.mp3"
|
||||
title = "My Song"
|
||||
artist = "My Artist"
|
||||
```
|
||||
|
||||
Run `cliamp` without arguments to browse and play your playlists:
|
||||
|
||||
```sh
|
||||
./cliamp # opens playlist browser
|
||||
```
|
||||
|
||||
Use arrow keys to navigate, Enter to load a playlist. Press `Esc`/`b` during playback to return to the browser and pick another. Press `p` to open the playlist manager — browse playlists, add/remove tracks, and delete playlists.
|
||||
|
||||
See [docs/playlists.md](docs/playlists.md) for the full guide.
|
||||
|
||||
## Podcasts
|
||||
|
||||
Play any podcast by passing its RSS feed URL:
|
||||
@@ -157,10 +196,11 @@ eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
| `S` | Save track to ~/Music |
|
||||
| `/` | Search playlist |
|
||||
| `a` | Toggle queue (play next) |
|
||||
| `p` | Playlist manager |
|
||||
| `r` | Cycle repeat (Off / All / One) |
|
||||
| `z` | Toggle shuffle |
|
||||
| `Ctrl+K` | Show keymap |
|
||||
| `b` `Esc` | Back to provider (Navidrome) |
|
||||
| `b` `Esc` | Back to provider |
|
||||
| `q` | Quit |
|
||||
|
||||
## Author
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Playlists
|
||||
|
||||
Cliamp supports two kinds of playlists: **M3U files** loaded from the command line and **local TOML playlists** managed from within the app.
|
||||
|
||||
## M3U Playlists
|
||||
|
||||
Load any `.m3u` or `.m3u8` file, local or remote:
|
||||
|
||||
```sh
|
||||
cliamp ~/radio-stations.m3u
|
||||
cliamp http://radio.example.com/streams.m3u
|
||||
cliamp ~/music.m3u https://example.com/live.m3u # mix local + remote
|
||||
```
|
||||
|
||||
### EXTINF Metadata
|
||||
|
||||
The parser extracts titles and durations from `#EXTINF` lines:
|
||||
|
||||
```m3u
|
||||
#EXTM3U
|
||||
#EXTINF:180,Radio Station 1
|
||||
http://station-1.com/stream
|
||||
#EXTINF:-1,Radio Station 2
|
||||
http://station-2.com/stream/hd
|
||||
```
|
||||
|
||||
Entries without `#EXTINF` still work — the filename or URL is used as the title instead.
|
||||
|
||||
### Relative Paths
|
||||
|
||||
Paths in a local M3U file are resolved relative to the M3U file's directory:
|
||||
|
||||
```m3u
|
||||
#EXTINF:240,My Song
|
||||
../Music/song.mp3
|
||||
#EXTINF:-1,Live Stream
|
||||
http://example.com/live
|
||||
```
|
||||
|
||||
If `radio.m3u` is in `~/playlists/`, then `../Music/song.mp3` resolves to `~/Music/song.mp3`.
|
||||
|
||||
### Edge Cases Handled
|
||||
|
||||
- UTF-8 BOM (common in Windows-created files)
|
||||
- `\r\n` line endings
|
||||
- Missing `#EXTM3U` header
|
||||
- Mixed local and remote entries in the same file
|
||||
- Other `#` directives (silently skipped)
|
||||
|
||||
---
|
||||
|
||||
## Local TOML Playlists
|
||||
|
||||
Create and manage your own playlists stored as `.toml` files in `~/.config/cliamp/playlists/`.
|
||||
|
||||
### File Format
|
||||
|
||||
Each playlist is a separate `.toml` file. The filename (minus extension) becomes the playlist name.
|
||||
|
||||
```toml
|
||||
# ~/.config/cliamp/playlists/radio-stations.toml
|
||||
|
||||
[[track]]
|
||||
path = "http://station-1.com/stream"
|
||||
title = "Radio Station 1"
|
||||
|
||||
[[track]]
|
||||
path = "http://station-2.com/stream/hd"
|
||||
title = "Radio Station 2"
|
||||
artist = "Radio Network"
|
||||
|
||||
[[track]]
|
||||
path = "/home/user/Music/song.mp3"
|
||||
title = "My Song"
|
||||
artist = "My Artist"
|
||||
```
|
||||
|
||||
Each `[[track]]` section supports:
|
||||
|
||||
| Key | Required | Description |
|
||||
|-----|----------|-------------|
|
||||
| `path` | Yes | File path or HTTP URL |
|
||||
| `title` | Yes | Display title |
|
||||
| `artist` | No | Artist name |
|
||||
|
||||
HTTP/HTTPS paths are automatically treated as streams.
|
||||
|
||||
### Browsing and Loading Playlists
|
||||
|
||||
When TOML playlists exist on disk, running `cliamp` without arguments opens the playlist browser:
|
||||
|
||||
```sh
|
||||
cliamp
|
||||
```
|
||||
|
||||
Navigate with `Up`/`Down` (or `j`/`k`) and press `Enter` to load a playlist. Tracks are added to the player and playback starts immediately.
|
||||
|
||||
To switch to a different playlist, press `Esc` or `b` during playback to return to the browser and pick another one. Press `Tab` to jump back to the now-playing playlist without reloading.
|
||||
|
||||
If Navidrome is also configured, both sources appear in the same list with provider labels (e.g., `[Navidrome] Jazz`, `[Local Playlists] favorites`).
|
||||
|
||||
You can also start with CLI files and browse playlists later — press `Esc`/`b` to open the browser at any time:
|
||||
|
||||
```sh
|
||||
cliamp song.mp3 # starts playing, Esc opens browser
|
||||
```
|
||||
|
||||
### Managing Playlists
|
||||
|
||||
Press `p` from any view to open the playlist manager:
|
||||
|
||||
1. **Browse** — see all playlists with track counts
|
||||
2. **Open** — press `Enter` or `→` to view tracks inside a playlist
|
||||
3. **Add track** — press `a` to add the currently playing track
|
||||
4. **Delete playlist** — press `d` then `y` to confirm deletion
|
||||
5. **Remove track** — open a playlist, highlight a track, press `d` to remove it
|
||||
6. **Play all** — press `Enter` on the track list to load all tracks into the player
|
||||
7. **New playlist** — select "+ New Playlist...", type a name, and press Enter
|
||||
|
||||
The directory `~/.config/cliamp/playlists/` is created automatically on first use. Removing the last track from a playlist auto-deletes the file.
|
||||
|
||||
### Creating Playlists Manually
|
||||
|
||||
Create the directory and add a `.toml` file:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/cliamp/playlists
|
||||
```
|
||||
|
||||
```toml
|
||||
# ~/.config/cliamp/playlists/favorites.toml
|
||||
|
||||
[[track]]
|
||||
path = "/home/user/Music/song.mp3"
|
||||
title = "Great Song"
|
||||
artist = "Good Artist"
|
||||
|
||||
[[track]]
|
||||
path = "https://radio.example.com/stream"
|
||||
title = "My Radio"
|
||||
```
|
||||
|
||||
### Controls
|
||||
|
||||
**Playlist browser (provider view):**
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Up` `Down` / `j` `k` | Navigate playlists |
|
||||
| `Enter` | Load selected playlist |
|
||||
| `Tab` | Switch to now-playing playlist |
|
||||
| `Esc` `b` | Open browser (from playlist view) |
|
||||
|
||||
**Playlist manager (`p` key):**
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `p` | Open/close playlist manager |
|
||||
| `Up` `Down` / `j` `k` | Navigate |
|
||||
| `Enter` / `→` | Open playlist / Play all tracks |
|
||||
| `a` | Add currently playing track |
|
||||
| `d` | Delete playlist (confirms) / Remove track |
|
||||
| `Esc` / `←` | Close / Go back |
|
||||
|
||||
Vendored
+209
@@ -0,0 +1,209 @@
|
||||
// Package local implements a playlist.Provider backed by TOML files in
|
||||
// ~/.config/cliamp/playlists/.
|
||||
package local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"cliamp/playlist"
|
||||
)
|
||||
|
||||
// Provider reads and writes TOML-based playlists stored on disk.
|
||||
type Provider struct {
|
||||
dir string // e.g. ~/.config/cliamp/playlists/
|
||||
}
|
||||
|
||||
// New creates a Provider using ~/.config/cliamp/playlists/ as the base directory.
|
||||
func New() *Provider {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Provider{dir: filepath.Join(home, ".config", "cliamp", "playlists")}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "Local Playlists" }
|
||||
|
||||
// Playlists scans the directory for .toml files and returns their metadata.
|
||||
// Returns an empty list (not error) when the directory doesn't exist.
|
||||
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
|
||||
entries, err := os.ReadDir(p.dir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lists []playlist.PlaylistInfo
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".toml") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
||||
tracks, err := p.loadTOML(filepath.Join(p.dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lists = append(lists, playlist.PlaylistInfo{
|
||||
ID: name,
|
||||
Name: name,
|
||||
TrackCount: len(tracks),
|
||||
})
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
// Tracks parses the TOML file for the given playlist name and returns its tracks.
|
||||
func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) {
|
||||
path := filepath.Join(p.dir, playlistID+".toml")
|
||||
return p.loadTOML(path)
|
||||
}
|
||||
|
||||
// AddTrack appends a track to the named playlist, creating the directory and
|
||||
// file if needed.
|
||||
func (p *Provider) AddTrack(playlistName string, track playlist.Track) error {
|
||||
if err := os.MkdirAll(p.dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(p.dir, playlistName+".toml")
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Add a blank line before the section if file is non-empty.
|
||||
if info, _ := f.Stat(); info.Size() > 0 {
|
||||
fmt.Fprintln(f)
|
||||
}
|
||||
|
||||
writeTrack(f, track)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SavePlaylist overwrites the named playlist with the given tracks.
|
||||
func (p *Provider) SavePlaylist(name string, tracks []playlist.Track) error {
|
||||
if err := os.MkdirAll(p.dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(p.dir, name+".toml")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
for i, t := range tracks {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(f)
|
||||
}
|
||||
writeTrack(f, t)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePlaylist removes the TOML file for the named playlist.
|
||||
func (p *Provider) DeletePlaylist(name string) error {
|
||||
path := filepath.Join(p.dir, name+".toml")
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// RemoveTrack removes a track by index from the named playlist.
|
||||
// If the playlist becomes empty after removal, the file is deleted.
|
||||
func (p *Provider) RemoveTrack(name string, index int) error {
|
||||
tracks, err := p.Tracks(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if index < 0 || index >= len(tracks) {
|
||||
return fmt.Errorf("track index %d out of range", index)
|
||||
}
|
||||
tracks = slices.Delete(tracks, index, index+1)
|
||||
if len(tracks) == 0 {
|
||||
return p.DeletePlaylist(name)
|
||||
}
|
||||
return p.SavePlaylist(name, tracks)
|
||||
}
|
||||
|
||||
// writeTrack writes a single [[track]] TOML section to w.
|
||||
func writeTrack(w io.Writer, t playlist.Track) {
|
||||
fmt.Fprintln(w, "[[track]]")
|
||||
fmt.Fprintf(w, "path = %q\n", t.Path)
|
||||
fmt.Fprintf(w, "title = %q\n", t.Title)
|
||||
if t.Artist != "" {
|
||||
fmt.Fprintf(w, "artist = %q\n", t.Artist)
|
||||
}
|
||||
}
|
||||
|
||||
// loadTOML parses a minimal TOML file with [[track]] sections.
|
||||
// Each section supports path, title, and artist keys.
|
||||
func (p *Provider) loadTOML(path string) ([]playlist.Track, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tracks []playlist.Track
|
||||
var current *playlist.Track
|
||||
|
||||
for _, rawLine := range strings.Split(string(data), "\n") {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
|
||||
// Skip comments and blank lines.
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// New track section.
|
||||
if line == "[[track]]" {
|
||||
if current != nil {
|
||||
tracks = append(tracks, *current)
|
||||
}
|
||||
current = &playlist.Track{}
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse key = "value" lines.
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
val = unquote(val)
|
||||
|
||||
switch key {
|
||||
case "path":
|
||||
current.Path = val
|
||||
current.Stream = playlist.IsURL(val)
|
||||
case "title":
|
||||
current.Title = val
|
||||
case "artist":
|
||||
current.Artist = val
|
||||
}
|
||||
}
|
||||
if current != nil {
|
||||
tracks = append(tracks, *current)
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// unquote strips surrounding double quotes from a TOML string value.
|
||||
func unquote(s string) string {
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/gopxl/beep/v2"
|
||||
|
||||
"cliamp/config"
|
||||
"cliamp/external/local"
|
||||
"cliamp/external/navidrome"
|
||||
"cliamp/mpris"
|
||||
"cliamp/player"
|
||||
@@ -25,9 +26,20 @@ func run() error {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
|
||||
var provider playlist.Provider
|
||||
var navProv playlist.Provider
|
||||
if c := navidrome.NewFromEnv(); c != nil {
|
||||
provider = c
|
||||
navProv = c
|
||||
}
|
||||
localProv := local.New()
|
||||
var localAsProvider playlist.Provider
|
||||
if localProv != nil {
|
||||
if pls, _ := localProv.Playlists(); len(pls) > 0 {
|
||||
localAsProvider = localProv
|
||||
}
|
||||
}
|
||||
var provider playlist.Provider
|
||||
if cp := playlist.NewComposite(navProv, localAsProvider); cp != nil {
|
||||
provider = cp
|
||||
}
|
||||
|
||||
defer resolve.CleanupYTDL()
|
||||
@@ -41,6 +53,7 @@ func run() error {
|
||||
return errors.New(`usage: cliamp <file|folder|url> [...]
|
||||
|
||||
Local files cliamp track.mp3 song.flac ~/Music
|
||||
Local M3U cliamp ~/radio-stations.m3u
|
||||
HTTP stream cliamp https://example.com/song.mp3
|
||||
Radio / M3U cliamp http://radio.example.com/stream.m3u
|
||||
Podcast feed cliamp https://example.com/podcast/feed.xml
|
||||
@@ -49,6 +62,7 @@ func run() error {
|
||||
Bandcamp cliamp https://artist.bandcamp.com/album/...
|
||||
|
||||
Navidrome Set NAVIDROME_URL, NAVIDROME_USER, NAVIDROME_PASS
|
||||
Playlists ~/.config/cliamp/playlists/*.toml
|
||||
|
||||
Formats: mp3, wav, flac, ogg, m4a, aac, opus, wma (aac/opus/wma need ffmpeg)
|
||||
SoundCloud/YouTube/Bandcamp require yt-dlp (brew install yt-dlp)`)
|
||||
@@ -65,8 +79,11 @@ SoundCloud/YouTube/Bandcamp require yt-dlp (brew install yt-dlp)`)
|
||||
|
||||
themes := theme.LoadAll()
|
||||
|
||||
m := ui.NewModel(p, pl, provider, themes)
|
||||
m := ui.NewModel(p, pl, provider, localProv, themes)
|
||||
m.SetPendingURLs(resolved.Pending)
|
||||
if len(resolved.Tracks) == 0 && len(resolved.Pending) == 0 {
|
||||
m.StartInProvider()
|
||||
}
|
||||
if cfg.EQPreset != "" && cfg.EQPreset != "Custom" {
|
||||
m.SetEQPreset(cfg.EQPreset)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CompositeProvider merges multiple Provider implementations into one,
|
||||
// prefixing playlist IDs with a provider index for disambiguation.
|
||||
// When only one provider is present, it delegates directly without prefixing.
|
||||
type CompositeProvider struct {
|
||||
providers []Provider
|
||||
}
|
||||
|
||||
// NewComposite creates a CompositeProvider from the given providers.
|
||||
// Nil providers are filtered out. Returns nil if no providers remain.
|
||||
func NewComposite(providers ...Provider) *CompositeProvider {
|
||||
var valid []Provider
|
||||
for _, p := range providers {
|
||||
if p != nil {
|
||||
valid = append(valid, p)
|
||||
}
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &CompositeProvider{providers: valid}
|
||||
}
|
||||
|
||||
func (c *CompositeProvider) Name() string {
|
||||
if len(c.providers) == 1 {
|
||||
return c.providers[0].Name()
|
||||
}
|
||||
return "Playlists"
|
||||
}
|
||||
|
||||
// Playlists merges lists from all providers, prefixing IDs when multiple
|
||||
// providers are present.
|
||||
func (c *CompositeProvider) Playlists() ([]PlaylistInfo, error) {
|
||||
var all []PlaylistInfo
|
||||
for i, p := range c.providers {
|
||||
lists, err := p.Playlists()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, l := range lists {
|
||||
if len(c.providers) > 1 {
|
||||
l.ID = fmt.Sprintf("%d:%s", i, l.ID)
|
||||
l.Name = fmt.Sprintf("[%s] %s", p.Name(), l.Name)
|
||||
}
|
||||
all = append(all, l)
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// Tracks parses the provider prefix from the ID and dispatches to the
|
||||
// correct provider.
|
||||
func (c *CompositeProvider) Tracks(id string) ([]Track, error) {
|
||||
if len(c.providers) == 1 {
|
||||
return c.providers[0].Tracks(id)
|
||||
}
|
||||
|
||||
idx, realID, ok := strings.Cut(id, ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid composite ID: %s", id)
|
||||
}
|
||||
i, err := strconv.Atoi(idx)
|
||||
if err != nil || i < 0 || i >= len(c.providers) {
|
||||
return nil, fmt.Errorf("invalid provider index in ID: %s", id)
|
||||
}
|
||||
return c.providers[i].Tracks(realID)
|
||||
}
|
||||
+14
-8
@@ -42,19 +42,25 @@ func IsURL(path string) bool {
|
||||
return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://")
|
||||
}
|
||||
|
||||
// IsM3U reports whether the URL points to an M3U playlist file.
|
||||
// IsM3U reports whether the path points to an M3U playlist file (URL or local).
|
||||
func IsM3U(path string) bool {
|
||||
if !IsURL(path) {
|
||||
return false
|
||||
if IsURL(path) {
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(u.Path))
|
||||
return ext == ".m3u" || ext == ".m3u8"
|
||||
}
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(u.Path))
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
return ext == ".m3u" || ext == ".m3u8"
|
||||
}
|
||||
|
||||
// IsLocalM3U reports whether the path is a local (non-URL) M3U file.
|
||||
func IsLocalM3U(path string) bool {
|
||||
return !IsURL(path) && IsM3U(path)
|
||||
}
|
||||
|
||||
// IsYTDL reports whether the URL points to a site supported by yt-dlp
|
||||
// (SoundCloud, YouTube, Bandcamp, etc.).
|
||||
func IsYTDL(path string) bool {
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package resolve
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cliamp/playlist"
|
||||
)
|
||||
|
||||
// m3uEntry holds a single parsed M3U entry with optional EXTINF metadata.
|
||||
type m3uEntry struct {
|
||||
Path string
|
||||
Title string
|
||||
Duration int // seconds, -1 if unknown
|
||||
}
|
||||
|
||||
// parseM3U reads an M3U stream and extracts entries with EXTINF metadata.
|
||||
// Relative paths are resolved against baseDir (empty for remote M3U).
|
||||
// Handles UTF-8 BOM, \r\n line endings, missing #EXTM3U header, and bare
|
||||
// entries without EXTINF lines.
|
||||
func parseM3U(r io.Reader, baseDir string) ([]m3uEntry, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
var entries []m3uEntry
|
||||
var pending *m3uEntry // EXTINF parsed, waiting for path line
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Strip UTF-8 BOM if present (common in Windows-created M3U files).
|
||||
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip the #EXTM3U header.
|
||||
if strings.HasPrefix(line, "#EXTM3U") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse #EXTINF:duration,title
|
||||
if strings.HasPrefix(line, "#EXTINF:") {
|
||||
info := strings.TrimPrefix(line, "#EXTINF:")
|
||||
dur := -1
|
||||
title := ""
|
||||
if comma := strings.IndexByte(info, ','); comma >= 0 {
|
||||
if d, err := strconv.Atoi(strings.TrimSpace(info[:comma])); err == nil {
|
||||
dur = d
|
||||
}
|
||||
title = strings.TrimSpace(info[comma+1:])
|
||||
}
|
||||
pending = &m3uEntry{Duration: dur, Title: title}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip other comment/directive lines.
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// This is a path/URL line.
|
||||
path := line
|
||||
if baseDir != "" && !playlist.IsURL(path) && !filepath.IsAbs(path) {
|
||||
path = filepath.Join(baseDir, path)
|
||||
}
|
||||
|
||||
if pending != nil {
|
||||
pending.Path = path
|
||||
entries = append(entries, *pending)
|
||||
pending = nil
|
||||
} else {
|
||||
entries = append(entries, m3uEntry{Path: path, Duration: -1})
|
||||
}
|
||||
}
|
||||
|
||||
return entries, scanner.Err()
|
||||
}
|
||||
|
||||
// m3uEntryToTrack converts a parsed M3U entry to a playlist.Track.
|
||||
func m3uEntryToTrack(e m3uEntry) playlist.Track {
|
||||
if e.Title != "" {
|
||||
return playlist.Track{
|
||||
Path: e.Path,
|
||||
Title: e.Title,
|
||||
Stream: playlist.IsURL(e.Path),
|
||||
}
|
||||
}
|
||||
return playlist.TrackFromPath(e.Path)
|
||||
}
|
||||
|
||||
// entriesToTracks converts parsed M3U entries to playlist tracks.
|
||||
func entriesToTracks(entries []m3uEntry) []playlist.Track {
|
||||
tracks := make([]playlist.Track, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
tracks = append(tracks, m3uEntryToTrack(e))
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
// ResolveLocalM3U opens a local .m3u/.m3u8 file, parses it with EXTINF
|
||||
// metadata, and returns the resulting tracks. Relative paths in the M3U
|
||||
// are resolved against the directory containing the M3U file.
|
||||
func ResolveLocalM3U(path string) ([]playlist.Track, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
entries, err := parseM3U(f, filepath.Dir(path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entriesToTracks(entries), nil
|
||||
}
|
||||
+16
-15
@@ -47,6 +47,14 @@ func Args(args []string) (Result, error) {
|
||||
matches = []string{arg}
|
||||
}
|
||||
for _, path := range matches {
|
||||
if playlist.IsLocalM3U(path) {
|
||||
tracks, err := ResolveLocalM3U(path)
|
||||
if err != nil {
|
||||
return r, fmt.Errorf("loading m3u %s: %w", path, err)
|
||||
}
|
||||
r.Tracks = append(r.Tracks, tracks...)
|
||||
continue
|
||||
}
|
||||
resolved, err := collectAudioFiles(path)
|
||||
if err != nil {
|
||||
return r, fmt.Errorf("scanning %s: %w", path, err)
|
||||
@@ -79,13 +87,11 @@ func Remote(urls []string) ([]playlist.Track, error) {
|
||||
}
|
||||
tracks = append(tracks, t...)
|
||||
case playlist.IsM3U(u):
|
||||
streams, err := resolveM3U(u)
|
||||
t, err := resolveM3U(u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving m3u %s: %w", u, err)
|
||||
}
|
||||
for _, s := range streams {
|
||||
tracks = append(tracks, playlist.TrackFromPath(s))
|
||||
}
|
||||
tracks = append(tracks, t...)
|
||||
}
|
||||
}
|
||||
return tracks, nil
|
||||
@@ -164,24 +170,19 @@ func resolveFeed(feedURL string) ([]playlist.Track, error) {
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// resolveM3U fetches an M3U playlist URL and returns the stream URLs it contains.
|
||||
func resolveM3U(m3uURL string) ([]string, error) {
|
||||
// resolveM3U fetches an M3U playlist URL and returns tracks with EXTINF metadata.
|
||||
func resolveM3U(m3uURL string) ([]playlist.Track, error) {
|
||||
resp, err := http.Get(m3uURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var urls []string
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
urls = append(urls, line)
|
||||
entries, err := parseM3U(resp.Body, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return urls, scanner.Err()
|
||||
return entriesToTracks(entries), nil
|
||||
}
|
||||
|
||||
// ytdlFlatEntry holds JSON fields from yt-dlp --flat-playlist output.
|
||||
|
||||
+204
@@ -25,6 +25,11 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
|
||||
return m.handleThemeKey(msg)
|
||||
}
|
||||
|
||||
// Playlist manager overlay (browse, add, remove, delete)
|
||||
if m.showPlManager {
|
||||
return m.handlePlaylistManagerKey(msg)
|
||||
}
|
||||
|
||||
if m.searching {
|
||||
return m.handleSearchKey(msg)
|
||||
}
|
||||
@@ -200,6 +205,11 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
|
||||
m.prevFocus = m.focus
|
||||
m.focus = focusSearch
|
||||
|
||||
case "p":
|
||||
if m.localProvider != nil {
|
||||
m.openPlaylistManager()
|
||||
}
|
||||
|
||||
case "t":
|
||||
m.openThemePicker()
|
||||
|
||||
@@ -337,6 +347,200 @@ func (m *Model) handleSearchKey(msg tea.KeyMsg) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlaylistManagerKey dispatches keys to the active manager screen.
|
||||
func (m *Model) handlePlaylistManagerKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch m.plMgrScreen {
|
||||
case plMgrScreenList:
|
||||
return m.handlePlMgrListKey(msg)
|
||||
case plMgrScreenTracks:
|
||||
return m.handlePlMgrTracksKey(msg)
|
||||
case plMgrScreenNewName:
|
||||
return m.handlePlMgrNewNameKey(msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlMgrListKey handles keys on screen 0 (playlist list).
|
||||
func (m *Model) handlePlMgrListKey(msg tea.KeyMsg) tea.Cmd {
|
||||
// If waiting for delete confirmation, only accept y/n.
|
||||
if m.plMgrConfirmDel {
|
||||
switch msg.String() {
|
||||
case "y", "Y":
|
||||
if m.plMgrCursor < len(m.plMgrPlaylists) {
|
||||
name := m.plMgrPlaylists[m.plMgrCursor].Name
|
||||
if err := m.localProvider.DeletePlaylist(name); err != nil {
|
||||
m.saveMsg = fmt.Sprintf("Delete failed: %s", err)
|
||||
m.saveMsgTTL = 60
|
||||
} else {
|
||||
m.saveMsg = fmt.Sprintf("Deleted \"%s\"", name)
|
||||
m.saveMsgTTL = 60
|
||||
}
|
||||
m.plMgrRefreshList()
|
||||
}
|
||||
m.plMgrConfirmDel = false
|
||||
default:
|
||||
m.plMgrConfirmDel = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
count := len(m.plMgrPlaylists) + 1 // +1 for "+ New Playlist..."
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
m.showPlManager = false
|
||||
m.player.Close()
|
||||
m.quitting = true
|
||||
return tea.Quit
|
||||
case "up", "k":
|
||||
if m.plMgrCursor > 0 {
|
||||
m.plMgrCursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.plMgrCursor < count-1 {
|
||||
m.plMgrCursor++
|
||||
}
|
||||
case "enter", "l", "right":
|
||||
if m.plMgrCursor < len(m.plMgrPlaylists) {
|
||||
m.plMgrEnterTrackList(m.plMgrPlaylists[m.plMgrCursor].Name)
|
||||
} else {
|
||||
// "+ New Playlist..." selected
|
||||
m.plMgrScreen = plMgrScreenNewName
|
||||
m.plMgrNewName = ""
|
||||
}
|
||||
case "a":
|
||||
// Quick-add current track to the highlighted playlist.
|
||||
if m.plMgrCursor < len(m.plMgrPlaylists) {
|
||||
m.addToPlaylist(m.plMgrPlaylists[m.plMgrCursor].Name)
|
||||
m.plMgrRefreshList()
|
||||
}
|
||||
case "d":
|
||||
if m.plMgrCursor < len(m.plMgrPlaylists) {
|
||||
m.plMgrConfirmDel = true
|
||||
}
|
||||
case "esc", "p":
|
||||
m.showPlManager = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlMgrTracksKey handles keys on screen 1 (track list inside a playlist).
|
||||
func (m *Model) handlePlMgrTracksKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
m.showPlManager = false
|
||||
m.player.Close()
|
||||
m.quitting = true
|
||||
return tea.Quit
|
||||
case "up", "k":
|
||||
if m.plMgrCursor > 0 {
|
||||
m.plMgrCursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.plMgrCursor < len(m.plMgrTracks)-1 {
|
||||
m.plMgrCursor++
|
||||
}
|
||||
case "enter":
|
||||
// Load all tracks into the player and start playback.
|
||||
if len(m.plMgrTracks) > 0 {
|
||||
m.playlist.Add(m.plMgrTracks...)
|
||||
m.plCursor = m.playlist.Len() - len(m.plMgrTracks)
|
||||
m.playlist.SetIndex(m.plCursor)
|
||||
m.adjustScroll()
|
||||
m.showPlManager = false
|
||||
m.focus = focusPlaylist
|
||||
cmd := m.playCurrentTrack()
|
||||
m.notifyMPRIS()
|
||||
return cmd
|
||||
}
|
||||
case "a":
|
||||
// Add current playing track to this playlist.
|
||||
m.addToPlaylist(m.plMgrSelPlaylist)
|
||||
// Refresh the track list to show the new track.
|
||||
tracks, _ := m.localProvider.Tracks(m.plMgrSelPlaylist)
|
||||
m.plMgrTracks = tracks
|
||||
case "d":
|
||||
// Remove highlighted track.
|
||||
if len(m.plMgrTracks) > 0 && m.plMgrCursor < len(m.plMgrTracks) {
|
||||
err := m.localProvider.RemoveTrack(m.plMgrSelPlaylist, m.plMgrCursor)
|
||||
if err != nil {
|
||||
m.saveMsg = fmt.Sprintf("Remove failed: %s", err)
|
||||
m.saveMsgTTL = 60
|
||||
} else {
|
||||
m.saveMsg = "Track removed"
|
||||
m.saveMsgTTL = 60
|
||||
}
|
||||
// Reload tracks (or go back if playlist was deleted).
|
||||
tracks, err := m.localProvider.Tracks(m.plMgrSelPlaylist)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
// Playlist was auto-deleted (empty). Return to list.
|
||||
m.plMgrRefreshList()
|
||||
m.plMgrScreen = plMgrScreenList
|
||||
m.plMgrCursor = 0
|
||||
return nil
|
||||
}
|
||||
m.plMgrTracks = tracks
|
||||
if m.plMgrCursor >= len(m.plMgrTracks) {
|
||||
m.plMgrCursor = len(m.plMgrTracks) - 1
|
||||
}
|
||||
}
|
||||
case "esc", "backspace", "h", "left":
|
||||
// Go back to playlist list.
|
||||
m.plMgrRefreshList()
|
||||
m.plMgrScreen = plMgrScreenList
|
||||
// Try to position cursor on the playlist we just left.
|
||||
for i, pl := range m.plMgrPlaylists {
|
||||
if pl.Name == m.plMgrSelPlaylist {
|
||||
m.plMgrCursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
m.plMgrConfirmDel = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlMgrNewNameKey handles keys on screen 2 (new playlist name input).
|
||||
func (m *Model) handlePlMgrNewNameKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.Type {
|
||||
case tea.KeyEscape:
|
||||
m.plMgrScreen = plMgrScreenList
|
||||
case tea.KeyEnter:
|
||||
name := strings.TrimSpace(m.plMgrNewName)
|
||||
if name != "" {
|
||||
m.addToPlaylist(name)
|
||||
m.plMgrRefreshList()
|
||||
m.plMgrScreen = plMgrScreenList
|
||||
}
|
||||
case tea.KeyBackspace:
|
||||
if len(m.plMgrNewName) > 0 {
|
||||
_, size := utf8.DecodeLastRuneInString(m.plMgrNewName)
|
||||
m.plMgrNewName = m.plMgrNewName[:len(m.plMgrNewName)-size]
|
||||
}
|
||||
default:
|
||||
if msg.Type == tea.KeyRunes {
|
||||
m.plMgrNewName += string(msg.Runes)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addToPlaylist appends the current track to a local playlist and shows a status message.
|
||||
func (m *Model) addToPlaylist(name string) {
|
||||
track, idx := m.playlist.Current()
|
||||
if idx < 0 {
|
||||
m.saveMsg = "No track to add"
|
||||
m.saveMsgTTL = 40
|
||||
return
|
||||
}
|
||||
if err := m.localProvider.AddTrack(name, track); err != nil {
|
||||
m.saveMsg = fmt.Sprintf("Failed: %s", err)
|
||||
m.saveMsgTTL = 60
|
||||
return
|
||||
}
|
||||
m.saveMsg = fmt.Sprintf("Added to \"%s\"", name)
|
||||
m.saveMsgTTL = 60 // ~3s
|
||||
}
|
||||
|
||||
// handleThemeKey processes key presses while the theme picker is open.
|
||||
func (m *Model) handleThemeKey(msg tea.KeyMsg) tea.Cmd {
|
||||
count := len(m.themes) + 1 // +1 for Default
|
||||
|
||||
+75
-15
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"cliamp/external/local"
|
||||
"cliamp/mpris"
|
||||
"cliamp/player"
|
||||
"cliamp/playlist"
|
||||
@@ -23,6 +24,14 @@ const (
|
||||
focusProvider
|
||||
)
|
||||
|
||||
type plMgrScreenType int
|
||||
|
||||
const (
|
||||
plMgrScreenList plMgrScreenType = iota
|
||||
plMgrScreenTracks
|
||||
plMgrScreenNewName
|
||||
)
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
// Model is the Bubbletea model for the CLIAMP TUI.
|
||||
@@ -42,6 +51,7 @@ type Model struct {
|
||||
height int
|
||||
|
||||
provider playlist.Provider
|
||||
localProvider *local.Provider // direct ref for write operations (add-to-playlist)
|
||||
providerLists []playlist.PlaylistInfo
|
||||
provCursor int
|
||||
provLoading bool
|
||||
@@ -76,28 +86,38 @@ type Model struct {
|
||||
mpris *mpris.Service
|
||||
|
||||
// Theme state: -1 = Default (ANSI), 0+ = index into themes
|
||||
themes []theme.Theme
|
||||
themeIdx int
|
||||
showThemes bool // theme picker overlay visible
|
||||
themeCursor int // cursor in theme picker (0 = Default, 1+ = themes[i-1])
|
||||
themeSavedIdx int // themeIdx before opening picker, for cancel/restore
|
||||
themes []theme.Theme
|
||||
themeIdx int
|
||||
showThemes bool // theme picker overlay visible
|
||||
themeCursor int // cursor in theme picker (0 = Default, 1+ = themes[i-1])
|
||||
themeSavedIdx int // themeIdx before opening picker, for cancel/restore
|
||||
|
||||
// Playlist manager overlay (browse, add, remove, delete playlists)
|
||||
showPlManager bool // overlay visible
|
||||
plMgrScreen plMgrScreenType
|
||||
plMgrCursor int
|
||||
plMgrPlaylists []playlist.PlaylistInfo
|
||||
plMgrSelPlaylist string // playlist name open in screen 1
|
||||
plMgrTracks []playlist.Track // tracks in the selected playlist
|
||||
plMgrNewName string
|
||||
plMgrConfirmDel bool
|
||||
}
|
||||
|
||||
// NewModel creates a Model wired to the given player and playlist.
|
||||
func NewModel(p *player.Player, pl *playlist.Playlist, prov playlist.Provider, themes []theme.Theme) Model {
|
||||
// localProv is an optional direct reference to the local provider for write ops.
|
||||
func NewModel(p *player.Player, pl *playlist.Playlist, prov playlist.Provider, localProv *local.Provider, themes []theme.Theme) Model {
|
||||
m := Model{
|
||||
player: p,
|
||||
playlist: pl,
|
||||
vis: NewVisualizer(44100),
|
||||
plVisible: 5,
|
||||
eqPresetIdx: -1, // custom until a preset is selected
|
||||
themes: themes,
|
||||
themeIdx: -1, // Default (ANSI)
|
||||
player: p,
|
||||
playlist: pl,
|
||||
vis: NewVisualizer(44100),
|
||||
plVisible: 5,
|
||||
eqPresetIdx: -1, // custom until a preset is selected
|
||||
themes: themes,
|
||||
themeIdx: -1, // Default (ANSI)
|
||||
localProvider: localProv,
|
||||
}
|
||||
if prov != nil {
|
||||
m.provider = prov
|
||||
m.focus = focusProvider
|
||||
m.provLoading = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -166,6 +186,46 @@ func (m *Model) themePickerCancel() {
|
||||
m.showThemes = false
|
||||
}
|
||||
|
||||
// openPlaylistManager loads playlist metadata and opens the manager overlay.
|
||||
func (m *Model) openPlaylistManager() {
|
||||
m.plMgrRefreshList()
|
||||
m.plMgrScreen = plMgrScreenList
|
||||
m.plMgrConfirmDel = false
|
||||
m.showPlManager = true
|
||||
}
|
||||
|
||||
// plMgrEnterTrackList loads the tracks for a playlist and switches to screen 1.
|
||||
func (m *Model) plMgrEnterTrackList(name string) {
|
||||
tracks, _ := m.localProvider.Tracks(name)
|
||||
m.plMgrSelPlaylist = name
|
||||
m.plMgrTracks = tracks
|
||||
m.plMgrScreen = plMgrScreenTracks
|
||||
m.plMgrCursor = 0
|
||||
m.plMgrConfirmDel = false
|
||||
}
|
||||
|
||||
// plMgrRefreshList reloads playlist names and counts from disk and clamps the cursor.
|
||||
func (m *Model) plMgrRefreshList() {
|
||||
m.plMgrPlaylists, _ = m.localProvider.Playlists()
|
||||
// +1 for the "+ New Playlist..." entry
|
||||
total := len(m.plMgrPlaylists) + 1
|
||||
if m.plMgrCursor >= total {
|
||||
m.plMgrCursor = total - 1
|
||||
}
|
||||
if m.plMgrCursor < 0 {
|
||||
m.plMgrCursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
// StartInProvider configures the model to begin in the provider browse view.
|
||||
// Call this from main when no CLI tracks or pending URLs were given.
|
||||
func (m *Model) StartInProvider() {
|
||||
if m.provider != nil {
|
||||
m.focus = focusProvider
|
||||
m.provLoading = true
|
||||
}
|
||||
}
|
||||
|
||||
// SetPendingURLs stores remote URLs (feeds, M3U) for async resolution after Init.
|
||||
func (m *Model) SetPendingURLs(urls []string) {
|
||||
m.pendingURLs = urls
|
||||
|
||||
+134
-19
@@ -33,6 +33,10 @@ func (m Model) View() string {
|
||||
return m.renderThemePicker()
|
||||
}
|
||||
|
||||
if m.showPlManager {
|
||||
return m.renderPlaylistManager()
|
||||
}
|
||||
|
||||
sections := []string{
|
||||
// Now playing
|
||||
m.renderTitle(),
|
||||
@@ -76,6 +80,15 @@ func (m Model) View() string {
|
||||
lipgloss.NewStyle().MarginLeft(padLeft).Render(frame)
|
||||
}
|
||||
|
||||
// centerOverlay wraps content in a frame and centers it in the terminal.
|
||||
func (m Model) centerOverlay(content string) string {
|
||||
frame := frameStyle.Render(content)
|
||||
padLeft := max(0, (m.width-lipgloss.Width(frame))/2)
|
||||
padTop := max(0, (m.height-lipgloss.Height(frame))/2)
|
||||
return strings.Repeat("\n", padTop) +
|
||||
lipgloss.NewStyle().MarginLeft(padLeft).Render(frame)
|
||||
}
|
||||
|
||||
func (m Model) renderKeymapOverlay() string {
|
||||
keys := []struct{ key, action string }{
|
||||
{"Space", "Play / Pause"},
|
||||
@@ -92,6 +105,7 @@ func (m Model) renderKeymapOverlay() string {
|
||||
{"h l", "EQ cursor left/right"},
|
||||
{"Enter", "Play selected track"},
|
||||
{"a", "Toggle queue (play next)"},
|
||||
{"p", "Playlist manager"},
|
||||
{"S", "Save track to ~/Music"},
|
||||
{"r", "Cycle repeat"},
|
||||
{"z", "Toggle shuffle"},
|
||||
@@ -112,16 +126,7 @@ func (m Model) renderKeymapOverlay() string {
|
||||
}
|
||||
lines = append(lines, "", helpStyle.Render("Press any key to close"))
|
||||
|
||||
content := strings.Join(lines, "\n")
|
||||
frame := frameStyle.Render(content)
|
||||
|
||||
frameW := lipgloss.Width(frame)
|
||||
frameH := lipgloss.Height(frame)
|
||||
padLeft := max(0, (m.width-frameW)/2)
|
||||
padTop := max(0, (m.height-frameH)/2)
|
||||
|
||||
return strings.Repeat("\n", padTop) +
|
||||
lipgloss.NewStyle().MarginLeft(padLeft).Render(frame)
|
||||
return m.centerOverlay(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (m Model) renderThemePicker() string {
|
||||
@@ -159,16 +164,123 @@ func (m Model) renderThemePicker() string {
|
||||
|
||||
lines = append(lines, "", helpStyle.Render("[↑↓]Navigate [Enter]Select [Esc]Cancel"))
|
||||
|
||||
content := strings.Join(lines, "\n")
|
||||
frame := frameStyle.Render(content)
|
||||
return m.centerOverlay(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
frameW := lipgloss.Width(frame)
|
||||
frameH := lipgloss.Height(frame)
|
||||
padLeft := max(0, (m.width-frameW)/2)
|
||||
padTop := max(0, (m.height-frameH)/2)
|
||||
func (m Model) renderPlaylistManager() string {
|
||||
var lines []string
|
||||
switch m.plMgrScreen {
|
||||
case plMgrScreenList:
|
||||
lines = m.renderPlMgrList()
|
||||
case plMgrScreenTracks:
|
||||
lines = m.renderPlMgrTracks()
|
||||
case plMgrScreenNewName:
|
||||
lines = m.renderPlMgrNewName()
|
||||
}
|
||||
|
||||
return strings.Repeat("\n", padTop) +
|
||||
lipgloss.NewStyle().MarginLeft(padLeft).Render(frame)
|
||||
if m.saveMsg != "" {
|
||||
lines = append(lines, "", statusStyle.Render(m.saveMsg))
|
||||
}
|
||||
|
||||
return m.centerOverlay(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (m Model) renderPlMgrList() []string {
|
||||
lines := []string{
|
||||
titleStyle.Render("P L A Y L I S T S"),
|
||||
"",
|
||||
}
|
||||
|
||||
count := len(m.plMgrPlaylists) + 1 // +1 for "+ New Playlist..."
|
||||
maxVisible := 12
|
||||
scroll := 0
|
||||
if m.plMgrCursor >= maxVisible {
|
||||
scroll = m.plMgrCursor - maxVisible + 1
|
||||
}
|
||||
|
||||
for i := scroll; i < count && i < scroll+maxVisible; i++ {
|
||||
var label string
|
||||
if i < len(m.plMgrPlaylists) {
|
||||
pl := m.plMgrPlaylists[i]
|
||||
label = fmt.Sprintf("%s (%d tracks)", pl.Name, pl.TrackCount)
|
||||
} else {
|
||||
label = "+ New Playlist..."
|
||||
}
|
||||
|
||||
if i == m.plMgrCursor {
|
||||
if m.plMgrConfirmDel && i < len(m.plMgrPlaylists) {
|
||||
lines = append(lines, playlistSelectedStyle.Render("> Delete \""+m.plMgrPlaylists[i].Name+"\"? [y/n]"))
|
||||
} else {
|
||||
lines = append(lines, playlistSelectedStyle.Render("> "+label))
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, dimStyle.Render(" "+label))
|
||||
}
|
||||
}
|
||||
|
||||
if count > maxVisible {
|
||||
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(" %d/%d playlists", m.plMgrCursor+1, count)))
|
||||
}
|
||||
|
||||
lines = append(lines, "", helpStyle.Render("[↑↓]Navigate [Enter/→]Open [a]Add track [d]Delete [Esc]Close"))
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m Model) renderPlMgrTracks() []string {
|
||||
title := fmt.Sprintf("P L A Y L I S T : %s", m.plMgrSelPlaylist)
|
||||
lines := []string{
|
||||
titleStyle.Render(title),
|
||||
"",
|
||||
}
|
||||
|
||||
if len(m.plMgrTracks) == 0 {
|
||||
lines = append(lines, dimStyle.Render(" (empty)"))
|
||||
lines = append(lines, "", helpStyle.Render("[a]Add track [Esc]Back"))
|
||||
return lines
|
||||
}
|
||||
|
||||
maxVisible := 12
|
||||
scroll := 0
|
||||
if m.plMgrCursor >= maxVisible {
|
||||
scroll = m.plMgrCursor - maxVisible + 1
|
||||
}
|
||||
|
||||
for i := scroll; i < len(m.plMgrTracks) && i < scroll+maxVisible; i++ {
|
||||
name := m.plMgrTracks[i].DisplayName()
|
||||
maxW := panelWidth - 8
|
||||
nameRunes := []rune(name)
|
||||
if len(nameRunes) > maxW {
|
||||
name = string(nameRunes[:maxW-1]) + "…"
|
||||
}
|
||||
label := fmt.Sprintf("%d. %s", i+1, name)
|
||||
|
||||
if i == m.plMgrCursor {
|
||||
lines = append(lines, playlistSelectedStyle.Render("> "+label))
|
||||
} else {
|
||||
lines = append(lines, dimStyle.Render(" "+label))
|
||||
}
|
||||
}
|
||||
|
||||
if len(m.plMgrTracks) > maxVisible {
|
||||
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(" %d/%d tracks", m.plMgrCursor+1, len(m.plMgrTracks))))
|
||||
}
|
||||
|
||||
lines = append(lines, "", helpStyle.Render("[↑↓]Navigate [Enter]Play all [a]Add track [d]Remove [Esc]Back"))
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m Model) renderPlMgrNewName() []string {
|
||||
lines := []string{
|
||||
titleStyle.Render("N E W P L A Y L I S T"),
|
||||
"",
|
||||
dimStyle.Render(" Playlist name:"),
|
||||
playlistSelectedStyle.Render(" " + m.plMgrNewName + "_"),
|
||||
"",
|
||||
helpStyle.Render("[Enter]Create & add track [Esc]Cancel"),
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m Model) renderTitle() string {
|
||||
@@ -349,7 +461,7 @@ func (m Model) renderPlaylist() string {
|
||||
return dimStyle.Render(fmt.Sprintf(" Loading %s...", m.provider.Name()))
|
||||
}
|
||||
if len(m.providerLists) == 0 {
|
||||
return dimStyle.Render(" No playlists found.")
|
||||
return dimStyle.Render(" No playlists found.\n Add playlists to ~/.config/cliamp/playlists/")
|
||||
}
|
||||
|
||||
visible := min(m.plVisible, len(m.providerLists))
|
||||
@@ -492,6 +604,9 @@ func (m Model) renderHelp() string {
|
||||
if !track.Stream && strings.HasPrefix(track.Path, os.TempDir()) {
|
||||
help += "[S]Save "
|
||||
}
|
||||
if m.localProvider != nil {
|
||||
help += "[p]Playlists "
|
||||
}
|
||||
help += "[+-]Vol [m]Mono [e]EQ [t]Theme [v]Vis [a]Queue [/]Search "
|
||||
|
||||
// Conditionally show the back button if a provider is configured
|
||||
|
||||
Reference in New Issue
Block a user