diff --git a/.github/workflows/sqlc-diff.yml b/.github/workflows/sqlc-diff.yml index 7d003ec..d5b3ce0 100644 --- a/.github/workflows/sqlc-diff.yml +++ b/.github/workflows/sqlc-diff.yml @@ -5,7 +5,7 @@ jobs: diff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: sqlc-dev/setup-sqlc@v3 with: sqlc-version: '1.27.0' diff --git a/db/queries/song.sql b/db/queries/song.sql index cb09a81..02db229 100644 --- a/db/queries/song.sql +++ b/db/queries/song.sql @@ -73,7 +73,7 @@ ORDER BY a.name, g.genre; -- name: GetSongHistory :many SELECT s.title FROM song_history sh -JOIN song s ON sh.song_id = s.id +JOIN song s ON sh.song_id = s.id ORDER BY created_at DESC LIMIT 5; diff --git a/go.mod b/go.mod index 174bb6a..409d5d2 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.23.1 require ( github.com/NimbleMarkets/ntcharts v0.1.2 - github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/bubbletea v1.1.0 github.com/charmbracelet/lipgloss v1.0.0 github.com/disintegration/imaging v1.6.2 github.com/go-playground/validator/v10 v10.22.1 @@ -27,9 +27,12 @@ require ( github.com/antchfx/xmlquery v1.4.2 // indirect github.com/antchfx/xpath v1.3.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.18.0 // indirect + github.com/charmbracelet/bubbles v0.20.0 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.4.2 // indirect + github.com/charmbracelet/x/term v0.2.0 // indirect github.com/containerd/console v1.0.4 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -47,7 +50,7 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect diff --git a/go.sum b/go.sum index e8f6556..b07f473 100644 --- a/go.sum +++ b/go.sum @@ -18,14 +18,23 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= +github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= +github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -34,6 +43,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -97,6 +108,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -189,6 +202,7 @@ golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index bfb001c..98cfb46 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -12,14 +12,14 @@ import ( tui "github.com/zeusWPI/scc/ui" "github.com/zeusWPI/scc/ui/screen" "github.com/zeusWPI/scc/ui/screen/cammie" + songScreen "github.com/zeusWPI/scc/ui/screen/song" "github.com/zeusWPI/scc/ui/view" "go.uber.org/zap" ) var screens = map[string]func(*db.DB) screen.Screen{ "cammie": cammie.New, - "song": screen.NewSong, - "test": screen.NewTest, + "song": songScreen.New, } // TUI starts the terminal user interface diff --git a/internal/pkg/db/sqlc/song.sql.go b/internal/pkg/db/sqlc/song.sql.go index e37f873..d9947c0 100644 --- a/internal/pkg/db/sqlc/song.sql.go +++ b/internal/pkg/db/sqlc/song.sql.go @@ -305,7 +305,7 @@ func (q *Queries) GetSongGenreByName(ctx context.Context, genre string) (SongGen const getSongHistory = `-- name: GetSongHistory :many SELECT s.title FROM song_history sh -JOIN song s ON sh.song_id = s.id +JOIN song s ON sh.song_id = s.id ORDER BY created_at DESC LIMIT 5 ` diff --git a/internal/pkg/lyrics/lrc.go b/internal/pkg/lyrics/lrc.go index f8f9452..a4ec841 100644 --- a/internal/pkg/lyrics/lrc.go +++ b/internal/pkg/lyrics/lrc.go @@ -9,6 +9,8 @@ import ( "github.com/zeusWPI/scc/internal/pkg/db/dto" ) +var re = regexp.MustCompile(`^\[(\d{2}):(\d{2})\.(\d{2})\]`) + // LRC represents synced lyrics type LRC struct { song dto.Song @@ -76,40 +78,40 @@ func (l *LRC) Upcoming(amount int) []Lyric { return lyrics } +// Progress shows the fraction of lyrics that have been used. +func (l *LRC) Progress() float64 { + return float64(l.i) / float64(len(l.lyrics)) +} + func parseLRC(text string, totalDuration time.Duration) []Lyric { lines := strings.Split(text, "\n") lyrics := make([]Lyric, 0, len(lines)+1) var previousTimestamp time.Duration - re, err := regexp.Compile(`^\[(\d{2}):(\d{2})\.(\d{2})\] (.+)$`) - if err != nil { - return lyrics - } - // Add first lyric (no text) lyrics = append(lyrics, Lyric{Text: ""}) previousTimestamp = time.Duration(0) for i, line := range lines { - match := re.FindStringSubmatch(line) - if match == nil { + parts := strings.SplitN(line, " ", 2) + if len(parts) != 2 { continue } - // Construct timestamp - minutes, _ := strconv.Atoi(match[1]) - seconds, _ := strconv.Atoi(match[2]) - hundredths, _ := strconv.Atoi(match[3]) + // Duration part + timeParts := re.FindStringSubmatch(parts[0]) + minutes, _ := strconv.Atoi(timeParts[1]) + seconds, _ := strconv.Atoi(timeParts[2]) + hundredths, _ := strconv.Atoi(timeParts[3]) timestamp := time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second + time.Duration(hundredths)*10*time.Millisecond - t := match[4] - - lyrics = append(lyrics, Lyric{Text: t}) + // Actual lyric + lyric := parts[1] - // Set duration of previous lyric + lyrics = append(lyrics, Lyric{Text: lyric}) lyrics[i].Duration = timestamp - previousTimestamp previousTimestamp = timestamp } diff --git a/internal/pkg/lyrics/lyrics.go b/internal/pkg/lyrics/lyrics.go index 80e964f..69a6750 100644 --- a/internal/pkg/lyrics/lyrics.go +++ b/internal/pkg/lyrics/lyrics.go @@ -14,6 +14,7 @@ type Lyrics interface { Current() (Lyric, bool) Next() (Lyric, bool) Upcoming(int) []Lyric + Progress() float64 } // Lyric represents a single lyric line. diff --git a/internal/pkg/lyrics/plain.go b/internal/pkg/lyrics/plain.go index 6ce3946..24cbd7f 100644 --- a/internal/pkg/lyrics/plain.go +++ b/internal/pkg/lyrics/plain.go @@ -57,3 +57,12 @@ func (p *Plain) Next() (Lyric, bool) { func (p *Plain) Upcoming(_ int) []Lyric { return []Lyric{} } + +// Progress shows the fraction of lyrics that have been used. +func (p *Plain) Progress() float64 { + if p.given { + return 1 + } + + return 0 +} diff --git a/ui/components/stopwatch/stopwatch.go b/ui/components/stopwatch/stopwatch.go new file mode 100644 index 0000000..a96a681 --- /dev/null +++ b/ui/components/stopwatch/stopwatch.go @@ -0,0 +1,128 @@ +// Package stopwatch provides a simple stopwatch component +package stopwatch + +import ( + "fmt" + "sync/atomic" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// Slightly adjusted version of https://github.com/charmbracelet/bubbles/blob/master/stopwatch/stopwatch.go + +var lastID int64 + +func nextID() int { + return int(atomic.AddInt64(&lastID, 1)) +} + +// TickMsg is a message that is sent on every stopwatch tick +type TickMsg struct { + id int +} + +// StartStopMsg is a message that controls if the stopwatch is running or not +type StartStopMsg struct { + running bool + startDuration time.Duration +} + +// ResetMsg is a message that resets the stopwatch +type ResetMsg struct { +} + +// Model for the stopwatch component +type Model struct { + id int + duration time.Duration + running bool +} + +// New created a new stopwatch with a given interval +func New() Model { + return Model{ + id: nextID(), + duration: 0, + running: false, + } +} + +// Init initiates the stopwatch component +func (m Model) Init() tea.Cmd { + return nil +} + +// Start starts the stopwatch +func (m Model) Start(startDuration time.Duration) tea.Cmd { + return func() tea.Msg { + return StartStopMsg{running: true, startDuration: startDuration} + } +} + +// Stop stops the stopwatch +func (m Model) Stop() tea.Cmd { + return func() tea.Msg { + return StartStopMsg{running: false} + } +} + +// Reset resets the stopwatch +func (m Model) Reset() tea.Cmd { + return func() tea.Msg { + return ResetMsg{} + } +} + +// Update handles the stopwatch tick +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case TickMsg: + if msg.id != m.id || !m.running { + return m, nil + } + + m.duration += time.Second + return m, tick(m.id) + + case ResetMsg: + m.duration = 0 + m.running = false + + case StartStopMsg: + if msg.running { + // Start + if m.running { + // Already running + return m, nil + } + + m.id = nextID() + m.duration = msg.startDuration + m.running = true + + return m, tick(m.id) + } + + // Stop + m.running = false + return m, nil + } + return m, nil +} + +// View of the stopwatch component +func (m Model) View() string { + duration := m.duration.Round(time.Second) + + min := int(duration / time.Minute) + sec := int((duration % time.Minute) / time.Second) + + return fmt.Sprintf("%02d:%02d", min, sec) +} + +func tick(id int) tea.Cmd { + return tea.Tick(time.Second, func(_ time.Time) tea.Msg { + return TickMsg{id: id} + }) +} diff --git a/ui/screen/cammie/cammie.go b/ui/screen/cammie/cammie.go index 03bcfed..22e78b7 100644 --- a/ui/screen/cammie/cammie.go +++ b/ui/screen/cammie/cammie.go @@ -63,7 +63,7 @@ func (c *Cammie) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { sTop = sTop.Width(c.width/2 - sTop.GetHorizontalFrameSize()).Height(c.height/2 - sTop.GetVerticalFrameSize()) sBottom = sBottom.Width(c.width/2 - sBottom.GetHorizontalFrameSize()).Height(c.height/2 - sBottom.GetVerticalFrameSize()) - return c, updateSize(*c) + return c, c.GetSizeMsg case msgIndex: c.indexTop = msg.indexBottom @@ -140,6 +140,27 @@ func (c *Cammie) GetUpdateViews() []view.UpdateData { return updates } +// GetSizeMsg returns a message for the views informing them about their width and height +func (c *Cammie) GetSizeMsg() tea.Msg { + sizes := make(map[string]view.Size) + + msgW := sMsg.GetWidth() + msgH := sMsg.GetHeight() + sizes[c.messages.Name()] = view.Size{Width: msgW, Height: msgH} + + bottomW := sBottom.GetWidth() + bottomH := sBottom.GetHeight() + sizes[c.bottom.Name()] = view.Size{Width: bottomW, Height: bottomH} + + for _, top := range c.top { + topW := sTop.GetWidth() + topH := sTop.GetHeight() + sizes[top.Name()] = view.Size{Width: topW, Height: topH} + } + + return view.MsgSize{Sizes: sizes} +} + func updateBottomIndex(cammie Cammie) tea.Cmd { timeout := time.Duration(config.GetDefaultInt("tui.screen.cammie_interval_change_s", 300) * int(time.Second)) return tea.Tick(timeout, func(_ time.Time) tea.Msg { @@ -148,27 +169,3 @@ func updateBottomIndex(cammie Cammie) tea.Cmd { return msgIndex{indexBottom: newIndex} }) } - -func updateSize(cammie Cammie) tea.Cmd { - return func() tea.Msg { - msg := view.MsgSize{ - Sizes: make(map[string]view.Size), - } - - msgW := sMsg.GetWidth() - msgH := sMsg.GetHeight() - msg.Sizes[cammie.messages.Name()] = view.Size{Width: msgW, Height: msgH} - - bottomW := sBottom.GetWidth() - bottomH := sBottom.GetHeight() - msg.Sizes[cammie.bottom.Name()] = view.Size{Width: bottomW, Height: bottomH} - - for _, top := range cammie.top { - topW := sTop.GetWidth() - topH := sTop.GetHeight() - msg.Sizes[top.Name()] = view.Size{Width: topW, Height: topH} - } - - return msg - } -} diff --git a/ui/screen/screen.go b/ui/screen/screen.go index 6b2a4d9..e4edd1c 100644 --- a/ui/screen/screen.go +++ b/ui/screen/screen.go @@ -12,4 +12,5 @@ type Screen interface { Update(tea.Msg) (Screen, tea.Cmd) View() string GetUpdateViews() []view.UpdateData + GetSizeMsg() tea.Msg } diff --git a/ui/screen/song.go b/ui/screen/song.go deleted file mode 100644 index 6788715..0000000 --- a/ui/screen/song.go +++ /dev/null @@ -1,50 +0,0 @@ -package screen - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/ui/view" - "github.com/zeusWPI/scc/ui/view/song" -) - -// Song represents the song screen -type Song struct { - db *db.DB - song view.View -} - -// NewSong creates a new song screen -func NewSong(db *db.DB) Screen { - return &Song{db: db, song: song.NewModel(db)} -} - -// Init initializes the song screen -func (s *Song) Init() tea.Cmd { - return s.song.Init() -} - -// Update updates the song screen -func (s *Song) Update(msg tea.Msg) (Screen, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - default: - song, cmd := s.song.Update(msg) - s.song = song - - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - return s, tea.Batch(cmds...) -} - -// View returns the song screen view -func (s *Song) View() string { - return s.song.View() -} - -// GetUpdateViews returns all the update functions for the song screen -func (s *Song) GetUpdateViews() []view.UpdateData { - return s.song.GetUpdateDatas() -} diff --git a/ui/screen/song/song.go b/ui/screen/song/song.go new file mode 100644 index 0000000..3532205 --- /dev/null +++ b/ui/screen/song/song.go @@ -0,0 +1,77 @@ +// Package song contains the screen displaying the song view +package song + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/ui/screen" + "github.com/zeusWPI/scc/ui/view" + "github.com/zeusWPI/scc/ui/view/song" +) + +// Song represents the song screen +type Song struct { + db *db.DB + song view.View + width int + height int +} + +// New creates a new song screen +func New(db *db.DB) screen.Screen { + return &Song{db: db, song: song.NewModel(db), width: 0, height: 0} +} + +// Init initializes the song screen +func (s *Song) Init() tea.Cmd { + return s.song.Init() +} + +// Update updates the song screen and all its views +func (s *Song) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.width = msg.Width + s.height = msg.Height + + sSong = sSong.Width(s.width - sSong.GetHorizontalFrameSize() - sSong.GetHorizontalPadding()).Height(s.height - sSong.GetVerticalFrameSize() - sSong.GetVerticalPadding()) + + return s, s.GetSizeMsg + } + + cmds := make([]tea.Cmd, 0) + var cmd tea.Cmd + + s.song, cmd = s.song.Update(msg) + cmds = append(cmds, cmd) + + return s, tea.Batch(cmds...) +} + +// View returns the song screen view +func (s *Song) View() string { + if s.width == 0 || s.height == 0 { + return "Initializing..." + } + + view := s.song.View() + view = sSong.Render(view) + + return view +} + +// GetUpdateViews returns all the update functions for the song screen +func (s *Song) GetUpdateViews() []view.UpdateData { + return s.song.GetUpdateDatas() +} + +// GetSizeMsg returns a message for the views informing them about their width and height +func (s *Song) GetSizeMsg() tea.Msg { + sizes := make(map[string]view.Size) + + songW := sSong.GetWidth() + songH := sSong.GetHeight() + sizes[s.song.Name()] = view.Size{Width: songW, Height: songH} + + return view.MsgSize{Sizes: sizes} +} diff --git a/ui/screen/song/style.go b/ui/screen/song/style.go new file mode 100644 index 0000000..e8962d6 --- /dev/null +++ b/ui/screen/song/style.go @@ -0,0 +1,10 @@ +package song + +import "github.com/charmbracelet/lipgloss" + +var base = lipgloss.NewStyle() + +// Style +var ( + sSong = base.AlignVertical(lipgloss.Center).AlignHorizontal(lipgloss.Center) +) diff --git a/ui/view/song/song.go b/ui/view/song/song.go index c77aad8..7b6d7fa 100644 --- a/ui/view/song/song.go +++ b/ui/view/song/song.go @@ -6,11 +6,13 @@ import ( "database/sql" "time" + "github.com/charmbracelet/bubbles/progress" tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/internal/pkg/db/dto" "github.com/zeusWPI/scc/internal/pkg/lyrics" "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/ui/components/stopwatch" "github.com/zeusWPI/scc/ui/view" ) @@ -20,11 +22,13 @@ var ( ) type playing struct { - song *dto.Song - lyrics lyrics.Lyrics - previous []string // Lyrics already sang - current string // Current lyric - upcoming []string // Lyrics that are coming up + song *dto.Song + progress progress.Model + stopwatch stopwatch.Model + lyrics lyrics.Lyrics + previous []string // Lyrics already sang + current string // Current lyric + upcoming []string // Lyrics that are coming up } // Model represents the view model for song @@ -35,6 +39,8 @@ type Model struct { topSongs []topStat topGenres []topStat topArtists []topStat + width int + height int } // Msg triggers a song data update @@ -72,17 +78,19 @@ func NewModel(db *db.DB) view.View { return &Model{ db: db, - current: playing{}, + current: playing{stopwatch: stopwatch.New(), progress: progress.New()}, history: history, topSongs: make([]topStat, 0, 5), topGenres: make([]topStat, 0, 5), topArtists: make([]topStat, 0, 5), + width: 0, + height: 0, } } // Init starts the song view func (m *Model) Init() tea.Cmd { - return nil + return m.current.stopwatch.Init() } // Name returns the name of the view @@ -93,6 +101,14 @@ func (m *Model) Name() string { // Update updates the song view func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { + case view.MsgSize: + entry, ok := msg.Sizes[m.Name()] + if ok { + m.width = entry.Width + m.height = entry.Height + } + + return m, nil case msgPlaying: m.history = append(m.history, msg.current.song.Title) if len(m.history) > 5 { @@ -101,24 +117,28 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { m.current = msg.current // New song, start the commands to update the lyrics - lyric, ok := m.current.lyrics.Current() + lyric, ok := m.current.lyrics.Next() if !ok { - // Song already done (shouldn't happen) + // Song already done m.current = playing{song: nil} - return m, nil + return m, m.current.stopwatch.Reset() } + + // Go through the lyrics until we get to the current one startTime := m.current.song.CreatedAt.Add(lyric.Duration) for startTime.Before(time.Now()) { lyric, ok := m.current.lyrics.Next() if !ok { // We're too late to display lyrics m.current = playing{song: nil} - return m, nil + return m, m.current.stopwatch.Reset() } startTime = startTime.Add(lyric.Duration) } + m.current.upcoming = lyricsToString(m.current.lyrics.Upcoming(upcomingAmount)) - return m, updateLyrics(m.current, startTime) + return m, tea.Batch(updateLyrics(m.current, startTime), m.current.stopwatch.Start(time.Since(m.current.song.CreatedAt))) + case msgTop: if msg.topSongs != nil { m.topSongs = msg.topSongs @@ -129,6 +149,9 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { if msg.topArtists != nil { m.topArtists = msg.topArtists } + + return m, nil + case msgLyrics: // Check if it's still relevant if msg.song.ID != m.current.song.ID { @@ -139,7 +162,7 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { if msg.done { // Song has finished. Reset variables m.current = playing{song: nil} - return m, nil + return m, m.current.stopwatch.Reset() } // Msg is relevant, update values @@ -149,9 +172,18 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // Start the cmd to update the lyrics return m, updateLyrics(m.current, msg.startNext) + + case progress.FrameMsg: + progressModel, cmd := m.current.progress.Update(msg) + m.current.progress = progressModel.(progress.Model) + + return m, cmd } - return m, nil + var cmd tea.Cmd + m.current.stopwatch, cmd = m.current.stopwatch.Update(msg) + + return m, cmd } // View draws the song view diff --git a/ui/view/song/style.go b/ui/view/song/style.go index d25ecef..783dbf0 100644 --- a/ui/view/song/style.go +++ b/ui/view/song/style.go @@ -9,7 +9,7 @@ var ( ) // Base style -var sBase = lipgloss.NewStyle() +var base = lipgloss.NewStyle() // Styles for the stats var ( @@ -18,17 +18,24 @@ var ( wStatAmount = 4 wStatBody = wStatTotal - wStatEnum - wStatAmount - sStat = sBase.Width(wStatTotal).MarginRight(3).MarginBottom(2) - sStatTitle = sBase.Foreground(cZeus).Width(wStatTotal).Align(lipgloss.Center). + sStat = base.Width(wStatTotal).MarginRight(3).MarginBottom(2) + sStatTitle = base.Foreground(cZeus).Width(wStatTotal).Align(lipgloss.Center). BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cSpotify) - sStatEnum = sBase.Foreground(cSpotify).Width(wStatEnum).Align(lipgloss.Left) - sStatBody = sBase.Width(wStatBody) - sStatAmount = sBase.Width(wStatAmount).Align(lipgloss.Right).Foreground(cZeus) + sStatEnum = base.Foreground(cSpotify).Width(wStatEnum).Align(lipgloss.Left) + sStatBody = base.Width(wStatBody) + sStatAmount = base.Width(wStatAmount).Align(lipgloss.Right).Foreground(cZeus) +) + +// Styles for the status +var ( + sStatusSong = base + sStatusStopwatch = base.Faint(true) + sStatusProgress = base ) // Styles for the lyrics var ( - sLyricBase = sBase.Width(50).Align(lipgloss.Center) + sLyricBase = base.Width(50).Align(lipgloss.Center) sLyricPrevious = sLyricBase.Foreground(cZeus).Faint(true) sLyricCurrent = sLyricBase.Foreground(cZeus) sLyricUpcoming = sLyricBase.Foreground(cSpotify) diff --git a/ui/view/song/view.go b/ui/view/song/view.go index 52ad541..8c1949c 100644 --- a/ui/view/song/view.go +++ b/ui/view/song/view.go @@ -2,12 +2,53 @@ package song import ( "fmt" + "math" "strings" "github.com/charmbracelet/lipgloss" ) func (m *Model) viewPlaying() string { + status := m.viewPlayingStatus() + lyrics := m.viewPlayingLyrics() + + view := lipgloss.JoinVertical(lipgloss.Left, status, lyrics) + + return view +} + +func (m *Model) viewPlayingStatus() string { + // Stopwatch + durationS := int(math.Round(float64(m.current.song.DurationMS) / 1000)) + stopwatch := fmt.Sprintf("\t%s / %02d:%02d", m.current.stopwatch.View(), durationS/60, durationS%60) + stopwatch = sStatusStopwatch.Render(stopwatch) + + // Song name + var artists strings.Builder + for _, artist := range m.current.song.Artists { + artists.WriteString(artist.Name + " & ") + } + artist := artists.String() + if len(artist) > 0 { + artist = artist[:len(artist)-3] + } + + song := sStatusSong.Width(m.width - lipgloss.Width(stopwatch)).Render(fmt.Sprintf("%s | %s", m.current.song.Title, artist)) + + // Progress bar + // zap.S().Info(m.current.lyrics.Progress()) + // progress := sStatusProgress.Width(m.width).Render(m.current.progress.ViewAs(m.current.lyrics.Progress())) + // zap.S().Info(progress) + + progress := sStatusProgress.Width(m.width).Render(strings.Repeat("▄", int(m.current.lyrics.Progress()*float64(m.width)))) + + view := lipgloss.JoinHorizontal(lipgloss.Top, song, stopwatch) + view = lipgloss.JoinVertical(lipgloss.Left, view, progress) + + return view +} + +func (m *Model) viewPlayingLyrics() string { var previousB strings.Builder for i, lyric := range m.current.previous { previousB.WriteString(lyric) @@ -26,7 +67,7 @@ func (m *Model) viewPlaying() string { } upcoming := sLyricUpcoming.Render(upcomingB.String()) - return sBase.MarginLeft(5).Render(lipgloss.JoinVertical(lipgloss.Left, previous, current, upcoming)) + return base.MarginLeft(5).Render(lipgloss.JoinVertical(lipgloss.Left, previous, current, upcoming)) } func (m *Model) viewNotPlaying() string { @@ -78,5 +119,7 @@ func (m *Model) viewNotPlaying() string { renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, row...)) } - return lipgloss.JoinVertical(lipgloss.Left, renderedRows...) + view := lipgloss.JoinVertical(lipgloss.Left, renderedRows...) + + return view }