Skip to content

Commit 4c61565

Browse files
author
pykeguo
committed
feat: add cover art display mode (lyrics cover)
1 parent bcc769a commit 4c61565

9 files changed

Lines changed: 315 additions & 40 deletions

File tree

cmd/lyrics/main.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
tea "github.com/charmbracelet/bubbletea"
99

10+
"github.com/index-null/cmus-lyric/internal/cover"
1011
"github.com/index-null/cmus-lyric/internal/player"
1112
)
1213

@@ -19,9 +20,19 @@ var (
1920
func main() {
2021
log.SetFlags(0)
2122

22-
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
23-
fmt.Printf("lyrics %s (%s) built at %s\n", version, commit, date)
24-
return
23+
if len(os.Args) > 1 {
24+
switch os.Args[1] {
25+
case "--version", "-v":
26+
fmt.Printf("lyrics %s (%s) built at %s\n", version, commit, date)
27+
return
28+
case "cover":
29+
p := tea.NewProgram(cover.NewModel(), tea.WithAltScreen())
30+
if _, err := p.Run(); err != nil {
31+
log.Print(err)
32+
os.Exit(1)
33+
}
34+
return
35+
}
2536
}
2637

2738
p := tea.NewProgram(player.NewModel(), tea.WithAltScreen())

go.mod

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,30 @@ require (
66
github.com/charmbracelet/bubbles v1.0.0
77
github.com/charmbracelet/bubbletea v1.3.10
88
github.com/charmbracelet/lipgloss v1.1.0
9+
github.com/charmbracelet/x/mosaic v0.0.0-20260422141420-a6cbdff8a7e2
910
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
10-
golang.org/x/text v0.35.0
11+
github.com/lucasb-eyer/go-colorful v1.4.0
12+
golang.org/x/text v0.36.0
1113
)
1214

1315
require (
1416
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
1517
github.com/charmbracelet/colorprofile v0.4.1 // indirect
1618
github.com/charmbracelet/harmonica v0.2.0 // indirect
17-
github.com/charmbracelet/x/ansi v0.11.6 // indirect
19+
github.com/charmbracelet/x/ansi v0.11.7 // indirect
1820
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
1921
github.com/charmbracelet/x/term v0.2.2 // indirect
20-
github.com/clipperhouse/displaywidth v0.9.0 // indirect
21-
github.com/clipperhouse/stringish v0.1.1 // indirect
22-
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
22+
github.com/clipperhouse/displaywidth v0.11.0 // indirect
23+
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
2324
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
24-
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
2525
github.com/mattn/go-isatty v0.0.20 // indirect
2626
github.com/mattn/go-localereader v0.0.1 // indirect
27-
github.com/mattn/go-runewidth v0.0.19 // indirect
27+
github.com/mattn/go-runewidth v0.0.23 // indirect
2828
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
2929
github.com/muesli/cancelreader v0.2.2 // indirect
3030
github.com/muesli/termenv v0.16.0 // indirect
3131
github.com/rivo/uniseg v0.4.7 // indirect
3232
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
33-
golang.org/x/sys v0.38.0 // indirect
33+
golang.org/x/image v0.39.0 // indirect
34+
golang.org/x/sys v0.41.0 // indirect
3435
)

go.sum

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,30 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG
1010
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
1111
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
1212
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
13-
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
14-
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
13+
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
14+
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
1515
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
1616
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
17+
github.com/charmbracelet/x/mosaic v0.0.0-20260422141420-a6cbdff8a7e2 h1:47PX0cQcpiIFv+1ECOSOPapsv1vbKXc5gldKLup/EJQ=
18+
github.com/charmbracelet/x/mosaic v0.0.0-20260422141420-a6cbdff8a7e2/go.mod h1:vbFTGyR3dHkpQOb/Xxh2VbpTeJKYtjEt/TPiq8vPyoM=
1719
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
1820
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
19-
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
20-
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
21-
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
22-
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
23-
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
24-
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
21+
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
22+
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
23+
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
24+
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
2525
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
2626
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
2727
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
2828
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
29-
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
30-
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
29+
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
30+
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
3131
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
3232
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
3333
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
3434
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
35-
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
36-
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
35+
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
36+
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
3737
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
3838
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
3939
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -46,9 +46,11 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
4646
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
4747
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
4848
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
49+
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
50+
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
4951
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5052
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
51-
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
52-
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
53-
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
54-
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
53+
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
54+
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
55+
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
56+
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=

internal/cover/model.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package cover
2+
3+
import (
4+
"bytes"
5+
"image"
6+
"time"
7+
8+
// Register image decoders for cover art.
9+
_ "image/gif"
10+
_ "image/jpeg"
11+
_ "image/png"
12+
13+
tea "github.com/charmbracelet/bubbletea"
14+
"github.com/charmbracelet/lipgloss"
15+
"github.com/charmbracelet/x/mosaic"
16+
17+
"github.com/index-null/cmus-lyric/internal/cmus"
18+
"github.com/index-null/cmus-lyric/internal/lyric"
19+
)
20+
21+
type Model struct {
22+
track cmus.Track
23+
curFile string
24+
coverImg image.Image
25+
renderedImg string
26+
renderW, renderH int
27+
width, height int
28+
errMsg string
29+
fetching bool
30+
}
31+
32+
type tickMsg struct{}
33+
type coverDoneMsg struct {
34+
file string
35+
img image.Image
36+
err error
37+
}
38+
39+
func NewModel() Model { return Model{} }
40+
41+
func (m Model) Init() tea.Cmd { return tick() }
42+
43+
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
44+
switch msg := msg.(type) {
45+
case tea.KeyMsg:
46+
if msg.String() == "q" || msg.String() == "ctrl+c" {
47+
return m, tea.Quit
48+
}
49+
case tea.WindowSizeMsg:
50+
m.width, m.height = msg.Width, msg.Height
51+
m.rerender()
52+
return m, nil
53+
case tickMsg:
54+
var cmd tea.Cmd
55+
m, cmd = m.poll()
56+
return m, tea.Batch(cmd, tick())
57+
case coverDoneMsg:
58+
m.fetching = false
59+
if msg.file != m.curFile {
60+
return m, nil
61+
}
62+
if msg.err != nil {
63+
m.errMsg = msg.err.Error()
64+
m.coverImg = nil
65+
m.renderedImg = ""
66+
return m, nil
67+
}
68+
m.errMsg = ""
69+
m.coverImg = msg.img
70+
m.rerender()
71+
return m, nil
72+
}
73+
return m, nil
74+
}
75+
76+
func (m Model) View() string {
77+
if m.width == 0 || m.height == 0 {
78+
return ""
79+
}
80+
if m.renderedImg != "" {
81+
return m.renderedImg
82+
}
83+
msg := "waiting for cmus..."
84+
if m.track.File != "" && m.fetching {
85+
msg = "loading cover..."
86+
} else if m.track.File != "" {
87+
msg = "no cover"
88+
}
89+
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
90+
lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")).Render(msg))
91+
}
92+
93+
func (m *Model) rerender() {
94+
if m.coverImg == nil || m.width < 2 || m.height < 2 {
95+
m.renderedImg = ""
96+
return
97+
}
98+
w, h := m.width, m.height
99+
if w > h*2 {
100+
w = h * 2
101+
}
102+
if h > w {
103+
h = w
104+
}
105+
if m.renderedImg != "" && w == m.renderW && h == m.renderH {
106+
return
107+
}
108+
mos := mosaic.New().Width(w).Height(h)
109+
rendered := mos.Render(m.coverImg)
110+
m.renderedImg = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, rendered)
111+
m.renderW, m.renderH = w, h
112+
}
113+
114+
func (m Model) poll() (Model, tea.Cmd) {
115+
track := cmus.Remote()
116+
m.track = track
117+
if track.File == m.curFile {
118+
return m, nil
119+
}
120+
m.curFile = track.File
121+
m.coverImg = nil
122+
m.renderedImg = ""
123+
m.errMsg = ""
124+
if track.File == "" {
125+
return m, nil
126+
}
127+
m.fetching = true
128+
file, artist, title, dur := track.File, track.Artist, track.Title, track.Duration
129+
return m, func() tea.Msg {
130+
img, err := loadCover(file, artist, title, dur)
131+
return coverDoneMsg{file: file, img: img, err: err}
132+
}
133+
}
134+
135+
func loadCover(file, artist, title string, duration int) (image.Image, error) {
136+
if data := lyric.LoadEmbeddedCover(file); data != nil {
137+
if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
138+
return img, nil
139+
}
140+
}
141+
if data := lyric.LoadCoverFromCache(artist, title); data != nil {
142+
if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
143+
return img, nil
144+
}
145+
}
146+
coverURL, err := lyric.FetchCoverURL(title, artist, duration)
147+
if err != nil {
148+
return nil, err
149+
}
150+
data, err := lyric.FetchCoverData(coverURL)
151+
if err != nil {
152+
return nil, err
153+
}
154+
_ = lyric.SaveCoverToCache(artist, title, data)
155+
img, _, err := image.Decode(bytes.NewReader(data))
156+
return img, err
157+
}
158+
159+
func tick() tea.Cmd {
160+
return tea.Tick(500*time.Millisecond, func(_ time.Time) tea.Msg { return tickMsg{} })
161+
}

internal/lyric/cache.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,26 @@ func SaveToCache(artist, title, lrc, tlyric string) error {
7171
return nil
7272
}
7373

74+
func CacheCoverPath(artist, title string) string {
75+
return filepath.Join(CacheDir(), CacheKey(artist, title)+".cover")
76+
}
77+
78+
func LoadCoverFromCache(artist, title string) []byte {
79+
data, err := os.ReadFile(CacheCoverPath(artist, title))
80+
if err != nil {
81+
return nil
82+
}
83+
return data
84+
}
85+
86+
func SaveCoverToCache(artist, title string, data []byte) error {
87+
dir := CacheDir()
88+
if err := os.MkdirAll(dir, 0755); err != nil {
89+
return err
90+
}
91+
return os.WriteFile(CacheCoverPath(artist, title), data, 0644)
92+
}
93+
7494
func splitLines(data []byte) []string {
7595
return splitLinesStr(string(data))
7696
}

0 commit comments

Comments
 (0)