diff --git a/config/development.yaml b/config/development.yaml index 5cb3c2e..50ddcb3 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -102,7 +102,8 @@ tui: song: interval_current_s: 5 interval_history_s: 5 - interval_top_s: 300 + interval_monthly_stats_s: 300 + interval_stats_s: 3600 tap: interval_s: 60 diff --git a/config/production.yaml b/config/production.yaml index 72fe481..e69de29 100644 --- a/config/production.yaml +++ b/config/production.yaml @@ -1,86 +0,0 @@ -# [server] -# host = "localhost" -# port = 3000 - -# [db] -# host = "localhost" -# port = 5432 -# database = "scc" -# user = "postgres" -# password = "postgres" - -# [song] -# spotify_api = "https://api.spotify.com/v1" -# spotify_account = "https://accounts.spotify.com/api/token" -# lrclib_api = "https://lrclib.net/api" - -# [tap] -# api = "https://tap.zeus.gent" -# interval_s = 60 -# beers = [ -# "Schelfaut", -# "Duvel", -# "Fourchette", -# "Jupiler", -# "Karmeliet", -# "Kriek", -# "Chouffe", -# "Maes", -# "Somersby", -# "Sportzot", -# "Stella", -# ] - -# [zess] -# api = "http://localhost:4000/api" -# interval_season_s = 300 -# interval_scan_s = 60 - -# [gamification] -# api = "https://gamification.zeus.gent" -# interval_s = 3600 - -# [event] -# api = "https://zeus.gent/events" -# api_poster = "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master" -# interval_s = 86400 - -# [buzzer] -# song = [ -# "-n", "-f880", "-l100", "-d0", -# "-n", "-f988", "-l100", "-d0", -# "-n", "-f588", "-l100", "-d0", -# "-n", "-f989", "-l100", "-d0", -# "-n", "-f660", "-l200", "-d0", -# "-n", "-f660", "-l200", "-d0", -# "-n", "-f588", "-l100", "-d0", -# "-n", "-f555", "-l100", "-d0", -# "-n", "-f495", "-l100", "-d0", -# ] - -# [tui] - -# [tui.screen] -# cammie_interval_change_s = 300 - -# [tui.zess] -# weeks = 10 -# interval_scan_s = 60 -# interval_season_s = 3600 - -# [tui.message] -# interval_s = 1 - -# [tui.tap] -# interval_s = 60 - -# [tui.gamification] -# interval_s = 3600 - -# [tui.song] -# interval_current_s = 5 -# interval_history_s = 5 -# interval_top_s = 3600 - -# [tui.event] -# interval_s = 3600 diff --git a/db/queries/song.sql b/db/queries/song.sql index c81f777..9ea92a6 100644 --- a/db/queries/song.sql +++ b/db/queries/song.sql @@ -71,11 +71,15 @@ WHERE sh.created_at = (SELECT MAX(created_at) FROM song_history) 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 -ORDER BY created_at DESC -LIMIT 10; +SELECT s.title, play_count, aggregated.created_at +FROM ( + SELECT sh.song_id, MAX(sh.created_at) AS created_at, COUNT(sh.song_id) AS play_count + FROM song_history sh + GROUP BY sh.song_id +) aggregated +JOIN song s ON aggregated.song_id = s.id +ORDER BY aggregated.created_at DESC +LIMIT 20; -- name: GetTopSongs :many SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count @@ -106,3 +110,36 @@ JOIN song_genre g ON sag.genre_id = g.id GROUP BY g.genre ORDER BY total_plays DESC LIMIT 10; + +-- name: GetTopMonthlySongs :many +SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY s.id, s.title +ORDER BY play_count DESC +LIMIT 10; + +-- name: GetTopMonthlyArtists :many +SELECT sa.id AS artist_id, sa.name AS artist_name, COUNT(sh.id) AS total_plays +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist sa ON sas.artist_id = sa.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY sa.id, sa.name +ORDER BY total_plays DESC +LIMIT 10; + +-- name: GetTopMonthlyGenres :many +SELECT g.genre AS genre_name, COUNT(sh.id) AS total_plays +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist sa ON sas.artist_id = sa.id +JOIN song_artist_genre sag ON sa.id = sag.artist_id +JOIN song_genre g ON sag.genre_id = g.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY g.genre +ORDER BY total_plays DESC +LIMIT 10; diff --git a/internal/pkg/db/sqlc/song.sql.go b/internal/pkg/db/sqlc/song.sql.go index 91d0493..a237b71 100644 --- a/internal/pkg/db/sqlc/song.sql.go +++ b/internal/pkg/db/sqlc/song.sql.go @@ -300,26 +300,36 @@ 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 -ORDER BY created_at DESC +SELECT s.title, play_count, aggregated.created_at +FROM ( + SELECT sh.song_id, MAX(sh.created_at) AS created_at, COUNT(sh.song_id) AS play_count + FROM song_history sh + GROUP BY sh.song_id +) aggregated +JOIN song s ON aggregated.song_id = s.id +ORDER BY aggregated.created_at DESC LIMIT 10 ` -func (q *Queries) GetSongHistory(ctx context.Context) ([]string, error) { +type GetSongHistoryRow struct { + Title string + PlayCount int64 + CreatedAt interface{} +} + +func (q *Queries) GetSongHistory(ctx context.Context) ([]GetSongHistoryRow, error) { rows, err := q.db.Query(ctx, getSongHistory) if err != nil { return nil, err } defer rows.Close() - var items []string + var items []GetSongHistoryRow for rows.Next() { - var title string - if err := rows.Scan(&title); err != nil { + var i GetSongHistoryRow + if err := rows.Scan(&i.Title, &i.PlayCount, &i.CreatedAt); err != nil { return nil, err } - items = append(items, title) + items = append(items, i) } if err := rows.Err(); err != nil { return nil, err @@ -402,6 +412,119 @@ func (q *Queries) GetTopGenres(ctx context.Context) ([]GetTopGenresRow, error) { return items, nil } +const getTopMonthlyArtists = `-- name: GetTopMonthlyArtists :many +SELECT sa.id AS artist_id, sa.name AS artist_name, COUNT(sh.id) AS total_plays +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist sa ON sas.artist_id = sa.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY sa.id, sa.name +ORDER BY total_plays DESC +LIMIT 10 +` + +type GetTopMonthlyArtistsRow struct { + ArtistID int32 + ArtistName string + TotalPlays int64 +} + +func (q *Queries) GetTopMonthlyArtists(ctx context.Context) ([]GetTopMonthlyArtistsRow, error) { + rows, err := q.db.Query(ctx, getTopMonthlyArtists) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTopMonthlyArtistsRow + for rows.Next() { + var i GetTopMonthlyArtistsRow + if err := rows.Scan(&i.ArtistID, &i.ArtistName, &i.TotalPlays); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getTopMonthlyGenres = `-- name: GetTopMonthlyGenres :many +SELECT g.genre AS genre_name, COUNT(sh.id) AS total_plays +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist sa ON sas.artist_id = sa.id +JOIN song_artist_genre sag ON sa.id = sag.artist_id +JOIN song_genre g ON sag.genre_id = g.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY g.genre +ORDER BY total_plays DESC +LIMIT 10 +` + +type GetTopMonthlyGenresRow struct { + GenreName string + TotalPlays int64 +} + +func (q *Queries) GetTopMonthlyGenres(ctx context.Context) ([]GetTopMonthlyGenresRow, error) { + rows, err := q.db.Query(ctx, getTopMonthlyGenres) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTopMonthlyGenresRow + for rows.Next() { + var i GetTopMonthlyGenresRow + if err := rows.Scan(&i.GenreName, &i.TotalPlays); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getTopMonthlySongs = `-- name: GetTopMonthlySongs :many +SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY s.id, s.title +ORDER BY play_count DESC +LIMIT 10 +` + +type GetTopMonthlySongsRow struct { + SongID int32 + Title string + PlayCount int64 +} + +func (q *Queries) GetTopMonthlySongs(ctx context.Context) ([]GetTopMonthlySongsRow, error) { + rows, err := q.db.Query(ctx, getTopMonthlySongs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTopMonthlySongsRow + for rows.Next() { + var i GetTopMonthlySongsRow + if err := rows.Scan(&i.SongID, &i.Title, &i.PlayCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTopSongs = `-- name: GetTopSongs :many SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count FROM song_history sh diff --git a/internal/pkg/lyrics/lrc.go b/internal/pkg/lyrics/lrc.go index f38b9ad..db72c5c 100644 --- a/internal/pkg/lyrics/lrc.go +++ b/internal/pkg/lyrics/lrc.go @@ -18,8 +18,8 @@ type LRC struct { i int } -func newLRC(song *dto.Song) Lyrics { - return &LRC{song: *song, lyrics: parseLRC(song.Lyrics, time.Duration(song.DurationMS)), i: 0} +func newLRC(song dto.Song) Lyrics { + return &LRC{song: song, lyrics: parseLRC(song.Lyrics, time.Duration(song.DurationMS)), i: 0} } // GetSong returns the song associated to the lyrics diff --git a/internal/pkg/lyrics/lyrics.go b/internal/pkg/lyrics/lyrics.go index 69a6750..89fb898 100644 --- a/internal/pkg/lyrics/lyrics.go +++ b/internal/pkg/lyrics/lyrics.go @@ -24,10 +24,17 @@ type Lyric struct { } // New returns a new object that implements the Lyrics interface -func New(song *dto.Song) Lyrics { +func New(song dto.Song) Lyrics { + // No lyrics + if song.LyricsType == "" { + return newMissing(song) + } + + // Basic sync if song.LyricsType == "synced" { return newLRC(song) } + // Lyrics but no syncing return newPlain(song) } diff --git a/internal/pkg/lyrics/missing.go b/internal/pkg/lyrics/missing.go new file mode 100644 index 0000000..e52bb6e --- /dev/null +++ b/internal/pkg/lyrics/missing.go @@ -0,0 +1,71 @@ +package lyrics + +import ( + "time" + + "github.com/zeusWPI/scc/internal/pkg/db/dto" +) + +// Missing represents lyrics that are absent +type Missing struct { + song dto.Song + lyrics Lyric + given bool +} + +func newMissing(song dto.Song) Lyrics { + lyric := Lyric{ + Text: "Missing lyrics\n\nHelp the open source community by adding them to\nhttps://lrclib.net/", + Duration: time.Duration(song.DurationMS) * time.Millisecond, + } + + return &Missing{song: song, lyrics: lyric, given: false} +} + +// GetSong returns the song associated to the lyrics +func (m *Missing) GetSong() dto.Song { + return m.song +} + +// Previous provides the previous `amount` of lyrics without affecting the current lyric +// In this case it's alway nothing +func (m *Missing) Previous(_ int) []Lyric { + return []Lyric{} +} + +// Current provides the current lyric if any. +// If the song is finished the boolean is set to false +func (m *Missing) Current() (Lyric, bool) { + if m.given { + return Lyric{}, false + } + + return m.lyrics, true +} + +// Next provides the next lyric. +// If the lyrics are finished the boolean is set to false +func (m *Missing) Next() (Lyric, bool) { + if m.given { + return Lyric{}, false + } + + m.given = true + + return m.lyrics, true +} + +// Upcoming provides the next `amount` lyrics without affecting the current lyric +// In this case it's always empty +func (m *Missing) Upcoming(_ int) []Lyric { + return []Lyric{} +} + +// Progress shows the fraction of lyrics that have been used. +func (m *Missing) Progress() float64 { + if m.given { + return 1 + } + + return 0 +} diff --git a/internal/pkg/lyrics/plain.go b/internal/pkg/lyrics/plain.go index 24cbd7f..a1a4609 100644 --- a/internal/pkg/lyrics/plain.go +++ b/internal/pkg/lyrics/plain.go @@ -13,12 +13,12 @@ type Plain struct { given bool } -func newPlain(song *dto.Song) Lyrics { +func newPlain(song dto.Song) Lyrics { lyric := Lyric{ Text: song.Lyrics, - Duration: time.Duration(song.DurationMS), + Duration: time.Duration(song.DurationMS) * time.Millisecond, } - return &Plain{song: *song, lyrics: lyric, given: false} + return &Plain{song: song, lyrics: lyric, given: false} } // GetSong returns the song associated to the lyrics @@ -27,6 +27,7 @@ func (p *Plain) GetSong() dto.Song { } // Previous provides the previous `amount` of lyrics without affecting the current lyric +// In this case it's always nothing func (p *Plain) Previous(_ int) []Lyric { return []Lyric{} } @@ -54,6 +55,7 @@ func (p *Plain) Next() (Lyric, bool) { } // Upcoming provides the next `amount` lyrics without affecting the current lyric +// In this case it's always empty func (p *Plain) Upcoming(_ int) []Lyric { return []Lyric{} } diff --git a/internal/pkg/zess/zess.go b/internal/pkg/zess/zess.go index f3800de..469b4c2 100644 --- a/internal/pkg/zess/zess.go +++ b/internal/pkg/zess/zess.go @@ -12,7 +12,6 @@ import ( "github.com/zeusWPI/scc/internal/pkg/db/sqlc" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/util" - "go.uber.org/zap" ) // Zess represents a zess instance @@ -79,8 +78,6 @@ func (z *Zess) UpdateScans() error { return err } - zap.S().Info(lastScan) - errs := make([]error, 0) for _, scan := range *zessScans { if scan.ScanID <= lastScan.ScanID { diff --git a/tui/components/progress/progress.go b/tui/components/bar/bar.go similarity index 66% rename from tui/components/progress/progress.go rename to tui/components/bar/bar.go index 75a2924..66d1821 100644 --- a/tui/components/progress/progress.go +++ b/tui/components/bar/bar.go @@ -1,5 +1,5 @@ -// Package progress provides an animated progress bar -package progress +// Package bar provides an animated progress bar +package bar import ( "strings" @@ -30,17 +30,16 @@ type StartMsg struct { // Model for the progress component type Model struct { - id int64 - width int - widthTarget int - interval time.Duration - styleFainted lipgloss.Style - styleGlow lipgloss.Style + id int64 + width int + widthTarget int + interval time.Duration + style lipgloss.Style } // New creates a new progress -func New(styleFainted, styleGlow lipgloss.Style) Model { - return Model{id: nextID(), styleFainted: styleFainted, styleGlow: styleGlow} +func New(style lipgloss.Style) Model { + return Model{id: nextID(), style: style} } // Init initializes the progress component @@ -91,25 +90,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // View of the progress bar component func (m Model) View() string { - glowCount := min(20, m.width) - // Make sure if m.width is uneven that the half block string is in the glow part - if m.width%2 == 1 && glowCount%2 == 0 { - glowCount-- + b := strings.Repeat("▄", m.width/2) + if m.width%2 == 1 { + b += "▖" } - faintedCount := m.width - glowCount - - // Construct fainted - fainted := strings.Repeat("▄", faintedCount/2) - fainted = m.styleFainted.Render(fainted) - - // Construct glow - glow := strings.Repeat("▄", glowCount/2) - if glowCount%2 == 1 { - glow += "▖" - } - glow = m.styleGlow.Render(glow) - - return lipgloss.JoinHorizontal(lipgloss.Top, fainted, glow) + return m.style.Render(b) } func tick(id int64, interval time.Duration) tea.Cmd { diff --git a/tui/view/song/song.go b/tui/view/song/song.go index 68496f3..f7488c2 100644 --- a/tui/view/song/song.go +++ b/tui/view/song/song.go @@ -11,9 +11,10 @@ import ( "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/tui/components/progress" + "github.com/zeusWPI/scc/tui/components/bar" "github.com/zeusWPI/scc/tui/components/stopwatch" "github.com/zeusWPI/scc/tui/view" + "go.uber.org/zap" ) var ( @@ -21,85 +22,85 @@ var ( upcomingAmount = 12 // Amount of upcoming lyrics to show ) +type stat struct { + title string + entries []statEntry +} + +type statEntry struct { + name string + amount int +} + type playing struct { - song *dto.Song + song dto.Song + playing bool + lyrics lyrics.Lyrics + previous []string // Lyrics already sang + current string // Current lyric + upcoming []string // Lyrics that are coming up +} + +type progression struct { stopwatch stopwatch.Model - progress progress.Model - lyrics lyrics.Lyrics - previous []string // Lyrics already sang - current string // Current lyric - upcoming []string // Lyrics that are coming up + bar bar.Model } // Model represents the view model for song type Model struct { - db *db.DB - current playing - history []string - topSongs topStat - topGenres topStat - topArtists topStat - width int - height int + db *db.DB + current playing + progress progression + history stat + stats []stat + statsMonthly []stat + width int + height int } // Msg triggers a song data update -// Required for the View interface +// Required for the view interface type Msg struct{} -type msgPlaying struct { - song *dto.Song - lyrics lyrics.Lyrics +type msgHistory struct { + history stat } -type msgTop struct { - topSongs []topStatEntry - topGenres []topStatEntry - topArtists []topStatEntry +type msgStats struct { + monthly bool + stats []stat } -type msgHistory struct { - history []string +type msgPlaying struct { + song dto.Song + lyrics lyrics.Lyrics } type msgLyrics struct { - song *dto.Song + song dto.Song + playing bool previous []string current string upcoming []string startNext time.Time - done bool -} - -type topStat struct { - title string - entries []topStatEntry -} - -type topStatEntry struct { - name string - amount int } // New initializes a new song model func New(db *db.DB) view.View { return &Model{ - db: db, - current: playing{stopwatch: stopwatch.New(), progress: progress.New(sStatusProgressFainted, sStatusProgressGlow)}, - history: make([]string, 0, 5), - topSongs: topStat{title: "Top Tracks", entries: make([]topStatEntry, 0, 5)}, - topGenres: topStat{title: "Top Genres", entries: make([]topStatEntry, 0, 5)}, - topArtists: topStat{title: "Top Artists", entries: make([]topStatEntry, 0, 5)}, - width: 0, - height: 0, + db: db, + current: playing{}, + progress: progression{stopwatch: stopwatch.New(), bar: bar.New(sStatusBar)}, + stats: make([]stat, 4), + statsMonthly: make([]stat, 4), } } // Init starts the song view func (m *Model) Init() tea.Cmd { return tea.Batch( - m.current.stopwatch.Init(), - m.current.progress.Init(), + m.progress.stopwatch.Init(), + m.progress.bar.Init(), ) } @@ -112,49 +113,61 @@ func (m *Model) Name() string { func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: + // Size update! + // Check if it's relevant for this view entry, ok := msg.Sizes[m.Name()] if ok { + // Update all dependent styles m.width = entry.Width m.height = entry.Height - sStatusSong = sStatusSong.Width(m.width - view.GetOuterWidth(sStatusSong)) - sStatusProgress = sStatusProgress.Width(m.width - view.GetOuterWidth(sStatusProgress)) - sLyric = sLyric.Width(m.width - view.GetOuterWidth(sLyric)) - sStatAll = sStatAll.Width(m.width - view.GetOuterWidth(sStatAll)) - sAll = sAll.Height(m.height - view.GetOuterHeight(sAll)).Width(m.width - view.GetOuterWidth(sAll)) + m.updateStyles() } return m, nil case msgPlaying: + // We're playing a song + // Initialize the variables m.current.song = msg.song + m.current.playing = true m.current.lyrics = msg.lyrics - // New song, start the commands to update the lyrics - lyric, ok := m.current.lyrics.Next() + m.current.current = "" + m.current.previous = []string{""} + m.current.upcoming = []string{""} + + // The song might already been playing for some time + // Let's go through the lyrics until we get to the current one + lyric, ok := m.current.lyrics.Current() if !ok { - // Song already done - m.current.song = nil - return m, m.current.stopwatch.Reset() + // Shouldn't happen + zap.S().Error("song: unable to get current lyric in initialization phase: ", m.current.song.Title) + m.current.playing = false + return m, nil } - // Go through the lyrics until we get to the current one - startTime := m.current.song.CreatedAt.Add(lyric.Duration) + startTime := m.current.song.CreatedAt.Add(lyric.Duration) // Start time of the next lyric for startTime.Before(time.Now()) { + // This lyric is already finished, onto the next! lyric, ok := m.current.lyrics.Next() if !ok { - // We're too late to display lyrics - m.current.song = nil - return m, m.current.stopwatch.Reset() + // No more lyrics to display, the song is already finished + m.current.playing = false + return m, m.progress.stopwatch.Reset() } startTime = startTime.Add(lyric.Duration) } + // We have the right lyric, let's get the previous and upcoming lyrics + m.current.current = lyric.Text m.current.previous = lyricsToString(m.current.lyrics.Previous(previousAmount)) m.current.upcoming = lyricsToString(m.current.lyrics.Upcoming(upcomingAmount)) + + // Start the update loop return m, tea.Batch( updateLyrics(m.current, startTime), - m.current.stopwatch.Start(time.Since(m.current.song.CreatedAt)), - m.current.progress.Start(view.GetWidth(sStatusProgress), time.Since(m.current.song.CreatedAt), time.Duration(m.current.song.DurationMS)*time.Millisecond), + m.progress.stopwatch.Start(time.Since(m.current.song.CreatedAt)), + m.progress.bar.Start(view.GetWidth(sStatusBar), time.Since(m.current.song.CreatedAt), time.Duration(m.current.song.DurationMS)*time.Millisecond), ) case msgHistory: @@ -162,17 +175,14 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { return m, nil - case msgTop: - if msg.topSongs != nil { - m.topSongs.entries = msg.topSongs - } - if msg.topGenres != nil { - m.topGenres.entries = msg.topGenres - } - if msg.topArtists != nil { - m.topArtists.entries = msg.topArtists + case msgStats: + if msg.monthly { + // Monthly stats + m.statsMonthly = msg.stats + return m, nil } + m.stats = msg.stats return m, nil case msgLyrics: @@ -182,13 +192,12 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { return m, nil } - if msg.done { + m.current.playing = msg.playing + if !m.current.playing { // Song has finished. Reset variables - m.current.song = nil - return m, m.current.stopwatch.Reset() + return m, m.progress.stopwatch.Reset() } - // Msg is relevant, update values m.current.previous = msg.previous m.current.current = msg.current m.current.upcoming = msg.upcoming @@ -199,25 +208,24 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // Maybe a stopwatch message? var cmd tea.Cmd - m.current.stopwatch, cmd = m.current.stopwatch.Update(msg) + m.progress.stopwatch, cmd = m.progress.stopwatch.Update(msg) if cmd != nil { return m, cmd } - // Maybe a progress bar message? - m.current.progress, cmd = m.current.progress.Update(msg) + // Apparently not, lets try the bar! + m.progress.bar, cmd = m.progress.bar.Update(msg) return m, cmd } // View draws the song view func (m *Model) View() string { - if m.current.song != nil { + if m.current.playing { return m.viewPlaying() } return m.viewNotPlaying() - } // GetUpdateDatas gets all update functions for the song view @@ -236,14 +244,21 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { Interval: config.GetDefaultInt("tui.view.song.interval_history_s", 5), }, { - Name: "top stats", + Name: "monthly stats", + View: m, + Update: updateMonthlyStats, + Interval: config.GetDefaultInt("tui.view.song.interval_monthly_stats_s", 300), + }, + { + Name: "all time stats", View: m, - Update: updateTopStats, - Interval: config.GetDefaultInt("tui.view.song.interval_top_s", 3600), + Update: updateStats, + Interval: config.GetDefaultInt("tui.view.song.interval_stats_s", 3600), }, } } +// updateCurrentSong checks if there's currently a song playing func updateCurrentSong(view view.View) (tea.Msg, error) { m := view.(*Model) @@ -264,16 +279,18 @@ func updateCurrentSong(view view.View) (tea.Msg, error) { return nil, nil } - if m.current.song != nil && songs[0].ID == m.current.song.ID { + if m.current.playing && songs[0].ID == m.current.song.ID { // Song is already set to current return nil, nil } - song := dto.SongDTOHistory(songs) + // Convert sqlc song to a dto song + song := *dto.SongDTOHistory(songs) return msgPlaying{song: song, lyrics: lyrics.New(song)}, nil } +// updateHistory updates the recently played list func updateHistory(view view.View) (tea.Msg, error) { m := view.(*Model) @@ -282,32 +299,74 @@ func updateHistory(view view.View) (tea.Msg, error) { return nil, err } - return msgHistory{history: history}, nil + stat := stat{title: tStatHistory, entries: []statEntry{}} + for _, h := range history { + stat.entries = append(stat.entries, statEntry{name: h.Title, amount: int(h.PlayCount)}) + } + + return msgHistory{history: stat}, nil } -func updateTopStats(view view.View) (tea.Msg, error) { +// Update all monthly stats +func updateMonthlyStats(view view.View) (tea.Msg, error) { m := view.(*Model) - msg := msgTop{} - change := false - songs, err := m.db.Queries.GetTopSongs(context.Background()) + songs, err := m.db.Queries.GetTopMonthlySongs(context.Background()) if err != nil && err != pgx.ErrNoRows { return nil, err } - if !equalTopSongs(m.topSongs.entries, songs) { - msg.topSongs = topStatSqlcSong(songs) - change = true + genres, err := m.db.Queries.GetTopMonthlyGenres(context.Background()) + if err != nil && err != pgx.ErrNoRows { + return nil, err } - genres, err := m.db.Queries.GetTopGenres(context.Background()) + artists, err := m.db.Queries.GetTopMonthlyArtists(context.Background()) + if err != nil && err != pgx.ErrNoRows { + return nil, err + } + + // Don't bother checking if anything has changed + // A single extra refresh won't matter + + msg := msgStats{monthly: true, stats: []stat{}} + + // Songs + s := stat{title: tStatSong, entries: []statEntry{}} + for _, song := range songs { + s.entries = append(s.entries, statEntry{name: song.Title, amount: int(song.PlayCount)}) + } + msg.stats = append(msg.stats, s) + + // Genres + s = stat{title: tStatGenre, entries: []statEntry{}} + for _, genre := range genres { + s.entries = append(s.entries, statEntry{name: genre.GenreName, amount: int(genre.TotalPlays)}) + } + msg.stats = append(msg.stats, s) + + // Artists + s = stat{title: tStatArtist, entries: []statEntry{}} + for _, artist := range artists { + s.entries = append(s.entries, statEntry{name: artist.ArtistName, amount: int(artist.TotalPlays)}) + } + msg.stats = append(msg.stats, s) + + return msg, nil +} + +// Update all stats +func updateStats(view view.View) (tea.Msg, error) { + m := view.(*Model) + + songs, err := m.db.Queries.GetTopSongs(context.Background()) if err != nil && err != pgx.ErrNoRows { return nil, err } - if !equalTopGenres(m.topGenres.entries, genres) { - msg.topGenres = topStatSqlcGenre(genres) - change = true + genres, err := m.db.Queries.GetTopGenres(context.Background()) + if err != nil && err != pgx.ErrNoRows { + return nil, err } artists, err := m.db.Queries.GetTopArtists(context.Background()) @@ -315,19 +374,38 @@ func updateTopStats(view view.View) (tea.Msg, error) { return nil, err } - if !equalTopArtists(m.topArtists.entries, artists) { - msg.topArtists = topStatSqlcArtist(artists) - change = true + // Don't bother checking if anything has changed + // A single extra refresh won't matter + + msg := msgStats{monthly: false, stats: []stat{}} + + // Songs + s := stat{title: tStatSong, entries: []statEntry{}} + for _, song := range songs { + s.entries = append(s.entries, statEntry{name: song.Title, amount: int(song.PlayCount)}) + } + msg.stats = append(msg.stats, s) + + // Genres + s = stat{title: tStatGenre, entries: []statEntry{}} + for _, genre := range genres { + s.entries = append(s.entries, statEntry{name: genre.GenreName, amount: int(genre.TotalPlays)}) } + msg.stats = append(msg.stats, s) - if !change { - return nil, nil + // Artists + s = stat{title: tStatArtist, entries: []statEntry{}} + for _, artist := range artists { + s.entries = append(s.entries, statEntry{name: artist.ArtistName, amount: int(artist.TotalPlays)}) } + msg.stats = append(msg.stats, s) return msg, nil } +// Update the current lyric func updateLyrics(song playing, start time.Time) tea.Cmd { + // How long do we need to wait until we can update the lyric? timeout := time.Duration(0) now := time.Now() if start.After(now) { @@ -339,7 +417,7 @@ func updateLyrics(song playing, start time.Time) tea.Cmd { lyric, ok := song.lyrics.Next() if !ok { // Song finished - return msgLyrics{song: song.song, done: true} + return msgLyrics{song: song.song, playing: false} // Values in the other fields are not looked at when the song is finished } previous := song.lyrics.Previous(previousAmount) @@ -349,11 +427,11 @@ func updateLyrics(song playing, start time.Time) tea.Cmd { return msgLyrics{ song: song.song, + playing: true, previous: lyricsToString(previous), current: lyric.Text, upcoming: lyricsToString(upcoming), startNext: end, - done: false, } }) } diff --git a/tui/view/song/style.go b/tui/view/song/style.go index 81fb366..d88facb 100644 --- a/tui/view/song/style.go +++ b/tui/view/song/style.go @@ -1,6 +1,19 @@ package song -import "github.com/charmbracelet/lipgloss" +import ( + "math" + + "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/tui/view" +) + +// Title for statistics +const ( + tStatHistory = "Recently Played" + tStatSong = "Top Songs" + tStatGenre = "Top Genres" + tStatArtist = "Top Artists" +) // Colors var ( @@ -14,40 +27,70 @@ var base = lipgloss.NewStyle() // Styles for the stats var ( - wStatTotal = 40 - wStatEnum = 3 - wStatAmount = 4 - wStatBody = wStatTotal - wStatEnum - wStatAmount - - sStatAll = base.Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderTop(true).BorderForeground(cBorder).PaddingTop(3) - sStat = base.Width(wStatTotal).MarginRight(3).MarginBottom(2) - sStatTitle = base.Foreground(cZeus).Width(wStatTotal).Align(lipgloss.Center). - BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cSpotify) + // Widths + wStatEnum = 3 + wStatAmount = 4 + wStatEntryMax = 35 + + // Styles + sStat = base.Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderTop(true).BorderForeground(cBorder).PaddingTop(1) + sStatOne = base.Margin(0, 1) + sStatTitle = base.Foreground(cZeus).Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cSpotify) sStatEnum = base.Foreground(cSpotify).Width(wStatEnum).Align(lipgloss.Left) - sStatBody = base.Width(wStatBody) - sStatAmount = base.Width(wStatAmount).Align(lipgloss.Right).Foreground(cZeus) + sStatEntry = base.Align(lipgloss.Left) + sStatAmount = base.Foreground(cZeus).Width(wStatAmount).Align(lipgloss.Right) ) -// Styles for the status +// Styles for the lyrics var ( - sStatus = base.PaddingTop(1) - sStatusSong = base.Padding(0, 1).Align(lipgloss.Center) - sStatusStopwatch = base.Faint(true) - sStatusProgress = base.Padding(0, 2).PaddingBottom(3).Align(lipgloss.Left) - sStatusProgressFainted = base.Foreground(cZeus).Faint(true) - sStatusProgressGlow = base.Foreground(cZeus) + wLyricsF = 0.8 // Fraction of width + + sLyric = base.AlignVertical(lipgloss.Center).Align(lipgloss.Center) + sLyricPrevious = base.Foreground(cZeus).Bold(true).Align(lipgloss.Center).Faint(true) + sLyricCurrent = base.Foreground(cZeus).Bold(true).Align(lipgloss.Center) + sLyricUpcoming = base.Foreground(cSpotify).Bold(true).Align(lipgloss.Center) ) -// Styles for the lyrics +// Styles for the status var ( - sLyricBase = base.Width(50).Align(lipgloss.Center).Bold(true) - sLyric = sLyricBase.AlignVertical(lipgloss.Center) - sLyricPrevious = sLyricBase.Foreground(cZeus).Faint(true) - sLyricCurrent = sLyricBase.Foreground(cZeus) - sLyricUpcoming = sLyricBase.Foreground(cSpotify) + sStatus = base + sStatusSong = base.Align(lipgloss.Center) + sStatusStopwatch = base.Faint(true) + sStatusBar = base.Foreground(cZeus).Align(lipgloss.Left) ) // Style for everything var ( sAll = base.Align(lipgloss.Center).AlignVertical(lipgloss.Center) ) + +// updateStyles updates all the affected styles when a size update message is received +func (m *Model) updateStyles() { + // Adjust stats styles + sStat = sStat.Width(m.width) + + wStatEntry := int(math.Min(float64(wStatEntryMax), float64(m.width/4)-float64(view.GetOuterWidth(sStatOne)+wStatEnum+wStatAmount))) + sStatEntry = sStatEntry.Width(wStatEntry) + sStatOne = sStatOne.Width(wStatEnum + wStatAmount + wStatEntry) + sStatTitle = sStatTitle.Width(wStatEnum + wStatAmount + wStatEntry) + if wStatEntry == wStatEntryMax { + // We're full screen + sStatOne = sStatOne.Margin(0, 3) + } + + // Adjust lyrics styles + sLyric = sLyric.Width(m.width) + + wLyrics := int(float64(m.width) * wLyricsF) + sLyricPrevious = sLyricPrevious.Width(wLyrics) + sLyricCurrent = sLyricCurrent.Width(wLyrics) + sLyricUpcoming = sLyricUpcoming.Width(wLyrics) + + // Adjust status styles + + sStatusSong = sStatusSong.Width(m.width - view.GetOuterWidth(sStatusSong)) + sStatusBar = sStatusBar.Width(m.width - view.GetOuterWidth(sStatusBar)) + + // Adjust the all styles + sAll = sAll.Height(m.height - view.GetOuterHeight(sAll)).Width(m.width - view.GetOuterWidth(sAll)) +} diff --git a/tui/view/song/util.go b/tui/view/song/util.go index 8fb7b90..c3808ac 100644 --- a/tui/view/song/util.go +++ b/tui/view/song/util.go @@ -1,76 +1,9 @@ package song import ( - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" "github.com/zeusWPI/scc/internal/pkg/lyrics" ) -func equalTopSongs(s1 []topStatEntry, s2 []sqlc.GetTopSongsRow) bool { - if len(s1) != len(s2) { - return false - } - - for i, s := range s1 { - if s.name != s2[i].Title || s.amount != int(s2[i].PlayCount) { - return false - } - } - - return true -} - -func topStatSqlcSong(songs []sqlc.GetTopSongsRow) []topStatEntry { - topstats := make([]topStatEntry, 0, len(songs)) - for _, s := range songs { - topstats = append(topstats, topStatEntry{name: s.Title, amount: int(s.PlayCount)}) - } - return topstats -} - -func equalTopGenres(s1 []topStatEntry, s2 []sqlc.GetTopGenresRow) bool { - if len(s1) != len(s2) { - return false - } - - for i, s := range s1 { - if s.name != s2[i].GenreName || s.amount != int(s2[i].TotalPlays) { - return false - } - } - - return true -} - -func topStatSqlcGenre(songs []sqlc.GetTopGenresRow) []topStatEntry { - topstats := make([]topStatEntry, 0, len(songs)) - for _, s := range songs { - topstats = append(topstats, topStatEntry{name: s.GenreName, amount: int(s.TotalPlays)}) - } - return topstats -} - -func equalTopArtists(s1 []topStatEntry, s2 []sqlc.GetTopArtistsRow) bool { - if len(s1) != len(s2) { - return false - } - - for i, s := range s1 { - if s.name != s2[i].ArtistName || s.amount != int(s2[i].TotalPlays) { - return false - } - } - - return true -} - -func topStatSqlcArtist(songs []sqlc.GetTopArtistsRow) []topStatEntry { - topstats := make([]topStatEntry, 0, len(songs)) - for _, s := range songs { - topstats = append(topstats, topStatEntry{name: s.ArtistName, amount: int(s.TotalPlays)}) - } - return topstats -} - func lyricsToString(lyrics []lyrics.Lyric) []string { text := make([]string, 0, len(lyrics)) for _, lyric := range lyrics { diff --git a/tui/view/song/view.go b/tui/view/song/view.go index 97bf487..33d7651 100644 --- a/tui/view/song/view.go +++ b/tui/view/song/view.go @@ -13,10 +13,12 @@ func (m *Model) viewPlaying() string { status = sStatus.Render(status) stats := m.viewPlayingStats() - stats = sStatAll.Render(stats) + stats = sStat.Render(stats) lyrics := m.viewPlayingLyrics() - lyrics = sLyric.Height(sAll.GetHeight() - lipgloss.Height(status) - lipgloss.Height(stats)).Render(lyrics) + lyrics = sLyric.Height(sAll.GetHeight() - lipgloss.Height(status) - lipgloss.Height(stats)). + MaxHeight(sAll.GetHeight() - lipgloss.Height(status) - lipgloss.Height(stats)). + Render(lyrics) view := lipgloss.JoinVertical(lipgloss.Left, status, lyrics, stats) @@ -26,7 +28,7 @@ func (m *Model) viewPlaying() string { 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 := fmt.Sprintf("\t%s / %02d:%02d", m.progress.stopwatch.View(), durationS/60, durationS%60) stopwatch = sStatusStopwatch.Render(stopwatch) // Song name @@ -42,8 +44,8 @@ func (m *Model) viewPlayingStatus() string { song := sStatusSong.Width(sStatusSong.GetWidth() - lipgloss.Width(stopwatch)).Render(fmt.Sprintf("%s | %s", m.current.song.Title, artist)) // Progress bar - progress := m.current.progress.View() - progress = sStatusProgress.Render(progress) + progress := m.progress.bar.View() + progress = sStatusBar.Render(progress) view := lipgloss.JoinHorizontal(lipgloss.Top, song, stopwatch) view = lipgloss.JoinVertical(lipgloss.Left, view, progress) @@ -76,10 +78,10 @@ func (m *Model) viewPlayingLyrics() string { func (m *Model) viewPlayingStats() string { columns := make([]string, 0, 4) - columns = append(columns, m.viewRecent()) - columns = append(columns, m.viewTopStat(m.topSongs)) - columns = append(columns, m.viewTopStat(m.topArtists)) - columns = append(columns, m.viewTopStat(m.topGenres)) + columns = append(columns, m.viewStat(m.history)) + columns = append(columns, m.viewStat(m.stats[0])) + columns = append(columns, m.viewStat(m.stats[1])) + columns = append(columns, m.viewStat(m.stats[2])) return lipgloss.JoinHorizontal(lipgloss.Top, columns...) } @@ -90,10 +92,10 @@ func (m *Model) viewNotPlaying() string { rows = append(rows, make([]string, 0, 2)) } - rows[0] = append(rows[0], m.viewRecent()) - rows[0] = append(rows[0], m.viewTopStat(m.topSongs)) - rows[1] = append(rows[1], m.viewTopStat(m.topArtists)) - rows[1] = append(rows[1], m.viewTopStat(m.topGenres)) + rows[0] = append(rows[0], m.viewStat(m.history)) + rows[0] = append(rows[0], m.viewStat(m.stats[0])) + rows[1] = append(rows[1], m.viewStat(m.stats[1])) + rows[1] = append(rows[1], m.viewStat(m.stats[2])) renderedRows := make([]string, 0, 2) for _, row := range rows { @@ -105,30 +107,19 @@ func (m *Model) viewNotPlaying() string { return sAll.Render(view) } -func (m *Model) viewRecent() string { - items := make([]string, 0, len(m.history)) - for i, track := range m.history { - number := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) - body := sStatBody.Render(track) - items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, number, body)) - } - l := lipgloss.JoinVertical(lipgloss.Left, items...) - title := sStatTitle.Render("Recently Played") - - return sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l)) -} - -func (m *Model) viewTopStat(topStat topStat) string { - items := make([]string, 0, len(topStat.entries)) - for i, stat := range topStat.entries { - number := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) - body := sStatBody.Render(stat.name) +func (m *Model) viewStat(stat stat) string { + items := make([]string, 0, len(stat.entries)) + for i, stat := range stat.entries { + enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) + entry := sStatEntry.Render(stat.name) amount := sStatAmount.Render(fmt.Sprintf("%d", stat.amount)) - items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, number, body, amount)) + + items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, enum, entry, amount)) } + items = append(items, "") // HACK: Avoid the last item shifting to the right l := lipgloss.JoinVertical(lipgloss.Left, items...) - title := sStatTitle.Render(topStat.title) + title := sStatTitle.Render(stat.title) - return sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l)) + return sStatOne.Render(lipgloss.JoinVertical(lipgloss.Left, title, l)) }