Add proper playlist support including m3u files

This commit is contained in:
Bjarne Øverli
2026-02-28 13:33:23 +01:00
parent d4704092b6
commit 1baaa46530
11 changed files with 1070 additions and 61 deletions
+41 -1
View File
@@ -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
+164
View File
@@ -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 |
+209
View File
@@ -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
}
+20 -3
View File
@@ -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)
}
+74
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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