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..210f367 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 50; -- 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/go.mod b/go.mod index 5c0c539..0cac582 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,7 @@ module github.com/zeusWPI/scc go 1.23.1 require ( - github.com/NimbleMarkets/ntcharts v0.2.0 - github.com/charmbracelet/bubbles v0.20.0 + github.com/NimbleMarkets/ntcharts v0.3.1 github.com/charmbracelet/bubbletea v1.2.4 github.com/charmbracelet/lipgloss v1.0.0 github.com/disintegration/imaging v1.6.2 @@ -12,7 +11,7 @@ require ( github.com/gocolly/colly v1.2.0 github.com/gofiber/contrib/fiberzap v1.0.2 github.com/gofiber/fiber/v2 v2.52.5 - github.com/jackc/pgx/v5 v5.7.1 + github.com/jackc/pgx/v5 v5.7.2 github.com/joho/godotenv v1.5.1 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/spf13/viper v1.19.0 @@ -21,33 +20,33 @@ require ( require ( github.com/PuerkitoBio/goquery v1.10.0 // indirect - github.com/andybalholm/brotli v1.0.5 // indirect - github.com/andybalholm/cascadia v1.3.2 // indirect - github.com/antchfx/htmlquery v1.3.3 // indirect - github.com/antchfx/xmlquery v1.4.2 // indirect - github.com/antchfx/xpath v1.3.2 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/antchfx/htmlquery v1.3.4 // indirect + github.com/antchfx/xmlquery v1.4.3 // indirect + github.com/antchfx/xpath v1.3.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/bubbles v0.20.0 // indirect + github.com/charmbracelet/x/ansi v0.6.0 // indirect github.com/charmbracelet/x/term v0.2.1 // 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/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.7 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/google/uuid v1.5.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kennygrant/sanitize v1.2.4 // indirect - github.com/klauspost/compress v1.17.2 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/lrstanley/bubblezone v0.0.0-20241221063659-0f12a2876fb2 // indirect + github.com/magiconair/properties v1.8.9 // indirect 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 @@ -56,30 +55,30 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/locafero v0.6.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/temoto/robotstxt v1.1.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/fasthttp v1.58.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.27.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect - golang.org/x/net v0.29.0 // indirect - golang.org/x/sync v0.9.0 // indirect - golang.org/x/sys v0.27.0 // indirect - golang.org/x/text v0.20.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect + golang.org/x/image v0.23.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/protobuf v1.36.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c64bca3..6e012d2 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,41 @@ github.com/NimbleMarkets/ntcharts v0.2.0 h1:uVpvUL9fZk/LGsc8E00kdBLHwh60llfvci+2JpJ6EDI= github.com/NimbleMarkets/ntcharts v0.2.0/go.mod h1:BLzvdpQAv4NpGbOTsi3fCRzeDk276PGezkp75gD73kY= +github.com/NimbleMarkets/ntcharts v0.3.1 h1:EH4O80RMy5rqDmZM7aWjTbCSuRDDJ5fXOv/qAzdwOjk= +github.com/NimbleMarkets/ntcharts v0.3.1/go.mod h1:zVeRqYkh2n59YPe1bflaSL4O2aD2ZemNmrbdEqZ70hk= github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4= github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/antchfx/htmlquery v1.3.3 h1:x6tVzrRhVNfECDaVxnZi1mEGrQg3mjE/rxbH2Pe6dNE= github.com/antchfx/htmlquery v1.3.3/go.mod h1:WeU3N7/rL6mb6dCwtE30dURBnBieKDC/fR8t6X+cKjU= +github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ= +github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM= github.com/antchfx/xmlquery v1.4.2 h1:MZKd9+wblwxfQ1zd1AdrTsqVaMjMCwow3IqkCSe00KA= github.com/antchfx/xmlquery v1.4.2/go.mod h1:QXhvf5ldTuGqhd1SHNvvtlhhdQLks4dD0awIVhXIDTA= +github.com/antchfx/xmlquery v1.4.3 h1:f6jhxCzANrWfa93O+NmRWvieVyLs+R2Szfpy+YrZaww= +github.com/antchfx/xmlquery v1.4.3/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc= github.com/antchfx/xpath v1.3.2 h1:LNjzlsSjinu3bQpw9hWMY9ocB80oLOWuQqFvO6xt51U= github.com/antchfx/xpath v1.3.2/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/antchfx/xpath v1.3.3 h1:tmuPQa1Uye0Ym1Zn65vxPgfltWb/Lxu2jeqIGteJSRs= +github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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 v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= -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.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA= +github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,8 +50,12 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= +github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -58,15 +74,21 @@ github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yG github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -75,6 +97,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -83,6 +107,8 @@ github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8Nz github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -91,10 +117,14 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e h1:OLwZ8xVaeVrru0xyeuOX+fne0gQTFEGlzfNjipCbxlU= github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e/go.mod h1:NQ34EGeu8FAYGBMDzwhfNJL8YQYoWZP5xYJPRDAwN3E= +github.com/lrstanley/bubblezone v0.0.0-20241221063659-0f12a2876fb2 h1:iILPmPi4ytvFMzb90E7S7if5cdlyboFLXgBRe+7tLAA= +github.com/lrstanley/bubblezone v0.0.0-20241221063659-0f12a2876fb2/go.mod h1:NQ34EGeu8FAYGBMDzwhfNJL8YQYoWZP5xYJPRDAwN3E= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= +github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -114,6 +144,8 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -124,6 +156,8 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= +github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= @@ -134,6 +168,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= @@ -157,38 +193,67 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE= +github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo= +golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= +golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.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= @@ -199,24 +264,43 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= @@ -225,6 +309,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= +google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/pkg/db/dto/song.go b/internal/pkg/db/dto/song.go index b678297..401b3dc 100644 --- a/internal/pkg/db/dto/song.go +++ b/internal/pkg/db/dto/song.go @@ -14,7 +14,7 @@ type Song struct { Album string `json:"album"` SpotifyID string `json:"spotify_id" validate:"required"` DurationMS int32 `json:"duration_ms"` - LyricsType string `json:"lyrics_type"` // Either 'synced' or 'plain' + LyricsType string `json:"lyrics_type"` // Either 'synced' ,'plain' or 'instrumental' Lyrics string `json:"lyrics"` CreatedAt time.Time `json:"created_at"` Artists []SongArtist `json:"artists"` diff --git a/internal/pkg/db/sqlc/song.sql.go b/internal/pkg/db/sqlc/song.sql.go index 91d0493..16ff8de 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 -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 50 ` -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/instrumental.go b/internal/pkg/lyrics/instrumental.go new file mode 100644 index 0000000..a34d73e --- /dev/null +++ b/internal/pkg/lyrics/instrumental.go @@ -0,0 +1,318 @@ +package lyrics + +import ( + "fmt" + "math/rand/v2" + "time" + + "github.com/zeusWPI/scc/internal/pkg/db/dto" +) + +// Instrumental represents the lyrics for an instrumental song +type Instrumental struct { + song dto.Song + lyrics []Lyric + i int +} + +func newInstrumental(song dto.Song) Lyrics { + return &Instrumental{song: song, lyrics: generateInstrumental(time.Duration(song.DurationMS) * time.Millisecond), i: 0} +} + +// GetSong returns the song associated to the lyrics +func (i *Instrumental) GetSong() dto.Song { + return i.song +} + +// Previous provides the previous `amount` of lyrics without affecting the current lyric +func (i *Instrumental) Previous(amount int) []Lyric { + lyrics := make([]Lyric, 0, amount) + + for j := 1; j <= amount; j++ { + if i.i-j-1 < 0 { + break + } + + lyrics = append([]Lyric{i.lyrics[i.i-j-1]}, lyrics...) + } + + return lyrics +} + +// Current provides the current lyric if any. +func (i *Instrumental) Current() (Lyric, bool) { + if i.i >= len(i.lyrics) { + return Lyric{}, false + } + + return i.lyrics[i.i], true +} + +// Next provides the next lyric. +// In this case it's always nothing +func (i *Instrumental) Next() (Lyric, bool) { + if i.i+1 >= len(i.lyrics) { + return Lyric{}, false + } + + i.i++ + return i.lyrics[i.i-1], true +} + +// Upcoming provides the next `amount` lyrics without affecting the current lyric +// In this case it's always empty +func (i *Instrumental) Upcoming(amount int) []Lyric { + lyrics := make([]Lyric, 0, amount) + + for j := 0; j < amount; j++ { + if i.i+j >= len(i.lyrics) { + break + } + + lyrics = append(lyrics, i.lyrics[i.i+j]) + } + + return lyrics +} + +// Progress shows the fraction of lyrics that have been used. +func (i *Instrumental) Progress() float64 { + return float64(i.i) / float64(len(i.lyrics)) +} + +func generateInstrumental(dur time.Duration) []Lyric { + // Get all instruments with their frequency + freqs := []instrument{} + for _, instr := range instruments { + for range instr.frequency { + freqs = append(freqs, instr) + } + } + + lyrics := []Lyric{} + // Split up song in segments between 5 and 15 seconds + currentDur := time.Duration(0) + for currentDur < dur { + // Get a random instrument + instr := freqs[rand.IntN(len(freqs))] + + // Get a random duration + randomDur := time.Duration(rand.IntN(10)+5) * time.Second + currentDur += randomDur + if currentDur >= dur { + randomDur -= (currentDur - dur) + } + + // Get the lyrics + lyrics = append(lyrics, instr.generate(randomDur)...) + } + + return lyrics +} + +// Instruments + +type instrument struct { + frequency int // Odds of it occuring + name string // Name of the instrument + sounds []string // Different ways it could sound +} + +// generate creates lyrics for a specific instrument +func (i instrument) generate(dur time.Duration) []Lyric { + lyrics := []Lyric{} + + // Same logic as in `generateInstrumental` except that the segments are between 1 and 4 seconds + + // Add the start lyric + text := fmt.Sprintf(openings[rand.IntN(len(openings))], i.name) + randomDur := time.Duration(1) * time.Second + lyrics = append(lyrics, Lyric{Text: text, Duration: randomDur}) + + currentDur := randomDur + for currentDur < dur { + // Get a random text + textLength := rand.IntN(5) + 1 + var text string + for range textLength { + text += i.sounds[rand.IntN(len(i.sounds))] + " " + } + + // Get a random duration + randomDur := time.Duration(rand.IntN(3)+1) * time.Second + currentDur += randomDur + if currentDur >= dur { + // Last lyric, add a newline + text += "\n" + randomDur -= (currentDur - dur) + } + + lyrics = append(lyrics, Lyric{Text: text, Duration: randomDur}) + } + + return lyrics +} + +// All instruments to choose from +var instruments = []instrument{ + {frequency: 9, name: "Piano", sounds: []string{"plink", "plonk", "pling", "clink", "clang"}}, + {frequency: 7, name: "Drums", sounds: []string{"boom", "ba-dum", "thwack", "tshh", "bop"}}, + {frequency: 5, name: "Electric Guitar", sounds: []string{"wah", "zzzzzz", "twang", "vrrr", "brrraang"}}, + {frequency: 3, name: "Theremin", sounds: []string{"wooOOOooo", "weeeee", "ooooo", "waaAAaah", "hummmm"}}, + {frequency: 6, name: "Flute", sounds: []string{"toot", "fweee", "trillll", "pip", "peep"}}, + {frequency: 4, name: "Accordion", sounds: []string{"wheeze", "honk", "phwoo", "eep", "squawk"}}, + {frequency: 8, name: "Violin", sounds: []string{"screee", "swish", "zing", "vwee", "mreee"}}, + {frequency: 5, name: "Saxophone", sounds: []string{"saxxy", "bwoop", "dooo", "reebop", "honka"}}, + {frequency: 2, name: "Kazoo", sounds: []string{"bzzzzz", "zwip", "vwoo", "brrr", "zzzzrrt"}}, + {frequency: 9, name: "Trumpet", sounds: []string{"brrraaap", "toot", "doo-doo", "wah-wah", "parp"}}, + {frequency: 3, name: "Cowbell", sounds: []string{"clang", "clong", "ding", "donk", "bonk"}}, + {frequency: 2, name: "Bagpipes", sounds: []string{"drone", "hrooo", "whine", "skree", "rrrrrrr"}}, + {frequency: 6, name: "Triangle", sounds: []string{"ting", "tang", "ding", "dling", "plink"}}, + {frequency: 1, name: "Didgeridoo", sounds: []string{"whooooo", "womp", "drrrrrr", "brrrrr", "hummmmm"}}, + {frequency: 4, name: "Bongos", sounds: []string{"pop", "tap", "dum", "ba-dum", "bop"}}, + {frequency: 7, name: "Harp", sounds: []string{"plink", "tinkle", "zling", "glint", "ding"}}, + {frequency: 5, name: "Maracas", sounds: []string{"sh-sh-sh", "shaka-shaka", "chick", "rattle", "tktktk"}}, + {frequency: 3, name: "Tuba", sounds: []string{"oompah", "bruhm", "whoom", "booo", "phrum"}}, + {frequency: 1, name: "Banjo", sounds: []string{"twang", "plink", "brrrring", "plunk", "doink"}}, + {frequency: 2, name: "Synthesizer", sounds: []string{"beep-boop", "vwee-vwee", "zorp", "waah", "ding"}}, + {frequency: 6, name: "Xylophone", sounds: []string{"ding", "dunk", "plink-plonk", "tok", "tink"}}, + {frequency: 2, name: "Hurdy-Gurdy", sounds: []string{"whirr", "drone", "skreee", "buzz", "rrrrrng"}}, + {frequency: 4, name: "Harmonica", sounds: []string{"wheeze", "toot", "hoo", "blow", "brrrr"}}, + {frequency: 3, name: "Slide Whistle", sounds: []string{"whoooop", "wheeee", "wooo", "boooo", "zwip"}}, + {frequency: 5, name: "Tambourine", sounds: []string{"jingle", "shake-shake", "tshh", "tinkle", "ting-ting"}}, + {frequency: 2, name: "Ocarina", sounds: []string{"woo", "fweee", "doodle", "pip-pip", "toot"}}, + {frequency: 8, name: "Acoustic Guitar", sounds: []string{"strum", "plang", "twang", "zing", "thrum"}}, + {frequency: 1, name: "Sousaphone", sounds: []string{"toot", "boop", "pah-pah", "oompah", "pwaaah"}}, + {frequency: 3, name: "Castanets", sounds: []string{"clack", "click", "clap", "tick", "tack"}}, + {frequency: 7, name: "Synth Drum", sounds: []string{"pshh", "bzzt", "bip", "tsh", "zorp"}}, + {frequency: 2, name: "Bag of Gravel", sounds: []string{"crunch", "scrape", "sh-sh", "clatter", "grrnk"}}, + {frequency: 5, name: "Steel Drum", sounds: []string{"pong", "ding", "donk", "bop", "ting"}}, + {frequency: 4, name: "Mouth Harp", sounds: []string{"boing", "thwong", "zzzt", "doyoyoy", "wobble"}}, + {frequency: 2, name: "Rainstick", sounds: []string{"shhhhh", "rrrrrr", "drip-drop", "fwssh", "ssss"}}, + {frequency: 1, name: "Toy Piano", sounds: []string{"plink", "tink-tink", "chime", "plinkity", "dink"}}, + {frequency: 3, name: "Jaw Harp", sounds: []string{"twang", "boing", "doink", "womp", "zzzrrrt"}}, + {frequency: 4, name: "Bicycle Horn", sounds: []string{"honk", "meeep", "awoooga", "brrrt", "bop-bop"}}, + {frequency: 2, name: "Glass Harp", sounds: []string{"wheee", "zing", "woo", "glint", "oooo"}}, + {frequency: 6, name: "Claves", sounds: []string{"clack", "click", "clonk", "tak", "tok"}}, + {frequency: 3, name: "Rubber Band", sounds: []string{"twang", "ping", "boing", "zing", "snap"}}, + {frequency: 2, name: "Paper Comb", sounds: []string{"buzz", "brrr", "wobble", "zzzt", "drone"}}, + {frequency: 1, name: "Duck Call", sounds: []string{"quack", "wak-wak", "honk", "waak", "weeek"}}, + {frequency: 5, name: "Handbells", sounds: []string{"ding", "dong", "chime", "tinkle", "bong"}}, + {frequency: 4, name: "Foghorn", sounds: []string{"MOOOO", "hoooonk", "BWAAAA", "WOOOO", "brrrmmm"}}, + {frequency: 7, name: "Cello", sounds: []string{"mmmm", "vmmm", "vroom", "dronnn", "zoomm"}}, + {frequency: 6, name: "Clarinet", sounds: []string{"toot", "wooo", "hmmm", "dee-dee", "reeee"}}, + {frequency: 8, name: "Oboe", sounds: []string{"hweee", "hee", "whee", "ooooo", "reee"}}, + {frequency: 5, name: "French Horn", sounds: []string{"vooom", "phoo", "bwoo", "vuuum", "whooo"}}, + {frequency: 6, name: "Bassoon", sounds: []string{"boo", "brrrr", "phrum", "wuuu", "vrrr"}}, + {frequency: 8, name: "Timpani", sounds: []string{"boom", "dum", "rumble", "thud", "pum"}}, + {frequency: 7, name: "Double Bass", sounds: []string{"vrumm", "dumm", "boooom", "grumm", "zzzooom"}}, + {frequency: 9, name: "Trumpet", sounds: []string{"brrrmp", "doo-doo", "toot", "baap", "dah-dah"}}, + {frequency: 6, name: "Trombone", sounds: []string{"wah-wah", "dooo", "wooo", "bwaaah", "vroom"}}, + {frequency: 4, name: "Harp", sounds: []string{"plink", "strum", "zinnnng", "twang", "gliss"}}, + {frequency: 6, name: "Piccolo", sounds: []string{"peep", "tweet", "fweep", "weeet", "pweep"}}, + {frequency: 7, name: "Bass Drum", sounds: []string{"boom", "thud", "pum", "dum", "bomp"}}, + {frequency: 5, name: "Snare Drum", sounds: []string{"rat-a-tat", "tsh", "tktktk", "snap", "crack"}}, + {frequency: 7, name: "Tuba", sounds: []string{"pah-pah", "brumm", "booom", "ooooh", "vrooo"}}, + {frequency: 6, name: "Viola", sounds: []string{"mmmmm", "zoooo", "veee", "whooo", "vrreee"}}, + {frequency: 5, name: "Glockenspiel", sounds: []string{"ding", "tinkle", "ping", "plink", "chime"}}, + {frequency: 7, name: "Organ", sounds: []string{"hummmm", "ooooo", "voooom", "drone", "wooo"}}, + {frequency: 4, name: "Bass Clarinet", sounds: []string{"mmmm", "brooo", "bwooo", "rooo", "vrmmm"}}, + {frequency: 6, name: "English Horn", sounds: []string{"hooo", "wheee", "woooo", "phmmm", "breee"}}, + {frequency: 8, name: "Concert Bass Drum", sounds: []string{"BOOM", "rumble", "dum", "doom", "pum"}}, + {frequency: 5, name: "Cymbals", sounds: []string{"crash", "clang", "clash", "shing", "chhhh"}}, + {frequency: 6, name: "Recorder", sounds: []string{"tweet", "toot", "peep", "reep", "fweee"}}, + {frequency: 5, name: "Baritone Saxophone", sounds: []string{"vrooo", "booo", "bop", "grmmm", "vrooom"}}, + {frequency: 7, name: "Marimba", sounds: []string{"tok", "tonk", "dunk", "dong", "bong"}}, +} + +var openings = []string{ + "The sound of %s fills the air", + "Everyone listens as %s takes over", + "A melody rises, played by %s", + "The stage belongs to %s now", + "You can hear %s in the distance", + "All eyes are on %s as it begins", + "The music swells, led by %s", + "A soft hum emerges from %s", + "Powerful notes erupt from %s", + "The rhythm shifts, thanks to %s", + "From the corner, %s adds its voice", + "The harmony is completed by %s", + "Suddenly, %s makes its presence known", + "In the mix, %s finds its place", + "A delicate tune floats out of %s", + "The energy builds, driven by %s", + "A resonant sound comes from %s", + "The silence is broken by %s", + "An unmistakable sound flows from %s", + "Everything changes when %s joins in", + "The audience is captivated by %s", + "The backdrop hums with the sound of %s", + "A new tone emerges, thanks to %s", + "The piece takes flight with %s", + "A rich sound emanates from %s", + "The music deepens as %s plays", + "Out of nowhere, %s begins to play", + "The atmosphere transforms with %s", + "The melody comes alive with %s", + "A wave of sound builds around %s", + "The air is electrified by %s", + "The composition breathes through %s", + "A bright tone emerges from %s", + "The song's heartbeat is driven by %s", + "In the chaos, %s finds its voice", + "The layers of sound are enriched by %s", + "A subtle rhythm flows from %s", + "The crowd stirs as %s joins the fray", + "The lead shifts to %s for a moment", + "The balance is perfected by %s", + "The soul of the piece resonates with %s", + "A cascade of notes falls from %s", + "The performance peaks with %s", + "Each note feels alive with %s playing", + "The essence of the tune shines through %s", + "A haunting sound drifts from %s", + "The soundscape expands with %s", + "The magic unfolds around %s", + "The rhythm breathes new life through %s", + "From the shadows, %s contributes a tone", + "The journey continues with %s", + "A bold entrance by %s turns heads", + "The crescendo builds, led by %s", + "The quiet is punctuated by %s", + "The song finds its pulse in %s", + "The atmosphere shimmers with %s", + "A tender phrase is born from %s", + "The mood shifts under the spell of %s", + "%s brings a new layer to the melody", + "%s fills the space with its sound", + "%s adds depth to the composition", + "%s carries the tune to new heights", + "%s weaves through the harmony effortlessly", + "%s resonates with a rich and vibrant tone", + "%s shapes the rhythm with precision", + "%s colors the soundscape beautifully", + "%s takes the lead with bold notes", + "%s softens the mood with its melody", + "%s breathes life into the music", + "%s anchors the harmony with steady tones", + "%s dances through the melody with ease", + "%s punctuates the silence with clarity", + "%s soars above the other instruments", + "%s enriches the atmosphere with its presence", + "%s blends seamlessly into the symphony", + "%s echoes the spirit of the piece", + "%s shines as the centerpiece of the sound", + "%s threads its voice into the composition", + "%s carries the weight of the rhythm", + "%s bursts forth with dynamic energy", + "%s hums softly, anchoring the melody", + "%s paints vivid colors with its notes", + "%s rises and falls with graceful precision", + "%s whispers a delicate phrase into the mix", + "%s transforms the tune with its entrance", + "%s gives the piece a fresh perspective", + "%s stirs emotions with every note", + "%s intertwines with the harmony effortlessly", + "%s drives the pulse of the music forward", +} diff --git a/internal/pkg/lyrics/lrc.go b/internal/pkg/lyrics/lrc.go index f38b9ad..02f9b1f 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 @@ -90,7 +90,7 @@ func parseLRC(text string, totalDuration time.Duration) []Lyric { return []Lyric{} } - lyrics := make([]Lyric, 0, len(lines)+1) + lyrics := make([]Lyric, 0, len(lines)+1) // + 1 for a start empty lyric var previousTimestamp time.Duration // Add first lyric (no text) diff --git a/internal/pkg/lyrics/lyrics.go b/internal/pkg/lyrics/lyrics.go index 69a6750..2c3cb23 100644 --- a/internal/pkg/lyrics/lyrics.go +++ b/internal/pkg/lyrics/lyrics.go @@ -24,10 +24,22 @@ 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 { + // Basic sync if song.LyricsType == "synced" { return newLRC(song) } - return newPlain(song) + // Plain + if song.LyricsType == "plain" { + return newPlain(song) + } + + // Instrumental + if song.LyricsType == "instrumental" { + return newInstrumental(song) + } + + // No lyrics found + return newMissing(song) } diff --git a/internal/pkg/lyrics/missing.go b/internal/pkg/lyrics/missing.go new file mode 100644 index 0000000..1602d2c --- /dev/null +++ b/internal/pkg/lyrics/missing.go @@ -0,0 +1,55 @@ +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 +} + +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} +} + +// 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. +func (m *Missing) Current() (Lyric, bool) { + return m.lyrics, true +} + +// Next provides the next lyric. +// In this case it's always nothing +func (m *Missing) Next() (Lyric, bool) { + return Lyric{}, false +} + +// 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 { + return 1 +} diff --git a/internal/pkg/lyrics/plain.go b/internal/pkg/lyrics/plain.go index 24cbd7f..2771969 100644 --- a/internal/pkg/lyrics/plain.go +++ b/internal/pkg/lyrics/plain.go @@ -10,15 +10,14 @@ import ( type Plain struct { song dto.Song lyrics Lyric - 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} } // GetSong returns the song associated to the lyrics @@ -27,42 +26,29 @@ 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{} } // Current provides the current lyric if any. -// If the song is finished the boolean is set to false func (p *Plain) Current() (Lyric, bool) { - if p.given { - return Lyric{}, false - } - - return Lyric{}, true + return p.lyrics, true } // Next provides the next lyric. -// If the lyrics are finished the boolean is set to false +// In this case it's alway nothing func (p *Plain) Next() (Lyric, bool) { - if p.given { - return Lyric{}, false - } - - p.given = true - - return p.lyrics, true + return Lyric{}, false } // 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{} } // Progress shows the fraction of lyrics that have been used. func (p *Plain) Progress() float64 { - if p.given { - return 1 - } - - return 0 + return 1 } diff --git a/internal/pkg/song/api.go b/internal/pkg/song/api.go index 1f651a7..1758e46 100644 --- a/internal/pkg/song/api.go +++ b/internal/pkg/song/api.go @@ -93,6 +93,7 @@ func (s *Song) getArtist(artist *dto.SongArtist) error { } type lyricsResponse struct { + Instrumental bool `json:"instrumental"` PlainLyrics string `json:"plainLyrics"` SyncedLyrics string `json:"SyncedLyrics"` } @@ -126,18 +127,31 @@ func (s *Song) getLyrics(track *dto.Song) error { return errors.Join(append([]error{errors.New("Song: Lyrics request failed")}, errs...)...) } if status != fiber.StatusOK { + if status == fiber.StatusNotFound { + // Lyrics not found + return nil + } + return fmt.Errorf("Song: Lyrics request wrong status code %d", status) } if (res == &lyricsResponse{}) { return errors.New("Song: Lyrics request returned empty struct") } + zap.S().Info(res) + if res.SyncedLyrics != "" { + // Synced lyrics ? track.LyricsType = "synced" track.Lyrics = res.SyncedLyrics - } else { + } else if res.PlainLyrics != "" { + // Plain lyrics ? track.LyricsType = "plain" track.Lyrics = res.PlainLyrics + } else if res.Instrumental { + // Instrumental ? + track.LyricsType = "instrumental" + track.Lyrics = "" } return nil diff --git a/internal/pkg/tap/tap.go b/internal/pkg/tap/tap.go index 808f8d0..d629566 100644 --- a/internal/pkg/tap/tap.go +++ b/internal/pkg/tap/tap.go @@ -101,7 +101,7 @@ func (t *Tap) adjustCategories(orders []orderResponseItem) { order.ProductCategory = "Other" case "beverages": // Atm only beverages get special categories - if strings.Contains(order.ProductName, "Mate") { + if strings.Contains(order.ProductName, "Mate") || strings.Contains(order.ProductName, "Mio Mio") { order.ProductCategory = "Mate" } else if slices.ContainsFunc(t.beers, func(beer string) bool { return strings.Contains(order.ProductName, beer) }) { order.ProductCategory = "Beer" 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/components/stopwatch/stopwatch.go b/tui/components/stopwatch/stopwatch.go index 6bf702b..0d47840 100644 --- a/tui/components/stopwatch/stopwatch.go +++ b/tui/components/stopwatch/stopwatch.go @@ -92,11 +92,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { case StartStopMsg: if msg.running { // Start - if m.running { - // Already running - return m, nil - } - m.id = nextID() m.duration = msg.startDuration m.running = true diff --git a/tui/screen/cammie/cammie.go b/tui/screen/cammie/cammie.go index bbb1ee3..dd7fa35 100644 --- a/tui/screen/cammie/cammie.go +++ b/tui/screen/cammie/cammie.go @@ -98,20 +98,20 @@ func (c *Cammie) View() string { // Render top // Render tabs - var topTabs []string + var tabs []string for i, view := range c.top { if i == c.indexTop { - topTabs = append(topTabs, sActiveTab.Render(view.Name())) + tabs = append(tabs, sActiveTab.Render(view.Name())) } else { - topTabs = append(topTabs, sTabNormal.Render(view.Name())) + tabs = append(tabs, sTabNormal.Render(view.Name())) } } - topTab := lipgloss.JoinHorizontal(lipgloss.Bottom, topTabs...) - topTabsLine := sTabNormal.Render(strings.Repeat(" ", max(0, (c.width/2)-lipgloss.Width(topTab)-10))) - topTab = lipgloss.JoinHorizontal(lipgloss.Bottom, topTab, topTabsLine) + tab := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...) + tabLine := sTabNormal.Render(strings.Repeat(" ", max(0, sTop.GetWidth()-lipgloss.Width(tab)-2))) // -2 comes from sTab padding + tab = lipgloss.JoinHorizontal(lipgloss.Bottom, tab, tabLine) // Render top view - top := lipgloss.JoinVertical(lipgloss.Left, topTab, c.top[c.indexTop].View()) + top := lipgloss.JoinVertical(lipgloss.Left, tab, c.top[c.indexTop].View()) top = sTop.Render(top) // Render bottom @@ -144,18 +144,11 @@ func (c *Cammie) GetUpdateViews() []view.UpdateData { 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} + sizes[c.messages.Name()] = view.Size{Width: sMsg.GetWidth(), Height: sMsg.GetHeight()} + sizes[c.bottom.Name()] = view.Size{Width: sBottom.GetWidth(), Height: sBottom.GetHeight()} for _, top := range c.top { - topW := sTop.GetWidth() - topH := sTop.GetHeight() - sizes[top.Name()] = view.Size{Width: topW, Height: topH} + sizes[top.Name()] = view.Size{Width: sTop.GetWidth(), Height: sTop.GetHeight() - view.GetOuterHeight(sTop) - view.GetOuterHeight(sTab)} } return view.MsgSize{Sizes: sizes} diff --git a/tui/screen/song/song.go b/tui/screen/song/song.go index 57c42ac..8e6608b 100644 --- a/tui/screen/song/song.go +++ b/tui/screen/song/song.go @@ -11,8 +11,9 @@ import ( // Song represents the song screen type Song struct { - db *db.DB - song view.View + db *db.DB + song view.View + width int height int } diff --git a/tui/view/event/event.go b/tui/view/event/event.go index 755ad70..ad7e32e 100644 --- a/tui/view/event/event.go +++ b/tui/view/event/event.go @@ -24,6 +24,9 @@ type Model struct { passed []dto.Event upcoming []dto.Event today *dto.Event + + width int + height int } // Msg represents the message to update the event view @@ -51,6 +54,20 @@ func (m *Model) Name() string { // Update updates the event model view 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 + + m.updateStyles() + } + + return m, nil + case Msg: m.passed = msg.passed m.upcoming = msg.upcoming @@ -66,7 +83,7 @@ func (m *Model) View() string { return m.viewToday() } - return m.viewNormal() + return m.viewOverview() } // GetUpdateDatas returns all the update function for the event model diff --git a/tui/view/event/style.go b/tui/view/event/style.go index bfbfbc9..87a0f71 100644 --- a/tui/view/event/style.go +++ b/tui/view/event/style.go @@ -1,27 +1,8 @@ package event -import "github.com/charmbracelet/lipgloss" - -// Widths -var ( - widthToday = 45 - widthImage = 32 - - widthOverview = 45 - widthOverviewName = 35 - widthOverviewImage = 32 -) - -// Base -var ( - base = lipgloss.NewStyle() - baseToday = base.Width(widthToday).Align(lipgloss.Center) -) - -// Margins -var ( - mTodayWarning = 3 - mOverview = 5 +import ( + "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/tui/view" ) // Color @@ -32,26 +13,80 @@ var ( cUpcoming = lipgloss.Color("#FFBF00") ) -// Styles today +// Base style +var base = lipgloss.NewStyle() + +// Styles for overview var ( - sTodayWarning = baseToday.Bold(true).Blink(true).Foreground(cWarning).Border(lipgloss.DoubleBorder(), true, false) - sTodayName = baseToday.Bold(true).Foreground(cZeus).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder) - sTodayTime = baseToday - sTodayPlace = baseToday.Italic(true).Faint(true) - sToday = baseToday.MarginLeft(8).AlignVertical(lipgloss.Center) + wOvDate = 8 // Width of the date, for example '13/11' (with some padding after) + wOvTextMin = 20 // Minimum width of the event name + wOvPoster = 20 // Width of the poster + wOvGap = 2 // Width of the gap between the overview and the poster + + sOvAll = base.Padding(0, 1) // Style for the overview and the poster + sOvPoster = base.AlignVertical(lipgloss.Center) + sOv = base.AlignVertical(lipgloss.Center).MarginRight(wOvGap) // Style for the overview of the events + sOvTitle = base.Bold(true).Foreground(cWarning).Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder) + + // Styles for passed events + sOvPassedDate = base.Width(wOvDate).Faint(true) + sOvPassedText = base.Foreground(cZeus).Faint(true) + + // Styles for next event + sOvNextDate = base.Width(wOvDate).Bold(true) + sOvNextText = base.Bold(true).Foreground(cZeus) + sOvNextLoc = base.Italic(true) + + // Styles for the upcoming envets + sOvUpcomingDate = base.Width(wOvDate).Faint(true) + sOvUpcomingText = base.Foreground(cUpcoming) + sOvUpcomingLoc = base.Italic(true).Faint(true) ) -// Styles overview +// Styles for today var ( - sOverviewTotal = base.AlignVertical(lipgloss.Center) - sOverviewTitle = base.Bold(true).Foreground(cWarning).Width(widthOverview).Align(lipgloss.Center) - sOverview = base.Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(cBorder).Width(widthOverview).MarginRight(mOverview) - sPassedName = base.Foreground(cZeus).Faint(true).Width(widthOverviewName) - sPassedTime = base.Faint(true) - sNextName = base.Bold(true).Foreground(cZeus).Width(widthOverviewName) - sNextTime = base.Bold(true) - sNextPlace = base.Italic(true).Width(widthOverviewName) - sUpcomingName = base.Width(widthOverviewName).Foreground(cUpcoming) - sUpcomingTime = base.Faint(true) - sUpcomingPlace = base.Italic(true).Faint(true).Width(widthOverviewName) + wTodayEvMin = 20 // Minimum width of the event + wTodayPoster = 20 // Width of the poster + wTodayGap = 2 // Width of the gap between the text and the poster + + sTodayAll = base.Padding(0, 1) // Style for the text and the poster + sTodayPoster = base.AlignVertical(lipgloss.Center) + sToday = base.AlignVertical(lipgloss.Center).MarginLeft(wOvGap).Padding(1, 0).Border(lipgloss.DoubleBorder(), true, false) // Style for the event + + sTodayDate = base.Align(lipgloss.Center) + sTodayText = base.Align(lipgloss.Center).Bold(true).Foreground(cZeus).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder) + sTodayeLoc = base.Align(lipgloss.Center).Italic(true).Faint(true) ) + +func (m *Model) updateStyles() { + // Adjust the styles for the overview + wOvPoster = (m.width - wOvGap - view.GetOuterWidth(sOvAll)) / 2 + if wOvPoster <= wOvDate+wOvTextMin { + // Screen is too small, don't draw the poster for more space + wOvPoster = 0 + } + + wOv := wOvPoster + wOvText := wOv - wOvDate + + sOv = sOv.Width(wOv) + sOvTitle = sOvTitle.Width(wOv) + sOvPassedText = sOvPassedText.Width(wOvText) + sOvNextText = sOvNextText.Width(wOvText) + sOvNextLoc = sOvNextLoc.Width(wOvText) + sOvUpcomingText = sOvUpcomingText.Width(wOvText) + sOvUpcomingLoc = sOvUpcomingLoc.Width(wOvText) + + // Adjust the styles for today + wTodayPoster = (m.width - wTodayGap - view.GetOuterWidth(sTodayAll)) / 2 + if wTodayPoster <= wTodayEvMin { + // Screen is too small, don't draw the poster for more space + wTodayPoster = 0 + } + + wTodayEv := wTodayPoster + + sTodayDate = sTodayDate.Width(wTodayEv) + sTodayText = sTodayText.Width(wTodayEv) + sTodayeLoc = sTodayeLoc.Width(wTodayEv) +} diff --git a/tui/view/event/view.go b/tui/view/event/view.go index 7d3a16c..94e3816 100644 --- a/tui/view/event/view.go +++ b/tui/view/event/view.go @@ -10,107 +10,100 @@ import ( func (m *Model) viewToday() string { // Render image - im := "" + poster := "" if m.today.Poster != nil { i, _, err := image.Decode(bytes.NewReader(m.today.Poster)) if err == nil { - im = view.ImagetoString(widthImage, i) + poster = view.ImagetoString(wTodayPoster, i) } } - // Render text - warningTop := sTodayWarning.MarginBottom(mTodayWarning).Render("🥳 Event Today 🥳") - warningBottom := sTodayWarning.MarginTop(mTodayWarning).Render("🥳 Event Today 🥳") + name := sTodayText.Render(m.today.Name) + date := sTodayDate.Render("🕙 " + m.today.Date.Format("15:04")) + location := sTodayeLoc.Render("📍 " + m.today.Location) - name := sTodayName.Render(m.today.Name) - time := sTodayTime.Render("🕙 " + m.today.Date.Format("15:04")) - location := sTodayPlace.Render("📍 " + m.today.Location) + event := lipgloss.JoinVertical(lipgloss.Left, name, date, location) + event = sToday.Render(event) - text := lipgloss.JoinVertical(lipgloss.Left, warningTop, name, time, location, warningBottom) - - // Resize so it's centered - if lipgloss.Height(im) > lipgloss.Height(text) { - sToday = sToday.Height(lipgloss.Height(im)) + if lipgloss.Height(poster) > lipgloss.Height(event) { + event = sTodayPoster.Height(lipgloss.Height(poster)).Render(event) + } else { + poster = sTodayPoster.Height(lipgloss.Height(event)).Render(poster) } - text = sToday.Render(text) - return lipgloss.JoinHorizontal(lipgloss.Top, im, text) + view := lipgloss.JoinHorizontal(lipgloss.Top, poster, event) + + return sTodayAll.Render(view) } -func (m *Model) viewNormal() string { +func (m *Model) viewOverview() string { // Poster if present - im := "" + poster := "" if len(m.upcoming) > 0 && m.upcoming[0].Poster != nil { i, _, err := image.Decode(bytes.NewReader(m.upcoming[0].Poster)) if err == nil { - im = view.ImagetoString(widthOverviewImage, i) + poster = view.ImagetoString(wOvPoster, i) } } // Overview - events := m.viewGetEvents() - - // Filthy hack to avoid the last event being centered by the cammie screen - events = append(events, "\n") + events := m.viewGetEventOverview() - // Render events overview - overview := lipgloss.JoinVertical(lipgloss.Left, events...) - overview = sOverview.Render(overview) - - title := sOverviewTitle.Render("Events") - overview = lipgloss.JoinVertical(lipgloss.Left, title, overview) - - // Center the overview - if lipgloss.Height(im) > lipgloss.Height(overview) { - overview = sOverviewTotal.Height(lipgloss.Height(im)).Render(overview) + if lipgloss.Height(poster) > lipgloss.Height(events) { + events = sOv.Height(lipgloss.Height(poster)).Render(events) + } else { + poster = sOvPoster.Height(lipgloss.Height(events)).Render(poster) } // Combine image and overview - view := lipgloss.JoinHorizontal(lipgloss.Top, overview, im) + view := lipgloss.JoinHorizontal(lipgloss.Top, events, poster) - return view + return sOvAll.Render(view) } -func (m *Model) viewGetEvents() []string { - events := make([]string, 0, len(m.passed)+len(m.upcoming)) +func (m *Model) viewGetEventOverview() string { + events := make([]string, 0, len(m.passed)+len(m.upcoming)+1) + + title := sOvTitle.Render("Events") + events = append(events, title) // Passed for _, event := range m.passed { - time := sPassedTime.Render(event.Date.Format("02/01") + "\t") - name := sPassedName.Render(event.Name) - text := lipgloss.JoinHorizontal(lipgloss.Top, time, name) + date := sOvPassedDate.Render(event.Date.Format("02/01")) + name := sOvPassedText.Render(event.Name) + text := lipgloss.JoinHorizontal(lipgloss.Top, date, name) events = append(events, text) } - if len(m.upcoming) == 0 { - return events - } - - // Next - name := sNextName.Render(m.upcoming[0].Name) - time := sNextTime.Render(m.upcoming[0].Date.Format("02/01") + "\t") - location := sNextPlace.Render("📍 " + m.upcoming[0].Location) + if len(m.upcoming) > 0 { + // Next + date := sOvNextDate.Render(m.upcoming[0].Date.Format("02/01")) + name := sOvNextText.Render(m.upcoming[0].Name) + location := sOvNextLoc.Render("📍 " + m.upcoming[0].Location) - text := lipgloss.JoinVertical(lipgloss.Left, name, location) - text = lipgloss.JoinHorizontal(lipgloss.Top, time, text) + text := lipgloss.JoinVertical(lipgloss.Left, name, location) + text = lipgloss.JoinHorizontal(lipgloss.Top, date, text) - events = append(events, text) + events = append(events, text) + } // Upcoming for i := 1; i < len(m.upcoming); i++ { - time := sUpcomingTime.Render(m.upcoming[i].Date.Format("02/01") + "\t") - name := sUpcomingName.Render(m.upcoming[i].Name) + date := sOvUpcomingDate.Render(m.upcoming[i].Date.Format("02/01")) + name := sOvUpcomingText.Render(m.upcoming[i].Name) text := name if i < 3 { - location := sUpcomingPlace.Render("📍 " + m.upcoming[i].Location) + location := sOvNextLoc.Render("📍 " + m.upcoming[i].Location) text = lipgloss.JoinVertical(lipgloss.Left, name, location) } - text = lipgloss.JoinHorizontal(lipgloss.Top, time, text) + text = lipgloss.JoinHorizontal(lipgloss.Top, date, text) events = append(events, text) } - return events + view := lipgloss.JoinVertical(lipgloss.Left, events...) + + return sOv.Render(view) } diff --git a/tui/view/gamification/gamification.go b/tui/view/gamification/gamification.go index d47bec6..c19afb0 100644 --- a/tui/view/gamification/gamification.go +++ b/tui/view/gamification/gamification.go @@ -21,6 +21,9 @@ import ( type Model struct { db *db.DB leaderboard []gamificationItem + + width int + height int } type gamificationItem struct { @@ -51,6 +54,20 @@ func (m *Model) Name() string { // Update updates the gamification view 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 + + m.updateStyles() + } + + return m, nil + case Msg: m.leaderboard = msg.leaderboard } @@ -62,21 +79,19 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { func (m *Model) View() string { columns := make([]string, 0, len(m.leaderboard)) - positions := []lipgloss.Style{sFirst, sSecond, sThird, sFourth} - for i, item := range m.leaderboard { user := lipgloss.JoinVertical(lipgloss.Left, - positions[i].Render(fmt.Sprintf("%d. %s", i+1, item.item.Name)), + positions[i].Inherit(sName).Render(fmt.Sprintf("%d. %s", i+1, item.item.Name)), sScore.Render(strconv.Itoa(int(item.item.Score))), ) - column := lipgloss.JoinVertical(lipgloss.Left, view.ImagetoString(width, item.image), user) + column := lipgloss.JoinVertical(lipgloss.Left, view.ImagetoString(wAvatar, item.image), user) columns = append(columns, sColumn.Render(column)) } list := lipgloss.JoinHorizontal(lipgloss.Top, columns...) - return list + return sAll.Render(list) } // GetUpdateDatas get all update functions for the gamification view diff --git a/tui/view/gamification/styles.go b/tui/view/gamification/styles.go index 9b5ce4e..cbc3d5c 100644 --- a/tui/view/gamification/styles.go +++ b/tui/view/gamification/styles.go @@ -1,9 +1,9 @@ package gamification -import "github.com/charmbracelet/lipgloss" - -var base = lipgloss.NewStyle() -var width = 20 +import ( + "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/tui/view" +) // Colors var ( @@ -13,17 +13,37 @@ var ( cBorder = lipgloss.Color("#383838") ) +// Base style +var base = lipgloss.NewStyle() + +// All style +var sAll = base.Align(lipgloss.Center) + // Styles var ( - sName = base.BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder).Width(width).Align(lipgloss.Center) - sScore = base.Width(width).Align(lipgloss.Center) - sColumn = base.MarginRight(4) -) + wAvatar = 20 // Width of an avatar + wAmount = 4 // Amount of people that are shown -// Positions -var ( - sFirst = sName.Foreground(cGold) - sSecond = sName.Foreground(cZeus) - sThird = sName.Foreground(cBronze) - sFourth = sName + sColumn = base.Margin(2) + sName = base.Align(lipgloss.Center) + sScore = base.Align(lipgloss.Center) ) + +// Styles for the positions +var positions = []lipgloss.Style{ + base.Foreground(cGold), + base.Foreground(cZeus), + base.Foreground(cBronze), + base, +} + +func (m *Model) updateStyles() { + // Adjust all style + sAll = sAll.Width(m.width).Height(m.height).MaxHeight(m.height) + + // Adjust styles + wAvatar = (sAll.GetWidth() - view.GetOuterWidth(sAll) - view.GetOuterWidth(sColumn)*wAmount) / wAmount + + sName = sName.Width(wAvatar).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder) + sScore = sScore.Width(wAvatar) +} diff --git a/tui/view/message/message.go b/tui/view/message/message.go index abe6704..c87ed12 100644 --- a/tui/view/message/message.go +++ b/tui/view/message/message.go @@ -15,11 +15,12 @@ import ( // Model represents the model for the message view type Model struct { - width int - height int db *db.DB lastMessageID int32 messages []message + + width int + height int } type message struct { diff --git a/tui/view/song/song.go b/tui/view/song/song.go index 68496f3..84eb01d 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,71 @@ 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 } - if !equalTopGenres(m.topGenres.entries, genres) { - msg.topGenres = topStatSqlcGenre(genres) - change = true + 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 + } + + 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 +371,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) - if !change { - return nil, nil + // 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 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 +414,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 +424,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..67f987b 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,77 @@ 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 // Supports up to 1000 + 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) + + // Specific styles for when no song is playing + sStatCategory = base.Align(lipgloss.Center) + sStatCategoryTitle = base.Foreground(cZeus).Align(lipgloss.Center).Border(lipgloss.NormalBorder(), true, false).BorderForeground(cBorder) + sStatHistory = base.MarginRight(1).PaddingRight(2).Border(lipgloss.ThickBorder(), false, true, false, false).BorderForeground(cBorder) ) -// 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.MarginTop(1) + 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) + } + sStatCategory = sStatCategory.Width(2 * (sStatOne.GetWidth() + view.GetOuterWidth(sStatOne))) + sStatCategoryTitle = sStatCategoryTitle.Width(2*sStatOne.GetWidth() + view.GetOuterWidth(sStatOne)) + + // 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..058ddf7 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,59 +78,85 @@ 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.viewStatPlaying(m.history)) + columns = append(columns, m.viewStatPlaying(m.statsMonthly[0])) + columns = append(columns, m.viewStatPlaying(m.statsMonthly[1])) + columns = append(columns, m.viewStatPlaying(m.statsMonthly[2])) return lipgloss.JoinHorizontal(lipgloss.Top, columns...) } func (m *Model) viewNotPlaying() string { - rows := make([][]string, 0, 2) - for i := 0; i < 2; i++ { + // Render stats + rows := make([][]string, 0, 3) + for i := 0; i < 3; i++ { 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.viewStatPlaying(m.statsMonthly[0], "Monthly")) + rows[0] = append(rows[0], m.viewStatPlaying(m.stats[0], "All Time")) + rows[1] = append(rows[1], m.viewStatPlaying(m.statsMonthly[1], "Monthly")) + rows[1] = append(rows[1], m.viewStatPlaying(m.stats[1], "All Time")) + rows[2] = append(rows[2], m.viewStatPlaying(m.statsMonthly[2], "Monthly")) + rows[2] = append(rows[2], m.viewStatPlaying(m.stats[2], "All Time")) + + renderedRows := make([]string, 0, 3) + var title string + for i, row := range rows { + r := lipgloss.JoinHorizontal(lipgloss.Top, row...) + title = sStatCategory.Render(sStatCategoryTitle.Render(m.stats[i].title)) // HACK: Make border same size as 2 stats next to each other + renderedRows = append(renderedRows, lipgloss.JoinVertical(lipgloss.Left, title, r)) + } + + v := lipgloss.JoinVertical(lipgloss.Left, renderedRows...) + + // Render history + items := make([]string, 0, len(m.history.entries)) - renderedRows := make([]string, 0, 2) - for _, row := range rows { - renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, row...)) + // Push it down + for range lipgloss.Height(title) { + items = append(items, "") } + items = append(items, sStatTitle.Render(m.history.title)) - view := lipgloss.JoinVertical(lipgloss.Left, renderedRows...) + for i, entry := range m.history.entries { + enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) + body := sStatEntry.Render(entry.name) + amount := sStatAmount.Render(fmt.Sprintf("%d", entry.amount)) + items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, enum, body, amount)) + } + items = append(items, "") // HACK: Avoid the last item shifting to the right + list := lipgloss.JoinVertical(lipgloss.Left, items...) + // title := sStatTitle.Render(m.history.title) + history := sStatHistory.Height(lipgloss.Height(v) - 1).MaxHeight(lipgloss.Height(v) - 1).Render(list) // - 1 to compensate for the hack newline at the end - return sAll.Render(view) + v = lipgloss.JoinHorizontal(lipgloss.Top, history, v) + + return sAll.Render(v) } -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)) +func (m *Model) viewStatPlaying(stat stat, titleOpt ...string) string { + title := stat.title + if len(titleOpt) > 0 { + title = titleOpt[0] } - l := lipgloss.JoinVertical(lipgloss.Left, items...) - title := sStatTitle.Render("Recently Played") - return sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l)) -} + items := make([]string, 0, len(stat.entries)) + for i := range stat.entries { + if i >= 10 { + break + } + + enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) + body := sStatEntry.Render(stat.entries[i].name) + amount := sStatAmount.Render(fmt.Sprintf("%d", stat.entries[i].amount)) -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) - 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, body, amount)) } + items = append(items, "") // HACK: Avoid the last item shifting to the right l := lipgloss.JoinVertical(lipgloss.Left, items...) - title := sStatTitle.Render(topStat.title) + t := sStatTitle.Render(title) - return sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l)) + return sStatOne.Render(lipgloss.JoinVertical(lipgloss.Left, t, l)) } diff --git a/tui/view/tap/style.go b/tui/view/tap/style.go index d72b0e4..f51e442 100644 --- a/tui/view/tap/style.go +++ b/tui/view/tap/style.go @@ -1,23 +1,8 @@ package tap -import "github.com/charmbracelet/lipgloss" - -var base = lipgloss.NewStyle() - -// Width -var ( - widthAmount = 5 - widthCategory = 8 - widthLast = 13 -) - -// Margin -var mStats = 2 - -// Barchart -var ( - widthBar = 40 - heightBar = 20 +import ( + "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/tui/view" ) // Colors @@ -31,20 +16,62 @@ var ( cStatsTitle = lipgloss.Color("#EE4B2B") ) -// Styles Chart +// Base style +var base = lipgloss.NewStyle() + +// Styles for the barchart var ( - sMate = base.Foreground(cMate) - sSoft = base.Foreground(cSoft) - sBeer = base.Foreground(cBeer) - sFood = base.Foreground(cFood) - sUnknown = base + // Widths + wBarGap = 1 // Gap between bars + wBar = 5 // Width of bar, gets dynamically adjusted + wBarMin = 4 // Minimum width required for the bar label + wBarAmount = len(categoryToStyle) // Amount of bars. Is the same as the amount of categories + + sBar = base.MarginBottom(1) + sBarOne = base + sBarLabel = base.Align(lipgloss.Center) +) + +// Styles for the stats +var ( + // Widths + wStatAmount = 5 // Supports up to 9999 with a space after it (or 99999 without a space) + wStatCategory = 4 // Longest label is 4 chars + wStatLast = 11 // 11 characters, for example '18:53 20/12' + wStatGapMin = 3 // Minimum gap size between the category and last purchase + + sStat = base.BorderStyle(lipgloss.ThickBorder()).BorderForeground(cBorder).BorderLeft(true).Margin(0, 1, 1, 1).PaddingLeft(1) + sStatTitle = base.Foreground(cStatsTitle).Bold(true).BorderStyle(lipgloss.NormalBorder()).BorderForeground(cBorder).BorderBottom(true).Align(lipgloss.Center).MarginBottom(1) + sStatAmount = base.Width(wStatAmount).Bold(true) + sStatCategory = base.Width(wStatCategory) + sStatLast = base.Width(wStatLast).Align(lipgloss.Right).Italic(true).Faint(true) ) -// Styles stats +// Styles for the different categories var ( - sStats = base.Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(cBorder).MarginLeft(mStats).PaddingLeft(mStats) - sStatsTitle = base.Foreground(cStatsTitle).Bold(true).Width(widthAmount+widthCategory+widthLast).Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(cBorder) - sStatsAmount = base.Width(widthAmount).Bold(true) - sStatsCategory = base.Width(widthCategory) - sStatsLast = base.Width(widthLast).Align(lipgloss.Right).Italic(true).Faint(true) + sMate = base.Foreground(cMate) + sSoft = base.Foreground(cSoft) + sBeer = base.Foreground(cBeer) + sFood = base.Foreground(cFood) ) + +func (m *Model) updateStyles() { + wStatWithoutGap := wStatAmount + wStatCategory + wStatLast + view.GetOuterWidth(sStat) + wBar = (m.width - wStatWithoutGap - wStatGapMin - wBarAmount*wBarGap) / wBarAmount + if wBar < wBarMin { + // Screen too small + return + } + + // Adjust bar styles + sBar = sBar.Width(wBarAmount*wBar + view.GetOuterWidth(sBar)).Height(m.height - view.GetOuterHeight(sBar)) + sBarLabel = sBarLabel.Width(wBar) + + // Adjust stat styles + wStatGap := m.width - wStatWithoutGap - (wBarAmount * wBar) + wStat := wStatAmount + wStatCategory + wStatGap + wStatLast + + sStat = sStat.Width(wStat + view.GetOuterWidth(sStat)).Height(m.height - view.GetOuterHeight(sStat)).MaxHeight(m.height - view.GetOuterHeight(sStat)) + sStatTitle = sStatTitle.Width(wStat) + sStatCategory = sStatCategory.Width(wStatCategory + wStatGap) +} diff --git a/tui/view/tap/tap.go b/tui/view/tap/tap.go index 499b030..814a33c 100644 --- a/tui/view/tap/tap.go +++ b/tui/view/tap/tap.go @@ -35,6 +35,9 @@ type Model struct { db *db.DB lastOrderID int32 items []tapItem + + width int + height int } // Msg represents a tap message @@ -67,6 +70,20 @@ func (m *Model) Name() string { // Update updates the tap model 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 + + m.updateStyles() + } + + return m, nil + case Msg: m.lastOrderID = msg.lastOrderID @@ -102,9 +119,6 @@ func (m *Model) View() string { chart := m.viewChart() stats := m.viewStats() - // Give them same height - stats = sStats.Height(lipgloss.Height(chart)).Render(stats) - // Join them together view := lipgloss.JoinHorizontal(lipgloss.Top, chart, stats) return view diff --git a/tui/view/tap/view.go b/tui/view/tap/view.go index 65433ae..d5236f8 100644 --- a/tui/view/tap/view.go +++ b/tui/view/tap/view.go @@ -8,21 +8,21 @@ import ( ) func (m *Model) viewChart() string { - chart := barchart.New(widthBar, heightBar) + chart := barchart.New(sBar.GetWidth(), sBar.GetHeight(), barchart.WithNoAutoBarWidth(), barchart.WithBarGap(wBarGap), barchart.WithBarWidth(wBar)) bars := make([]barchart.BarData, 0, len(m.items)) for _, item := range m.items { style, ok := categoryToStyle[item.category] if !ok { - style = sUnknown + continue } bars = append(bars, barchart.BarData{ - Label: string(item.category), + Label: sBarLabel.Render(string(item.category)), Values: []barchart.BarValue{{ Name: string(item.category), Value: float64(item.amount), - Style: style, + Style: style.Inherit(sBarOne), }}, }) } @@ -37,9 +37,9 @@ func (m *Model) viewStats() string { rows := make([]string, 0, len(m.items)) for _, item := range m.items { - amount := sStatsAmount.Render(strconv.Itoa(item.amount)) - category := sStatsCategory.Inherit(categoryToStyle[item.category]).Render(string(item.category)) - last := sStatsLast.Render(item.last.Format("15:04 02/01")) + amount := sStatAmount.Render(strconv.Itoa(item.amount)) + category := sStatCategory.Inherit(categoryToStyle[item.category]).Render(string(item.category)) + last := sStatLast.Render(item.last.Format("15:04 02/01")) text := lipgloss.JoinHorizontal(lipgloss.Top, amount, category, last) rows = append(rows, text) @@ -48,8 +48,8 @@ func (m *Model) viewStats() string { view := lipgloss.JoinVertical(lipgloss.Left, rows...) // Add title - title := sStatsTitle.Render("Leaderboard") + title := sStatTitle.Render("Leaderboard") view = lipgloss.JoinVertical(lipgloss.Left, title, view) - return view + return sStat.Render(view) } diff --git a/tui/view/zess/style.go b/tui/view/zess/style.go index fe6f3ad..790326c 100644 --- a/tui/view/zess/style.go +++ b/tui/view/zess/style.go @@ -1,32 +1,51 @@ package zess -import "github.com/charmbracelet/lipgloss" - -var base = lipgloss.NewStyle() +import ( + "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/tui/view" +) -// Width +// Colors var ( - widthAmount = 5 - widthWeek = 8 + cBorder = lipgloss.Color("#383838") + cZeus = lipgloss.Color("#FF7F00") + cStatsTitle = lipgloss.Color("#EE4B2B") ) -// Margin -var mOverview = 2 +// Base style +var base = lipgloss.NewStyle() -// Barchart +// Styles for the barchart var ( - widthBar = 60 - heightBar = 20 + // Widths + wBarGap = 1 // Gap between bars + wBar = 5 // Width of a bar, gets dynamically adjusted + wBarMin = 3 // Minimum width required for the bar label, for example 'W56' + wBarAmountMax = 10 // Maximum amount of bars + + sBar = base.MarginBottom(1) + sBarOne = base + sBarLabel = base.Align(lipgloss.Center) ) -// Colors +// Styles for the stats var ( - cBorder = lipgloss.Color("#383838") - cZeus = lipgloss.Color("#FF7F00") - cStatsTitle = lipgloss.Color("#EE4B2B") + // Widths + wStatDate = 11 // 11 characters, for example 'W56 - 29/12' + wStatAmount = 4 // Supports up to 9999 + wStatGapMin = 3 // Minimum gap size between the date and amount + + sStat = base.BorderStyle(lipgloss.ThickBorder()).BorderForeground(cBorder).BorderLeft(true).Margin(0, 1, 1, 1).PaddingLeft(1) //.Align(lipgloss.Center) + sStatTitle = base.Foreground(cStatsTitle).Bold(true).BorderStyle(lipgloss.NormalBorder()).BorderForeground(cBorder).BorderBottom(true).Align(lipgloss.Center).MarginBottom(1) + sStatDate = base.Width(wStatDate) + sStatAmount = base.Width(wStatAmount).Align(lipgloss.Right) + sStatTotal = base.BorderStyle(lipgloss.NormalBorder()).BorderForeground(cBorder).BorderTop(true).MarginTop(1) + sStatTotalTitle = sStatDate.Bold(true) + sStatTotalAmount = sStatAmount.Bold(true) + sStatMax = base.Foreground(cZeus).Bold(true) ) -// Message colors +// Bar colors var colors = []string{ "#FAF500", // Yellow "#3AFA00", // Green @@ -51,19 +70,31 @@ var colors = []string{ "#B3D2F9", // Boring Blue } -// Styles chart -var ( - sBar = base -) +// updateStyles updates all the affected styles when a size update message is received +func (m *Model) updateStyles() { + wStatWithoutGap := wStatDate + wStatAmount + view.GetOuterWidth(sStat) + if m.width-wStatWithoutGap-wStatGapMin < 0 { + // Screen is too small + // Avoid entering an infinite loop down below + return + } -// Styles stats -var ( - sStats = base.Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(cBorder).MarginLeft(mOverview).PaddingLeft(mOverview) - sStatsTitle = base.Foreground(cStatsTitle).Bold(true).Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(cBorder).Width(widthAmount + widthWeek).Align(lipgloss.Center) - sStatsWeek = base.Width(widthWeek) - sStatsAmount = base.Bold(true).Width(widthAmount).Align(lipgloss.Right) - sStatsAmountMax = sStatsAmount.Foreground(cZeus) - sStatsTotal = base.Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(cBorder).MarginTop(1) - sStatsTotalTitle = sStatsWeek - sStatsTotalAmount = sStatsAmount -) + // Adjust bar styles + wBar = wBarMin + for (m.width-wStatWithoutGap-wStatGapMin-wBarAmountMax*wBarGap)/wBar >= wBarAmountMax { + wBar++ + } + bars := (m.width - wStatWithoutGap - wStatGapMin) / wBar + sBar = sBar.Width(bars*wBar + view.GetOuterWidth(sBar)).Height(m.height - view.GetOuterHeight(sBar)) + sBarLabel = sBarLabel.Width(wBar) + + // Adjust stat styles + wStatGap := m.width - wStatWithoutGap - (bars * wBar) + wStat := wStatDate + wStatGap + wStatAmount + + sStat = sStat.Width(wStat + view.GetOuterWidth(sStat)).Height(m.height - view.GetOuterHeight(sStat)).MaxHeight(m.height - view.GetOuterHeight(sStat)) + sStatTitle = sStatTitle.Width(wStat) + sStatDate = sStatDate.Width(wStatDate + wStatGap) + sStatTotal = sStatTotal.Width(sStatTitle.GetWidth()) + sStatTotalTitle = sStatTotalTitle.Width(sStatDate.GetWidth()) +} diff --git a/tui/view/zess/view.go b/tui/view/zess/view.go index 6828146..54e0938 100644 --- a/tui/view/zess/view.go +++ b/tui/view/zess/view.go @@ -1,6 +1,7 @@ package zess import ( + "fmt" "strconv" "github.com/NimbleMarkets/ntcharts/barchart" @@ -8,15 +9,15 @@ import ( ) func (m *Model) viewChart() string { - chart := barchart.New(widthBar, heightBar) + chart := barchart.New(sBar.GetWidth(), sBar.GetHeight(), barchart.WithNoAutoBarWidth(), barchart.WithBarGap(wBarGap), barchart.WithBarWidth(wBar)) for _, scan := range m.scans { bar := barchart.BarData{ - Label: scan.label, + Label: sBarLabel.Render(fmt.Sprintf("W%d", scan.time.week)), Values: []barchart.BarValue{{ - Name: scan.label, + Name: scan.start, Value: float64(scan.amount), - Style: sBar.Foreground(lipgloss.Color(scan.color)), + Style: sBarOne.Foreground(lipgloss.Color(scan.color)), }}, } @@ -33,13 +34,13 @@ func (m *Model) viewStats() string { rows := make([]string, 0, len(m.scans)) for _, scan := range m.scans { - week := sStatsWeek.Render(scan.label) + week := sStatDate.Render(fmt.Sprintf("W%d - %s", scan.time.week, scan.start)) var amount string if scan.amount == m.maxWeekScans { - amount = sStatsAmountMax.Render(strconv.Itoa(int(scan.amount))) + amount = sStatMax.Inherit(sStatAmount).Render(strconv.Itoa(int(scan.amount))) } else { - amount = sStatsAmount.Render(strconv.Itoa(int(scan.amount))) + amount = sStatAmount.Render(strconv.Itoa(int(scan.amount))) } text := lipgloss.JoinHorizontal(lipgloss.Top, week, amount) @@ -49,15 +50,15 @@ func (m *Model) viewStats() string { view := lipgloss.JoinVertical(lipgloss.Left, rows...) // Title - title := sStatsTitle.Render("Overview") + title := sStatTitle.Render("Overview") // Total scans - total := sStatsTotalTitle.Render("Total") - amount := sStatsTotalAmount.Render(strconv.Itoa(int(m.seasonScans))) + total := sStatTotalTitle.Render("Total") + amount := sStatTotalAmount.Render(strconv.Itoa(int(m.seasonScans))) total = lipgloss.JoinHorizontal(lipgloss.Top, total, amount) - total = sStatsTotal.Render(total) + total = sStatTotal.Render(total) view = lipgloss.JoinVertical(lipgloss.Left, title, view, total) - return view + return sStat.Render(view) } diff --git a/tui/view/zess/zess.go b/tui/view/zess/zess.go index a8d8c9d..1eda17f 100644 --- a/tui/view/zess/zess.go +++ b/tui/view/zess/zess.go @@ -14,7 +14,7 @@ import ( "go.uber.org/zap" ) -// yearWeek represents a yearWeek object by keeping the year and week number +// yearWeek is used to represent a date by it's year and week type yearWeek struct { year int week int @@ -23,7 +23,7 @@ type yearWeek struct { type weekScan struct { time yearWeek amount int64 - label string + start string // The date when the week starts color string } @@ -36,6 +36,9 @@ type Model struct { maxWeekScans int64 currentSeason yearWeek // Start week of the season seasonScans int64 + + width int + height int } // Msg is the base message to indicate that something changed in the zess view @@ -98,6 +101,20 @@ func (m *Model) Name() string { // Update updates the zess model 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 + + m.updateStyles() + } + + return m, nil + // New scan(s) case scanMsg: m.lastScanID = msg.lastScanID @@ -164,13 +181,10 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // View returns the view for the zess model func (m *Model) View() string { chart := m.viewChart() - overview := m.viewStats() - - // Give them the same height - overview = sStats.Height(lipgloss.Height(chart)).Render(overview) + stats := m.viewStats() // Join them together - view := lipgloss.JoinHorizontal(lipgloss.Top, chart, overview) + view := lipgloss.JoinHorizontal(lipgloss.Top, chart, stats) return view } @@ -232,7 +246,7 @@ func updateScans(view view.View) (tea.Msg, error) { zessScanMsg.scans = append(zessScanMsg.scans, weekScan{ time: newTime, amount: 1, - label: newScan.ScanTime.Time.Format("02/01"), + start: newScan.ScanTime.Time.Format("02/01"), color: randomColor(), }) }