feat(lyrics,mediactl): embed local lyrics and album art in MPRIS/NowPlaying

Read embedded lyrics (LRC or plain text) and cover art from local file
tags at play time. Lyrics are preferred over network fetch when present;
album art is cached by content hash under ~/.local/share/cliamp/album-art/
and published via mpris:artUrl (Linux) and MPNowPlayingInfoCenter (macOS).
This commit is contained in:
lentikr
2026-06-29 20:17:41 +08:00
parent 854911e9cd
commit 184a67e519
25 changed files with 452 additions and 53 deletions
+1
View File
@@ -0,0 +1 @@
I use AI, and if this file is not deleted, I haven't reviewed my own code.
+2
View File
@@ -156,6 +156,7 @@ func PlaylistShow(name string, jsonOutput bool) error {
Year int `json:"year,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DurationSecs int `json:"duration_secs,omitempty"`
AlbumArtURL string `json:"album_art_url,omitempty"`
Bookmark bool `json:"bookmark,omitempty"`
}
out := make([]jsonTrack, len(tracks))
@@ -169,6 +170,7 @@ func PlaylistShow(name string, jsonOutput bool) error {
Year: t.Year,
TrackNumber: t.TrackNumber,
DurationSecs: t.DurationSecs,
AlbumArtURL: t.AlbumArtURL,
Bookmark: t.Bookmark,
}
}
+3 -1
View File
@@ -1,12 +1,14 @@
# Lyrics
Press `y` to show lyrics for the current track. Lyrics are fetched from LRCLIB and NetEase Cloud Music.
Press `y` to show lyrics for the current track. For local files, cliamp uses embedded lyrics from the file tags first. If no embedded lyrics are present, lyrics are fetched from LRCLIB and NetEase Cloud Music.
## Modes
- **Synced lyrics**: for local files and Navidrome tracks, lyrics auto scroll and highlight the active line in time with playback.
- **Scroll mode**: for streams and plain lyrics without timestamps, use `j`/`k` or arrow keys to scroll manually.
Embedded LRC lyrics keep their timestamps. Embedded plain text lyrics are shown in scroll mode.
## Streams
Lyrics auto update when the ICY metadata changes (e.g., internet radio station transitions).
+3
View File
@@ -69,6 +69,7 @@ Track metadata is published under the standard MPRIS keys:
| `xesam:artist` | Artist name (as a list with one entry) |
| `xesam:album` | Album name, when available |
| `xesam:url` | File path or stream URL |
| `mpris:artUrl` | Embedded album artwork from local files, when available |
| `mpris:length` | Duration in microseconds |
Query metadata with:
@@ -114,6 +115,8 @@ On macOS, Cliamp publishes now-playing information to the system's MPNowPlayingI
- Hardware media keys (play/pause, next, previous)
- Bluetooth headphone buttons
Local files with embedded cover art publish that artwork to Control Centre and Lock Screen media controls. Artwork is cached by content under `~/.local/share/cliamp/album-art/` and pruned opportunistically to stay around 100 MB.
The macOS implementation requires the media-control runtime to pin the main goroutine to thread 0 (via `runtime.LockOSThread`) so that the Cocoa run loop can pump events. Bubbletea runs on a background goroutine instead.
## Architecture
+8 -1
View File
@@ -82,6 +82,14 @@ Each `[[track]]` section supports:
| `path` | Yes | File path or HTTP URL |
| `title` | Yes | Display title |
| `artist` | No | Artist name |
| `album` | No | Album name |
| `genre` | No | Genre name |
| `year` | No | Release year |
| `track_number` | No | Track number |
| `duration_secs` | No | Duration in seconds |
| `embedded_lyrics` | No | Lyrics copied from local file tags |
| `album_art_url` | No | Cached file URL for embedded album art |
| `bookmark` | No | Bookmark flag |
HTTP/HTTPS paths are automatically treated as streams.
@@ -191,4 +199,3 @@ title = "My Radio"
| `a` | Add currently playing track |
| `d` | Delete playlist (confirms) / Remove track |
| `←` / `Backspace` | Go back from tracks screen to list |
+8
View File
@@ -458,6 +458,12 @@ func writeTrack(w io.Writer, t playlist.Track) {
if t.DurationSecs != 0 {
fmt.Fprintf(w, "duration_secs = %d\n", t.DurationSecs)
}
if t.EmbeddedLyrics != "" {
fmt.Fprintf(w, "embedded_lyrics = %q\n", t.EmbeddedLyrics)
}
if t.AlbumArtURL != "" {
fmt.Fprintf(w, "album_art_url = %q\n", t.AlbumArtURL)
}
if t.Bookmark {
fmt.Fprintln(w, "bookmark = true")
}
@@ -481,6 +487,8 @@ func (p *Provider) loadTOML(path string) ([]playlist.Track, error) {
Genre: f["genre"],
Feed: f["feed"] == "true",
}
t.EmbeddedLyrics = f["embedded_lyrics"]
t.AlbumArtURL = f["album_art_url"]
t.Stream = playlist.IsURL(t.Path)
// "favorite" is the pre-rename alias for "bookmark"; prefer bookmark.
bookmark, ok := f["bookmark"]
+8 -1
View File
@@ -100,6 +100,8 @@ func TestWriteTrackAllFields(t *testing.T) {
DurationSecs: 240,
Bookmark: true,
Feed: true,
EmbeddedLyrics: "[00:01.00]Line",
AlbumArtURL: "file:///tmp/cover.jpg",
})
got := buf.String()
@@ -112,6 +114,8 @@ func TestWriteTrackAllFields(t *testing.T) {
"year = 2024",
"track_number = 3",
"duration_secs = 240",
`embedded_lyrics = "[00:01.00]Line"`,
`album_art_url = "file:///tmp/cover.jpg"`,
"bookmark = true",
"feed = true",
} {
@@ -128,7 +132,7 @@ func TestLoadTOMLRoundTrip(t *testing.T) {
os.MkdirAll(p.dir, 0o755)
tracks := []playlist.Track{
{Path: "/a.mp3", Title: "A", Artist: "Art1", Album: "Alb", Year: 2020, TrackNumber: 1, DurationSecs: 180, Bookmark: true},
{Path: "/a.mp3", Title: "A", Artist: "Art1", Album: "Alb", Year: 2020, TrackNumber: 1, DurationSecs: 180, Bookmark: true, EmbeddedLyrics: "Line 1\nLine 2", AlbumArtURL: "file:///tmp/a.jpg"},
{Path: "/b.flac", Title: "B", Genre: "Jazz", Feed: true},
}
@@ -153,6 +157,9 @@ func TestLoadTOMLRoundTrip(t *testing.T) {
if loaded[0].Year != 2020 || loaded[0].TrackNumber != 1 || loaded[0].DurationSecs != 180 {
t.Fatalf("track 0 numeric fields mismatch: %+v", loaded[0])
}
if loaded[0].EmbeddedLyrics != "Line 1\nLine 2" || loaded[0].AlbumArtURL != "file:///tmp/a.jpg" {
t.Fatalf("track 0 embedded fields mismatch: %+v", loaded[0])
}
if loaded[1].Path != "/b.flac" || loaded[1].Title != "B" || loaded[1].Genre != "Jazz" {
t.Fatalf("track 1 mismatch: %+v", loaded[1])
+1
View File
@@ -32,6 +32,7 @@ type Track struct {
Genre string
TrackNumber int
URL string
ArtURL string
Duration time.Duration
}
+1
View File
@@ -52,6 +52,7 @@ func TestStateFields(t *testing.T) {
Genre: "Rock",
TrackNumber: 3,
URL: "file:///song.mp3",
ArtURL: "file:///cover.jpg",
Duration: 3 * time.Minute,
},
VolumeDB: -3.0,
+18
View File
@@ -112,6 +112,24 @@ func Fetch(artist, title string) ([]Line, error) {
return nil, ErrNotFound
}
// ParseEmbedded converts lyrics read from local file tags into display lines.
// Timestamped LRC data remains synced; plain text is returned as scrollable
// lines at timestamp 0.
func ParseEmbedded(data string) []Line {
data = strings.TrimSpace(data)
if data == "" {
return nil
}
lines := parseLRC(data)
if len(lines) > 0 {
return lines
}
for raw := range strings.SplitSeq(data, "\n") {
lines = append(lines, Line{Start: 0, Text: strings.TrimSpace(raw)})
}
return lines
}
func fetchLRCLIB(query string) ([]Line, error) {
searchURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query))
+20
View File
@@ -80,6 +80,26 @@ func TestParseLRCEmptyText(t *testing.T) {
}
}
func TestParseEmbeddedPrefersSyncedLRC(t *testing.T) {
lines := ParseEmbedded("[00:01.50]Hello\nplain fallback")
if len(lines) != 1 {
t.Fatalf("got %d lines, want 1 synced line", len(lines))
}
if lines[0].Start != 1500*time.Millisecond || lines[0].Text != "Hello" {
t.Fatalf("line = %+v, want synced Hello at 1.5s", lines[0])
}
}
func TestParseEmbeddedPlainText(t *testing.T) {
lines := ParseEmbedded("Line one\nLine two")
if len(lines) != 2 {
t.Fatalf("got %d lines, want 2", len(lines))
}
if lines[0].Start != 0 || lines[0].Text != "Line one" || lines[1].Text != "Line two" {
t.Fatalf("lines = %+v, want plain zero-timestamp lines", lines)
}
}
func TestCleanQuery(t *testing.T) {
tests := []struct {
input, want string
+3
View File
@@ -37,6 +37,9 @@ func makeMetadata(t playback.Track, trackID dbus.ObjectPath) map[string]dbus.Var
if t.URL != "" {
m["xesam:url"] = dbus.MakeVariant(t.URL)
}
if t.ArtURL != "" {
m["mpris:artUrl"] = dbus.MakeVariant(t.ArtURL)
}
if t.Duration > 0 {
m["mpris:length"] = dbus.MakeVariant(t.Duration.Microseconds())
}
+3
View File
@@ -18,6 +18,7 @@ func TestMakeMetadataMapsPlaybackTrackToMPRISFields(t *testing.T) {
Genre: "Ambient",
TrackNumber: 7,
URL: "file:///tmp/song.mp3",
ArtURL: "file:///tmp/cover.jpg",
Duration: 3*time.Minute + 15*time.Second,
}
@@ -31,6 +32,7 @@ func TestMakeMetadataMapsPlaybackTrackToMPRISFields(t *testing.T) {
"xesam:genre": dbus.MakeVariant([]string{"Ambient"}),
"xesam:trackNumber": dbus.MakeVariant(7),
"xesam:url": dbus.MakeVariant("file:///tmp/song.mp3"),
"mpris:artUrl": dbus.MakeVariant("file:///tmp/cover.jpg"),
"mpris:length": dbus.MakeVariant(track.Duration.Microseconds()),
}
@@ -64,6 +66,7 @@ func TestMakeMetadataOmitsEmptyOptionalFields(t *testing.T) {
"xesam:genre",
"xesam:trackNumber",
"xesam:url",
"mpris:artUrl",
"mpris:length",
} {
if _, ok := got[key]; ok {
+22 -3
View File
@@ -125,7 +125,7 @@ static void bridgeDestroy(MediaCtlBridgeRef ref) {
}
// playbackState: 0 = stopped, 1 = playing, 2 = paused
static void updateNowPlaying(const char *title, const char *artist, const char *album,
static void updateNowPlaying(const char *title, const char *artist, const char *album, const char *artURL,
double durationSecs, double elapsedSecs, int playbackState, int canSeek) {
@autoreleasepool {
MPRemoteCommandCenter *cc = [MPRemoteCommandCenter sharedCommandCenter];
@@ -141,6 +141,19 @@ static void updateNowPlaying(const char *title, const char *artist, const char *
if (title) info[MPMediaItemPropertyTitle] = @(title);
if (artist) info[MPMediaItemPropertyArtist] = @(artist);
if (album) info[MPMediaItemPropertyAlbumTitle] = @(album);
if (artURL) {
// cliamp only passes local file:// artwork URLs here. Avoid extending
// this path to remote artwork without moving image loading off-thread.
NSURL *url = [NSURL URLWithString:@(artURL)];
NSImage *image = url ? [[[NSImage alloc] initWithContentsOfURL:url] autorelease] : nil;
if (image) {
MPMediaItemArtwork *artwork = [[[MPMediaItemArtwork alloc] initWithBoundsSize:image.size
requestHandler:^NSImage * _Nonnull(CGSize size) {
return image;
}] autorelease];
info[MPMediaItemPropertyArtwork] = artwork;
}
}
if (durationSecs > 0) info[MPMediaItemPropertyPlaybackDuration] = @(durationSecs);
if (elapsedSecs >= 0) info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(elapsedSecs);
info[MPNowPlayingInfoPropertyPlaybackRate] = @(playbackState == 1 ? 1.0 : 0.0);
@@ -198,6 +211,7 @@ func Run(prog *tea.Program, svc *Service) (tea.Model, error) {
type updateReq struct {
title, artist, album string
artURL string
durationSecs, elapsedSecs float64
status playback.Status
canSeek bool
@@ -486,7 +500,7 @@ func (s *Service) beginRelease(allowRunLoop bool) (cgo.Handle, C.MediaCtlBridgeR
}
func applyUpdate(req updateReq) {
var cTitle, cArtist, cAlbum *C.char
var cTitle, cArtist, cAlbum, cArtURL *C.char
if req.title != "" {
cTitle = C.CString(req.title)
defer C.free(unsafe.Pointer(cTitle))
@@ -499,11 +513,15 @@ func applyUpdate(req updateReq) {
cAlbum = C.CString(req.album)
defer C.free(unsafe.Pointer(cAlbum))
}
if req.artURL != "" {
cArtURL = C.CString(req.artURL)
defer C.free(unsafe.Pointer(cArtURL))
}
canSeek := C.int(0)
if req.canSeek {
canSeek = 1
}
C.updateNowPlaying(cTitle, cArtist, cAlbum,
C.updateNowPlaying(cTitle, cArtist, cAlbum, cArtURL,
C.double(req.durationSecs), C.double(req.elapsedSecs), nowPlayingState(req.status), canSeek)
}
@@ -526,6 +544,7 @@ func (s *Service) Update(state playback.State) {
title: state.Track.Title,
artist: state.Track.Artist,
album: state.Track.Album,
artURL: state.Track.ArtURL,
durationSecs: state.Track.Duration.Seconds(),
elapsedSecs: state.Position.Seconds(),
status: state.Status,
+2 -1
View File
@@ -90,7 +90,7 @@ func TestDarwinUpdateCoalescesPendingState(t *testing.T) {
})
svc.Update(playback.State{
Status: playback.StatusPlaying,
Track: playback.Track{Title: "second", Artist: "artist"},
Track: playback.Track{Title: "second", Artist: "artist", ArtURL: "file:///tmp/cover.jpg"},
Position: 2250 * time.Millisecond,
Seekable: false,
})
@@ -100,6 +100,7 @@ func TestDarwinUpdateCoalescesPendingState(t *testing.T) {
want := updateReq{
title: "second",
artist: "artist",
artURL: "file:///tmp/cover.jpg",
durationSecs: 0,
elapsedSecs: 2.25,
status: playback.StatusPlaying,
+3
View File
@@ -48,6 +48,9 @@ type Track struct {
Unplayable bool // true when the track is known not playable in the current playback context
EmbeddedLyrics string // embedded lyrics from local file tags, when present
AlbumArtURL string // file:// URL for cached embedded album art, when present
// ProviderMeta holds provider-specific key-value pairs.
// Keys are namespaced by provider, e.g. "navidrome.id", "jellyfin.id".
ProviderMeta map[string]string
+171 -6
View File
@@ -1,17 +1,64 @@
package playlist
import (
"crypto/sha256"
"encoding/hex"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/dhowden/tag"
"cliamp/internal/appdir"
)
const (
albumArtCacheDir = "album-art"
albumArtCacheMaxBytes = 100 << 20
)
var supportedPictureExts = map[string]bool{
"jpg": true,
"jpeg": true,
"png": true,
"gif": true,
"webp": true,
"bmp": true,
"tiff": true,
}
// RefreshEmbeddedMetadata returns track with embedded local-file lyrics and
// album art populated from its current Path. Existing non-embedded fields are
// preserved so saved playlist metadata and provider fields remain stable.
func RefreshEmbeddedMetadata(track Track) Track {
if track.Path == "" || track.Stream || IsURL(track.Path) || strings.HasPrefix(track.Path, "ssh://") {
return track
}
if track.EmbeddedLyrics != "" && track.AlbumArtURL != "" {
return track
}
fresh := readTagsWithOptions(track.Path, track.AlbumArtURL == "")
if track.EmbeddedLyrics == "" {
track.EmbeddedLyrics = fresh.EmbeddedLyrics
}
if track.AlbumArtURL == "" {
track.AlbumArtURL = fresh.AlbumArtURL
}
return track
}
// readTags reads embedded metadata (ID3v2, Vorbis comments, MP4 atoms) from
// a local audio file and returns a Track. Falls back to filename parsing if
// tag reading fails or the tags contain no title.
func readTags(path string) Track {
return readTagsWithOptions(path, false)
}
func readTagsWithOptions(path string, cacheArt bool) Track {
f, err := os.Open(path)
if err != nil {
return TrackFromFilename(path)
@@ -19,23 +66,141 @@ func readTags(path string) Track {
defer f.Close()
m, err := tag.ReadFrom(f)
if err != nil || m == nil || strings.TrimSpace(m.Title()) == "" {
if err != nil || m == nil {
return TrackFromFilename(path)
}
t := Track{
Path: path,
Title: sanitizeTag(strings.TrimSpace(m.Title())),
Artist: sanitizeTag(strings.TrimSpace(m.Artist())),
Album: sanitizeTag(strings.TrimSpace(m.Album())),
Genre: sanitizeTag(strings.TrimSpace(m.Genre())),
Year: m.Year(),
EmbeddedLyrics: sanitizeTag(strings.TrimSpace(m.Lyrics())),
}
if cacheArt {
t.AlbumArtURL = cacheAlbumArt(m.Picture())
}
if strings.TrimSpace(m.Title()) == "" {
fallback := TrackFromFilename(path)
fallback.EmbeddedLyrics = t.EmbeddedLyrics
fallback.AlbumArtURL = t.AlbumArtURL
return fallback
}
t.Title = sanitizeTag(strings.TrimSpace(m.Title()))
t.Artist = sanitizeTag(strings.TrimSpace(m.Artist()))
t.Album = sanitizeTag(strings.TrimSpace(m.Album()))
t.Genre = sanitizeTag(strings.TrimSpace(m.Genre()))
t.Year = m.Year()
trackNum, _ := m.Track()
t.TrackNumber = trackNum
return t
}
func cacheAlbumArt(picture *tag.Picture) string {
if picture == nil || len(picture.Data) == 0 {
return ""
}
dir, err := appdir.DataDir()
if err != nil {
return ""
}
artDir := filepath.Join(dir, albumArtCacheDir)
if err := os.MkdirAll(artDir, 0o755); err != nil {
return ""
}
sum := sha256.Sum256(picture.Data)
ext := normalizedPictureExt(picture)
path := filepath.Join(artDir, hex.EncodeToString(sum[:])+"."+ext)
if _, err := os.Stat(path); err == nil {
now := time.Now()
_ = os.Chtimes(path, now, now)
cleanupAlbumArtCache(artDir, albumArtCacheMaxBytes, path)
return fileURL(path)
}
if err := os.WriteFile(path, picture.Data, 0o644); err != nil {
return ""
}
cleanupAlbumArtCache(artDir, albumArtCacheMaxBytes, path)
return fileURL(path)
}
func normalizedPictureExt(picture *tag.Picture) string {
ext := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(picture.Ext), "."))
if ext == "jpeg" {
return "jpg"
}
if supportedPictureExts[ext] {
return ext
}
switch strings.ToLower(strings.TrimSpace(picture.MIMEType)) {
case "image/jpeg", "image/jpg":
return "jpg"
case "image/png":
return "png"
case "image/gif":
return "gif"
case "image/webp":
return "webp"
case "image/bmp":
return "bmp"
case "image/tiff":
return "tiff"
default:
return "jpg"
}
}
func cleanupAlbumArtCache(dir string, maxBytes int64, keepPath string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type cachedFile struct {
path string
size int64
modTime time.Time
}
files := make([]cachedFile, 0, len(entries))
var total int64
for _, entry := range entries {
info, err := entry.Info()
if err != nil || !info.Mode().IsRegular() {
continue
}
path := filepath.Join(dir, entry.Name())
size := info.Size()
total += size
files = append(files, cachedFile{path: path, size: size, modTime: info.ModTime()})
}
if total <= maxBytes {
return
}
sort.Slice(files, func(i, j int) bool {
return files[i].modTime.Before(files[j].modTime)
})
for _, file := range files {
if total <= maxBytes {
return
}
if file.path == keepPath {
continue
}
if err := os.Remove(file.path); err == nil {
total -= file.size
}
}
}
func fileURL(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
u := url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}
return u.String()
}
// TrackFromFilename creates a Track by parsing "Artist - Title" from the
// filename, or using the bare filename as the title.
func TrackFromFilename(path string) Track {
+63 -1
View File
@@ -1,6 +1,15 @@
package playlist
import "testing"
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/dhowden/tag"
)
func TestTrackMeta(t *testing.T) {
t.Run("nil map returns empty", func(t *testing.T) {
@@ -78,6 +87,59 @@ func TestTrackIsLive(t *testing.T) {
})
}
func TestFileURL(t *testing.T) {
got := fileURL(filepath.Join("tmp", "cover art.jpg"))
if !strings.HasPrefix(got, "file:///") {
t.Fatalf("fileURL = %q, want file URL", got)
}
if runtime.GOOS != "windows" && !strings.Contains(got, "cover%20art.jpg") {
t.Fatalf("fileURL = %q, want escaped spaces", got)
}
}
func TestCacheAlbumArtUsesContentHash(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
first := cacheAlbumArt(&tag.Picture{Ext: "jpeg", Data: []byte("same cover")})
second := cacheAlbumArt(&tag.Picture{Ext: "jpg", Data: []byte("same cover")})
if first == "" || first != second {
t.Fatalf("cacheAlbumArt URLs = %q and %q, want same non-empty URL", first, second)
}
matches, err := filepath.Glob(filepath.Join(home, ".local", "share", "cliamp", albumArtCacheDir, "*"))
if err != nil {
t.Fatalf("Glob: %v", err)
}
if len(matches) != 1 {
t.Fatalf("cached files = %d, want 1: %v", len(matches), matches)
}
}
func TestCleanupAlbumArtCacheKeepsCurrentFile(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "old.jpg")
keepPath := filepath.Join(dir, "keep.jpg")
if err := os.WriteFile(oldPath, []byte(strings.Repeat("o", 80)), 0o644); err != nil {
t.Fatalf("WriteFile old: %v", err)
}
if err := os.WriteFile(keepPath, []byte(strings.Repeat("k", 80)), 0o644); err != nil {
t.Fatalf("WriteFile keep: %v", err)
}
oldTime := time.Now().Add(-time.Hour)
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
t.Fatalf("Chtimes old: %v", err)
}
cleanupAlbumArtCache(dir, 100, keepPath)
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Fatalf("old cache file still exists or stat failed: %v", err)
}
if _, err := os.Stat(keepPath); err != nil {
t.Fatalf("current cache file was removed: %v", err)
}
}
func TestTrackFromURL(t *testing.T) {
tests := []struct {
name string
+3 -3
View File
@@ -1628,7 +1628,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Synced Lyrics</div>
<p>Auto-scrolling lyrics for local &amp; Navidrome tracks. Press <kbd>y</kbd>.</p>
<p>Embedded local lyrics first, then LRCLIB/NetEase fallback. Auto-scrolling for timestamped lyrics.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
@@ -1638,7 +1638,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">MPRIS / Media Keys</div>
<p>Desktop integration. Hardware media keys and <code>playerctl</code>.</p>
<p>Desktop integration. Hardware media keys, <code>playerctl</code>, and embedded local cover art.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
@@ -1658,7 +1658,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Embedded Tag Reading</div>
<p>ID3v2, Vorbis comments, MP4 atoms — artist, album, genre, year.</p>
<p>ID3v2, Vorbis comments, MP4 atoms — artist, album, genre, year, lyrics, cover art.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
+10
View File
@@ -195,6 +195,16 @@ func fetchLyricsCmd(artist, title string) tea.Cmd {
}
}
func fetchTrackLyricsCmd(track playlist.Track, artist, title string) tea.Cmd {
return func() tea.Msg {
if lines := lyrics.ParseEmbedded(track.EmbeddedLyrics); len(lines) > 0 {
return lyricsLoadedMsg{lines: lines}
}
lines, err := lyrics.Fetch(artist, title)
return lyricsLoadedMsg{lines: lines, err: err}
}
}
func fetchNetSearchCmd(query string) tea.Cmd {
return func() tea.Msg {
tracks, err := resolve.Remote([]string{query})
+2 -1
View File
@@ -711,6 +711,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
case "y":
m.lyrics.visible = !m.lyrics.visible
if m.lyrics.visible && !m.lyrics.loading {
track, _ := m.currentPlaybackTrack()
artist, title := m.lyricsArtistTitle()
if artist != "" && title != "" {
q := artist + "\n" + title
@@ -719,7 +720,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
m.lyrics.loading = true
m.lyrics.lines = nil
m.lyrics.err = nil
return fetchLyricsCmd(artist, title)
return fetchTrackLyricsCmd(track, artist, title)
}
}
}
+1
View File
@@ -95,6 +95,7 @@ func (m *Model) notifyPlayback() {
Genre: track.Genre,
TrackNumber: track.TrackNumber,
URL: track.Path,
ArtURL: track.AlbumArtURL,
Duration: m.player.Duration(),
},
VolumeDB: m.player.Volume(),
+28 -20
View File
@@ -190,26 +190,7 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd {
m.status.Show("Loading feed...", statusTTLLong)
return resolveFeedTrackCmd(track.Path)
}
m.setPlaybackTrack(track)
m.reconnect.attempts = 0
m.reconnect.at = time.Time{}
m.streamTitle = ""
m.lyrics.lines = nil
m.lyrics.err = nil
m.lyrics.query = ""
m.lyrics.scroll = 0
m.seek.active = false
m.seek.timer = 0
m.seek.timerFor = 0
m.seek.grace = 0
m.seek.graceFor = 0
var fetchCmd tea.Cmd
if m.lyrics.visible && track.Artist != "" && track.Title != "" {
m.lyrics.loading = true
m.lyrics.query = track.Artist + "\n" + track.Title
fetchCmd = fetchLyricsCmd(track.Artist, track.Title)
}
track, fetchCmd := m.beginPlaybackTrack(track)
// Stream yt-dlp URLs (YouTube, SoundCloud, Bandcamp, etc.) via pipe chain.
if playlist.IsYTDL(track.Path) {
@@ -252,6 +233,33 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd {
return m.preloadNext()
}
// beginPlaybackTrack centralizes metadata refresh and model state reset for a
// new active track. It is used both by explicit playback and by gapless
// transitions, which advance audio without calling playTrack.
func (m *Model) beginPlaybackTrack(track playlist.Track) (playlist.Track, tea.Cmd) {
track = playlist.RefreshEmbeddedMetadata(track)
m.setPlaybackTrack(track)
m.reconnect.attempts = 0
m.reconnect.at = time.Time{}
m.streamTitle = ""
m.lyrics.lines = nil
m.lyrics.err = nil
m.lyrics.query = ""
m.lyrics.scroll = 0
m.seek.active = false
m.seek.timer = 0
m.seek.timerFor = 0
m.seek.grace = 0
m.seek.graceFor = 0
if m.lyrics.visible && track.Artist != "" && track.Title != "" {
m.lyrics.loading = true
m.lyrics.query = track.Artist + "\n" + track.Title
return track, fetchTrackLyricsCmd(track, track.Artist, track.Title)
}
m.lyrics.loading = false
return track, nil
}
// togglePlayPause starts playback if stopped, or toggles pause if playing.
// For live streams, unpausing reconnects to get current audio instead of
// playing stale data sitting in OS/decoder buffers from before the pause.
+50 -1
View File
@@ -12,6 +12,7 @@ import (
type playbackFakeEngine struct {
playing bool
gaplessAdvanced bool
playCalls []string
preloadCalls []string
clearPreloadCalls int
@@ -42,7 +43,13 @@ func (f *playbackFakeEngine) HasPreload() bool { return f
func (f *playbackFakeEngine) Seekable() bool { return false }
func (f *playbackFakeEngine) IsStreamSeek() bool { return false }
func (f *playbackFakeEngine) IsYTDLSeek() bool { return false }
func (f *playbackFakeEngine) GaplessAdvanced() bool { return false }
func (f *playbackFakeEngine) GaplessAdvanced() bool {
if !f.gaplessAdvanced {
return false
}
f.gaplessAdvanced = false
return true
}
func (f *playbackFakeEngine) Position() time.Duration { return 0 }
func (f *playbackFakeEngine) Duration() time.Duration { return 0 }
func (f *playbackFakeEngine) PositionAndDuration() (time.Duration, time.Duration) {
@@ -280,3 +287,45 @@ func TestPreloadAfterProviderPlaylistLoadUsesFirstNewTrack(t *testing.T) {
t.Fatalf("preloadCalls = %v, want first new track", player.preloadCalls)
}
}
func TestGaplessAdvanceRefreshesLyricsAndArtwork(t *testing.T) {
player := &playbackFakeEngine{playing: true, gaplessAdvanced: true}
p := playlist.New()
p.Replace([]playlist.Track{
{Title: "Old", Artist: "Artist", Path: "old.mp3", DurationSecs: 180, EmbeddedLyrics: "old lyric", AlbumArtURL: "file:///old.jpg"},
{Title: "New", Artist: "Artist", Path: "new.mp3", DurationSecs: 180, EmbeddedLyrics: "[00:01.00]new lyric", AlbumArtURL: "file:///new.jpg"},
})
p.SetIndex(0)
m := Model{
player: player,
playlist: p,
vis: ui.NewVisualizer(float64(player.SampleRate())),
lyrics: lyricsState{
visible: true,
query: "Artist\nOld",
},
}
m.setPlaybackTrack(p.Tracks()[0])
m.lyrics.lines = nil
next, cmd := m.Update(tickMsg(time.Now()))
m2 := next.(Model)
if cmd == nil {
t.Fatal("Update() command = nil, want lyric/preload/tick batch")
}
track, _ := m2.currentPlaybackTrack()
if track.Title != "New" {
t.Fatalf("current track = %q, want New", track.Title)
}
if track.AlbumArtURL != "file:///new.jpg" {
t.Fatalf("AlbumArtURL = %q, want new artwork", track.AlbumArtURL)
}
if m2.lyrics.query != "Artist\nNew" {
t.Fatalf("lyrics.query = %q, want new track query", m2.lyrics.query)
}
if !m2.lyrics.loading {
t.Fatal("lyrics.loading = false, want true for new track fetch")
}
}
+5 -1
View File
@@ -265,7 +265,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.plCursor = m.playlist.Index()
m.adjustScroll()
m.titleOff = 0
m.setPlaybackTrack(newTrack)
var gaplessLyricCmd tea.Cmd
newTrack, gaplessLyricCmd = m.beginPlaybackTrack(newTrack)
if gaplessLyricCmd != nil {
cmds = append(cmds, gaplessLyricCmd)
}
// The preload that just fired is consumed — clear the in-flight flag
// so the next track can be preloaded.
m.preloading = false