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:
@@ -0,0 +1 @@
|
|||||||
|
I use AI, and if this file is not deleted, I haven't reviewed my own code.
|
||||||
@@ -156,6 +156,7 @@ func PlaylistShow(name string, jsonOutput bool) error {
|
|||||||
Year int `json:"year,omitempty"`
|
Year int `json:"year,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
DurationSecs int `json:"duration_secs,omitempty"`
|
DurationSecs int `json:"duration_secs,omitempty"`
|
||||||
|
AlbumArtURL string `json:"album_art_url,omitempty"`
|
||||||
Bookmark bool `json:"bookmark,omitempty"`
|
Bookmark bool `json:"bookmark,omitempty"`
|
||||||
}
|
}
|
||||||
out := make([]jsonTrack, len(tracks))
|
out := make([]jsonTrack, len(tracks))
|
||||||
@@ -169,6 +170,7 @@ func PlaylistShow(name string, jsonOutput bool) error {
|
|||||||
Year: t.Year,
|
Year: t.Year,
|
||||||
TrackNumber: t.TrackNumber,
|
TrackNumber: t.TrackNumber,
|
||||||
DurationSecs: t.DurationSecs,
|
DurationSecs: t.DurationSecs,
|
||||||
|
AlbumArtURL: t.AlbumArtURL,
|
||||||
Bookmark: t.Bookmark,
|
Bookmark: t.Bookmark,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -1,12 +1,14 @@
|
|||||||
# Lyrics
|
# 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
|
## Modes
|
||||||
|
|
||||||
- **Synced lyrics**: for local files and Navidrome tracks, lyrics auto scroll and highlight the active line in time with playback.
|
- **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.
|
- **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
|
## Streams
|
||||||
|
|
||||||
Lyrics auto update when the ICY metadata changes (e.g., internet radio station transitions).
|
Lyrics auto update when the ICY metadata changes (e.g., internet radio station transitions).
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ Track metadata is published under the standard MPRIS keys:
|
|||||||
| `xesam:artist` | Artist name (as a list with one entry) |
|
| `xesam:artist` | Artist name (as a list with one entry) |
|
||||||
| `xesam:album` | Album name, when available |
|
| `xesam:album` | Album name, when available |
|
||||||
| `xesam:url` | File path or stream URL |
|
| `xesam:url` | File path or stream URL |
|
||||||
|
| `mpris:artUrl` | Embedded album artwork from local files, when available |
|
||||||
| `mpris:length` | Duration in microseconds |
|
| `mpris:length` | Duration in microseconds |
|
||||||
|
|
||||||
Query metadata with:
|
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)
|
- Hardware media keys (play/pause, next, previous)
|
||||||
- Bluetooth headphone buttons
|
- 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.
|
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
|
## Architecture
|
||||||
|
|||||||
+8
-1
@@ -82,6 +82,14 @@ Each `[[track]]` section supports:
|
|||||||
| `path` | Yes | File path or HTTP URL |
|
| `path` | Yes | File path or HTTP URL |
|
||||||
| `title` | Yes | Display title |
|
| `title` | Yes | Display title |
|
||||||
| `artist` | No | Artist name |
|
| `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.
|
HTTP/HTTPS paths are automatically treated as streams.
|
||||||
|
|
||||||
@@ -191,4 +199,3 @@ title = "My Radio"
|
|||||||
| `a` | Add currently playing track |
|
| `a` | Add currently playing track |
|
||||||
| `d` | Delete playlist (confirms) / Remove track |
|
| `d` | Delete playlist (confirms) / Remove track |
|
||||||
| `←` / `Backspace` | Go back from tracks screen to list |
|
| `←` / `Backspace` | Go back from tracks screen to list |
|
||||||
|
|
||||||
|
|||||||
Vendored
+8
@@ -458,6 +458,12 @@ func writeTrack(w io.Writer, t playlist.Track) {
|
|||||||
if t.DurationSecs != 0 {
|
if t.DurationSecs != 0 {
|
||||||
fmt.Fprintf(w, "duration_secs = %d\n", t.DurationSecs)
|
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 {
|
if t.Bookmark {
|
||||||
fmt.Fprintln(w, "bookmark = true")
|
fmt.Fprintln(w, "bookmark = true")
|
||||||
}
|
}
|
||||||
@@ -481,6 +487,8 @@ func (p *Provider) loadTOML(path string) ([]playlist.Track, error) {
|
|||||||
Genre: f["genre"],
|
Genre: f["genre"],
|
||||||
Feed: f["feed"] == "true",
|
Feed: f["feed"] == "true",
|
||||||
}
|
}
|
||||||
|
t.EmbeddedLyrics = f["embedded_lyrics"]
|
||||||
|
t.AlbumArtURL = f["album_art_url"]
|
||||||
t.Stream = playlist.IsURL(t.Path)
|
t.Stream = playlist.IsURL(t.Path)
|
||||||
// "favorite" is the pre-rename alias for "bookmark"; prefer bookmark.
|
// "favorite" is the pre-rename alias for "bookmark"; prefer bookmark.
|
||||||
bookmark, ok := f["bookmark"]
|
bookmark, ok := f["bookmark"]
|
||||||
|
|||||||
Vendored
+18
-11
@@ -90,16 +90,18 @@ func TestWriteTrackMinimal(t *testing.T) {
|
|||||||
func TestWriteTrackAllFields(t *testing.T) {
|
func TestWriteTrackAllFields(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
writeTrack(&buf, playlist.Track{
|
writeTrack(&buf, playlist.Track{
|
||||||
Path: "/music/song.flac",
|
Path: "/music/song.flac",
|
||||||
Title: "Title",
|
Title: "Title",
|
||||||
Artist: "Artist",
|
Artist: "Artist",
|
||||||
Album: "Album",
|
Album: "Album",
|
||||||
Genre: "Rock",
|
Genre: "Rock",
|
||||||
Year: 2024,
|
Year: 2024,
|
||||||
TrackNumber: 3,
|
TrackNumber: 3,
|
||||||
DurationSecs: 240,
|
DurationSecs: 240,
|
||||||
Bookmark: true,
|
Bookmark: true,
|
||||||
Feed: true,
|
Feed: true,
|
||||||
|
EmbeddedLyrics: "[00:01.00]Line",
|
||||||
|
AlbumArtURL: "file:///tmp/cover.jpg",
|
||||||
})
|
})
|
||||||
got := buf.String()
|
got := buf.String()
|
||||||
|
|
||||||
@@ -112,6 +114,8 @@ func TestWriteTrackAllFields(t *testing.T) {
|
|||||||
"year = 2024",
|
"year = 2024",
|
||||||
"track_number = 3",
|
"track_number = 3",
|
||||||
"duration_secs = 240",
|
"duration_secs = 240",
|
||||||
|
`embedded_lyrics = "[00:01.00]Line"`,
|
||||||
|
`album_art_url = "file:///tmp/cover.jpg"`,
|
||||||
"bookmark = true",
|
"bookmark = true",
|
||||||
"feed = true",
|
"feed = true",
|
||||||
} {
|
} {
|
||||||
@@ -128,7 +132,7 @@ func TestLoadTOMLRoundTrip(t *testing.T) {
|
|||||||
os.MkdirAll(p.dir, 0o755)
|
os.MkdirAll(p.dir, 0o755)
|
||||||
|
|
||||||
tracks := []playlist.Track{
|
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},
|
{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 {
|
if loaded[0].Year != 2020 || loaded[0].TrackNumber != 1 || loaded[0].DurationSecs != 180 {
|
||||||
t.Fatalf("track 0 numeric fields mismatch: %+v", loaded[0])
|
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" {
|
if loaded[1].Path != "/b.flac" || loaded[1].Title != "B" || loaded[1].Genre != "Jazz" {
|
||||||
t.Fatalf("track 1 mismatch: %+v", loaded[1])
|
t.Fatalf("track 1 mismatch: %+v", loaded[1])
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Track struct {
|
|||||||
Genre string
|
Genre string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
URL string
|
URL string
|
||||||
|
ArtURL string
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func TestStateFields(t *testing.T) {
|
|||||||
Genre: "Rock",
|
Genre: "Rock",
|
||||||
TrackNumber: 3,
|
TrackNumber: 3,
|
||||||
URL: "file:///song.mp3",
|
URL: "file:///song.mp3",
|
||||||
|
ArtURL: "file:///cover.jpg",
|
||||||
Duration: 3 * time.Minute,
|
Duration: 3 * time.Minute,
|
||||||
},
|
},
|
||||||
VolumeDB: -3.0,
|
VolumeDB: -3.0,
|
||||||
|
|||||||
@@ -112,6 +112,24 @@ func Fetch(artist, title string) ([]Line, error) {
|
|||||||
return nil, ErrNotFound
|
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) {
|
func fetchLRCLIB(query string) ([]Line, error) {
|
||||||
searchURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query))
|
searchURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query))
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestCleanQuery(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input, want string
|
input, want string
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ func makeMetadata(t playback.Track, trackID dbus.ObjectPath) map[string]dbus.Var
|
|||||||
if t.URL != "" {
|
if t.URL != "" {
|
||||||
m["xesam:url"] = dbus.MakeVariant(t.URL)
|
m["xesam:url"] = dbus.MakeVariant(t.URL)
|
||||||
}
|
}
|
||||||
|
if t.ArtURL != "" {
|
||||||
|
m["mpris:artUrl"] = dbus.MakeVariant(t.ArtURL)
|
||||||
|
}
|
||||||
if t.Duration > 0 {
|
if t.Duration > 0 {
|
||||||
m["mpris:length"] = dbus.MakeVariant(t.Duration.Microseconds())
|
m["mpris:length"] = dbus.MakeVariant(t.Duration.Microseconds())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func TestMakeMetadataMapsPlaybackTrackToMPRISFields(t *testing.T) {
|
|||||||
Genre: "Ambient",
|
Genre: "Ambient",
|
||||||
TrackNumber: 7,
|
TrackNumber: 7,
|
||||||
URL: "file:///tmp/song.mp3",
|
URL: "file:///tmp/song.mp3",
|
||||||
|
ArtURL: "file:///tmp/cover.jpg",
|
||||||
Duration: 3*time.Minute + 15*time.Second,
|
Duration: 3*time.Minute + 15*time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ func TestMakeMetadataMapsPlaybackTrackToMPRISFields(t *testing.T) {
|
|||||||
"xesam:genre": dbus.MakeVariant([]string{"Ambient"}),
|
"xesam:genre": dbus.MakeVariant([]string{"Ambient"}),
|
||||||
"xesam:trackNumber": dbus.MakeVariant(7),
|
"xesam:trackNumber": dbus.MakeVariant(7),
|
||||||
"xesam:url": dbus.MakeVariant("file:///tmp/song.mp3"),
|
"xesam:url": dbus.MakeVariant("file:///tmp/song.mp3"),
|
||||||
|
"mpris:artUrl": dbus.MakeVariant("file:///tmp/cover.jpg"),
|
||||||
"mpris:length": dbus.MakeVariant(track.Duration.Microseconds()),
|
"mpris:length": dbus.MakeVariant(track.Duration.Microseconds()),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ func TestMakeMetadataOmitsEmptyOptionalFields(t *testing.T) {
|
|||||||
"xesam:genre",
|
"xesam:genre",
|
||||||
"xesam:trackNumber",
|
"xesam:trackNumber",
|
||||||
"xesam:url",
|
"xesam:url",
|
||||||
|
"mpris:artUrl",
|
||||||
"mpris:length",
|
"mpris:length",
|
||||||
} {
|
} {
|
||||||
if _, ok := got[key]; ok {
|
if _, ok := got[key]; ok {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ static void bridgeDestroy(MediaCtlBridgeRef ref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// playbackState: 0 = stopped, 1 = playing, 2 = paused
|
// 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) {
|
double durationSecs, double elapsedSecs, int playbackState, int canSeek) {
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
MPRemoteCommandCenter *cc = [MPRemoteCommandCenter sharedCommandCenter];
|
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 (title) info[MPMediaItemPropertyTitle] = @(title);
|
||||||
if (artist) info[MPMediaItemPropertyArtist] = @(artist);
|
if (artist) info[MPMediaItemPropertyArtist] = @(artist);
|
||||||
if (album) info[MPMediaItemPropertyAlbumTitle] = @(album);
|
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 (durationSecs > 0) info[MPMediaItemPropertyPlaybackDuration] = @(durationSecs);
|
||||||
if (elapsedSecs >= 0) info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(elapsedSecs);
|
if (elapsedSecs >= 0) info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(elapsedSecs);
|
||||||
info[MPNowPlayingInfoPropertyPlaybackRate] = @(playbackState == 1 ? 1.0 : 0.0);
|
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 {
|
type updateReq struct {
|
||||||
title, artist, album string
|
title, artist, album string
|
||||||
|
artURL string
|
||||||
durationSecs, elapsedSecs float64
|
durationSecs, elapsedSecs float64
|
||||||
status playback.Status
|
status playback.Status
|
||||||
canSeek bool
|
canSeek bool
|
||||||
@@ -486,7 +500,7 @@ func (s *Service) beginRelease(allowRunLoop bool) (cgo.Handle, C.MediaCtlBridgeR
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyUpdate(req updateReq) {
|
func applyUpdate(req updateReq) {
|
||||||
var cTitle, cArtist, cAlbum *C.char
|
var cTitle, cArtist, cAlbum, cArtURL *C.char
|
||||||
if req.title != "" {
|
if req.title != "" {
|
||||||
cTitle = C.CString(req.title)
|
cTitle = C.CString(req.title)
|
||||||
defer C.free(unsafe.Pointer(cTitle))
|
defer C.free(unsafe.Pointer(cTitle))
|
||||||
@@ -499,11 +513,15 @@ func applyUpdate(req updateReq) {
|
|||||||
cAlbum = C.CString(req.album)
|
cAlbum = C.CString(req.album)
|
||||||
defer C.free(unsafe.Pointer(cAlbum))
|
defer C.free(unsafe.Pointer(cAlbum))
|
||||||
}
|
}
|
||||||
|
if req.artURL != "" {
|
||||||
|
cArtURL = C.CString(req.artURL)
|
||||||
|
defer C.free(unsafe.Pointer(cArtURL))
|
||||||
|
}
|
||||||
canSeek := C.int(0)
|
canSeek := C.int(0)
|
||||||
if req.canSeek {
|
if req.canSeek {
|
||||||
canSeek = 1
|
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)
|
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,
|
title: state.Track.Title,
|
||||||
artist: state.Track.Artist,
|
artist: state.Track.Artist,
|
||||||
album: state.Track.Album,
|
album: state.Track.Album,
|
||||||
|
artURL: state.Track.ArtURL,
|
||||||
durationSecs: state.Track.Duration.Seconds(),
|
durationSecs: state.Track.Duration.Seconds(),
|
||||||
elapsedSecs: state.Position.Seconds(),
|
elapsedSecs: state.Position.Seconds(),
|
||||||
status: state.Status,
|
status: state.Status,
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func TestDarwinUpdateCoalescesPendingState(t *testing.T) {
|
|||||||
})
|
})
|
||||||
svc.Update(playback.State{
|
svc.Update(playback.State{
|
||||||
Status: playback.StatusPlaying,
|
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,
|
Position: 2250 * time.Millisecond,
|
||||||
Seekable: false,
|
Seekable: false,
|
||||||
})
|
})
|
||||||
@@ -100,6 +100,7 @@ func TestDarwinUpdateCoalescesPendingState(t *testing.T) {
|
|||||||
want := updateReq{
|
want := updateReq{
|
||||||
title: "second",
|
title: "second",
|
||||||
artist: "artist",
|
artist: "artist",
|
||||||
|
artURL: "file:///tmp/cover.jpg",
|
||||||
durationSecs: 0,
|
durationSecs: 0,
|
||||||
elapsedSecs: 2.25,
|
elapsedSecs: 2.25,
|
||||||
status: playback.StatusPlaying,
|
status: playback.StatusPlaying,
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ type Track struct {
|
|||||||
|
|
||||||
Unplayable bool // true when the track is known not playable in the current playback context
|
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.
|
// ProviderMeta holds provider-specific key-value pairs.
|
||||||
// Keys are namespaced by provider, e.g. "navidrome.id", "jellyfin.id".
|
// Keys are namespaced by provider, e.g. "navidrome.id", "jellyfin.id".
|
||||||
ProviderMeta map[string]string
|
ProviderMeta map[string]string
|
||||||
|
|||||||
+172
-7
@@ -1,17 +1,64 @@
|
|||||||
package playlist
|
package playlist
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/dhowden/tag"
|
"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
|
// readTags reads embedded metadata (ID3v2, Vorbis comments, MP4 atoms) from
|
||||||
// a local audio file and returns a Track. Falls back to filename parsing if
|
// a local audio file and returns a Track. Falls back to filename parsing if
|
||||||
// tag reading fails or the tags contain no title.
|
// tag reading fails or the tags contain no title.
|
||||||
func readTags(path string) Track {
|
func readTags(path string) Track {
|
||||||
|
return readTagsWithOptions(path, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTagsWithOptions(path string, cacheArt bool) Track {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TrackFromFilename(path)
|
return TrackFromFilename(path)
|
||||||
@@ -19,23 +66,141 @@ func readTags(path string) Track {
|
|||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
m, err := tag.ReadFrom(f)
|
m, err := tag.ReadFrom(f)
|
||||||
if err != nil || m == nil || strings.TrimSpace(m.Title()) == "" {
|
if err != nil || m == nil {
|
||||||
return TrackFromFilename(path)
|
return TrackFromFilename(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
t := Track{
|
t := Track{
|
||||||
Path: path,
|
Path: path,
|
||||||
Title: sanitizeTag(strings.TrimSpace(m.Title())),
|
EmbeddedLyrics: sanitizeTag(strings.TrimSpace(m.Lyrics())),
|
||||||
Artist: sanitizeTag(strings.TrimSpace(m.Artist())),
|
|
||||||
Album: sanitizeTag(strings.TrimSpace(m.Album())),
|
|
||||||
Genre: sanitizeTag(strings.TrimSpace(m.Genre())),
|
|
||||||
Year: m.Year(),
|
|
||||||
}
|
}
|
||||||
|
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()
|
trackNum, _ := m.Track()
|
||||||
t.TrackNumber = trackNum
|
t.TrackNumber = trackNum
|
||||||
return t
|
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
|
// TrackFromFilename creates a Track by parsing "Artist - Title" from the
|
||||||
// filename, or using the bare filename as the title.
|
// filename, or using the bare filename as the title.
|
||||||
func TrackFromFilename(path string) Track {
|
func TrackFromFilename(path string) Track {
|
||||||
|
|||||||
+63
-1
@@ -1,6 +1,15 @@
|
|||||||
package playlist
|
package playlist
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dhowden/tag"
|
||||||
|
)
|
||||||
|
|
||||||
func TestTrackMeta(t *testing.T) {
|
func TestTrackMeta(t *testing.T) {
|
||||||
t.Run("nil map returns empty", func(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) {
|
func TestTrackFromURL(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+3
-3
@@ -1628,7 +1628,7 @@ user_id = "your-account-user-id"</code></pre>
|
|||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature-icon">♪</div>
|
<div class="feature-icon">♪</div>
|
||||||
<div class="feature-name">Synced Lyrics</div>
|
<div class="feature-name">Synced Lyrics</div>
|
||||||
<p>Auto-scrolling lyrics for local & Navidrome tracks. Press <kbd>y</kbd>.</p>
|
<p>Embedded local lyrics first, then LRCLIB/NetEase fallback. Auto-scrolling for timestamped lyrics.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature-icon">⇄</div>
|
<div class="feature-icon">⇄</div>
|
||||||
@@ -1638,7 +1638,7 @@ user_id = "your-account-user-id"</code></pre>
|
|||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature-icon">♬</div>
|
<div class="feature-icon">♬</div>
|
||||||
<div class="feature-name">MPRIS / Media Keys</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>
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature-icon">▮</div>
|
<div class="feature-icon">▮</div>
|
||||||
@@ -1658,7 +1658,7 @@ user_id = "your-account-user-id"</code></pre>
|
|||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature-icon">✎</div>
|
<div class="feature-icon">✎</div>
|
||||||
<div class="feature-name">Embedded Tag Reading</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>
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature-icon">▶</div>
|
<div class="feature-icon">▶</div>
|
||||||
|
|||||||
@@ -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 {
|
func fetchNetSearchCmd(query string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
tracks, err := resolve.Remote([]string{query})
|
tracks, err := resolve.Remote([]string{query})
|
||||||
|
|||||||
+2
-1
@@ -711,6 +711,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
case "y":
|
case "y":
|
||||||
m.lyrics.visible = !m.lyrics.visible
|
m.lyrics.visible = !m.lyrics.visible
|
||||||
if m.lyrics.visible && !m.lyrics.loading {
|
if m.lyrics.visible && !m.lyrics.loading {
|
||||||
|
track, _ := m.currentPlaybackTrack()
|
||||||
artist, title := m.lyricsArtistTitle()
|
artist, title := m.lyricsArtistTitle()
|
||||||
if artist != "" && title != "" {
|
if artist != "" && title != "" {
|
||||||
q := artist + "\n" + title
|
q := artist + "\n" + title
|
||||||
@@ -719,7 +720,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
m.lyrics.loading = true
|
m.lyrics.loading = true
|
||||||
m.lyrics.lines = nil
|
m.lyrics.lines = nil
|
||||||
m.lyrics.err = nil
|
m.lyrics.err = nil
|
||||||
return fetchLyricsCmd(artist, title)
|
return fetchTrackLyricsCmd(track, artist, title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ func (m *Model) notifyPlayback() {
|
|||||||
Genre: track.Genre,
|
Genre: track.Genre,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: track.TrackNumber,
|
||||||
URL: track.Path,
|
URL: track.Path,
|
||||||
|
ArtURL: track.AlbumArtURL,
|
||||||
Duration: m.player.Duration(),
|
Duration: m.player.Duration(),
|
||||||
},
|
},
|
||||||
VolumeDB: m.player.Volume(),
|
VolumeDB: m.player.Volume(),
|
||||||
|
|||||||
+28
-20
@@ -190,26 +190,7 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd {
|
|||||||
m.status.Show("Loading feed...", statusTTLLong)
|
m.status.Show("Loading feed...", statusTTLLong)
|
||||||
return resolveFeedTrackCmd(track.Path)
|
return resolveFeedTrackCmd(track.Path)
|
||||||
}
|
}
|
||||||
|
track, fetchCmd := m.beginPlaybackTrack(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
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream yt-dlp URLs (YouTube, SoundCloud, Bandcamp, etc.) via pipe chain.
|
// Stream yt-dlp URLs (YouTube, SoundCloud, Bandcamp, etc.) via pipe chain.
|
||||||
if playlist.IsYTDL(track.Path) {
|
if playlist.IsYTDL(track.Path) {
|
||||||
@@ -252,6 +233,33 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd {
|
|||||||
return m.preloadNext()
|
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.
|
// togglePlayPause starts playback if stopped, or toggles pause if playing.
|
||||||
// For live streams, unpausing reconnects to get current audio instead of
|
// For live streams, unpausing reconnects to get current audio instead of
|
||||||
// playing stale data sitting in OS/decoder buffers from before the pause.
|
// playing stale data sitting in OS/decoder buffers from before the pause.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
type playbackFakeEngine struct {
|
type playbackFakeEngine struct {
|
||||||
playing bool
|
playing bool
|
||||||
|
gaplessAdvanced bool
|
||||||
playCalls []string
|
playCalls []string
|
||||||
preloadCalls []string
|
preloadCalls []string
|
||||||
clearPreloadCalls int
|
clearPreloadCalls int
|
||||||
@@ -42,9 +43,15 @@ func (f *playbackFakeEngine) HasPreload() bool { return f
|
|||||||
func (f *playbackFakeEngine) Seekable() bool { return false }
|
func (f *playbackFakeEngine) Seekable() bool { return false }
|
||||||
func (f *playbackFakeEngine) IsStreamSeek() bool { return false }
|
func (f *playbackFakeEngine) IsStreamSeek() bool { return false }
|
||||||
func (f *playbackFakeEngine) IsYTDLSeek() bool { return false }
|
func (f *playbackFakeEngine) IsYTDLSeek() bool { return false }
|
||||||
func (f *playbackFakeEngine) GaplessAdvanced() bool { return false }
|
func (f *playbackFakeEngine) GaplessAdvanced() bool {
|
||||||
func (f *playbackFakeEngine) Position() time.Duration { return 0 }
|
if !f.gaplessAdvanced {
|
||||||
func (f *playbackFakeEngine) Duration() time.Duration { return 0 }
|
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) {
|
func (f *playbackFakeEngine) PositionAndDuration() (time.Duration, time.Duration) {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
@@ -280,3 +287,45 @@ func TestPreloadAfterProviderPlaylistLoadUsesFirstNewTrack(t *testing.T) {
|
|||||||
t.Fatalf("preloadCalls = %v, want first new track", player.preloadCalls)
|
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
@@ -265,7 +265,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.plCursor = m.playlist.Index()
|
m.plCursor = m.playlist.Index()
|
||||||
m.adjustScroll()
|
m.adjustScroll()
|
||||||
m.titleOff = 0
|
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
|
// The preload that just fired is consumed — clear the in-flight flag
|
||||||
// so the next track can be preloaded.
|
// so the next track can be preloaded.
|
||||||
m.preloading = false
|
m.preloading = false
|
||||||
|
|||||||
Reference in New Issue
Block a user