fully-functioning music status!
- accessible via /music - only detects strawberry for now
This commit is contained in:
parent
f838edeadb
commit
8a1fabf91a
53 changed files with 923 additions and 51 deletions
58
.air.toml
Normal file
58
.air.toml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
#:schema https://json.schemastore.org/any.json
|
||||||
|
|
||||||
|
env_files = []
|
||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ."
|
||||||
|
delay = 1000
|
||||||
|
entrypoint = ["./tmp/main"]
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
ignore_dangerous_root_dir = false
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html", "css", "js"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
silent = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
app_port = 0
|
||||||
|
app_start_timeout = 0
|
||||||
|
enabled = false
|
||||||
|
proxy_port = 0
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
|
tmp/
|
||||||
learning-title.txt
|
learning-title.txt
|
||||||
|
|
|
||||||
3
go.mod
3
go.mod
|
|
@ -1,4 +1,4 @@
|
||||||
module codeberg.org/arimelody/ari-stream-utils
|
module codeberg.org/arimelody/ari-stream-tools
|
||||||
|
|
||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
|
|
@ -16,6 +16,7 @@ require (
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -24,6 +24,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package learning
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -12,7 +11,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/arimelody/ari-is-learning/broadcast"
|
"codeberg.org/arimelody/ari-stream-tools/broadcast"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -29,7 +28,6 @@ type (
|
||||||
Service struct {
|
Service struct {
|
||||||
cfg ServiceConfig
|
cfg ServiceConfig
|
||||||
|
|
||||||
ctx context.Context
|
|
||||||
titleFileMutex sync.Mutex
|
titleFileMutex sync.Mutex
|
||||||
titleLastModTime time.Time
|
titleLastModTime time.Time
|
||||||
titleText string
|
titleText string
|
||||||
|
|
@ -39,21 +37,25 @@ type (
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(ctx context.Context, cfg ServiceConfig) *Service {
|
func New(ctx context.Context, cfg ServiceConfig) *Service {
|
||||||
|
titleText := []byte{}
|
||||||
titleUpdated := make(chan string)
|
titleUpdated := make(chan string)
|
||||||
|
|
||||||
if _, err := os.Stat(cfg.TitleFilePath); os.IsNotExist(err) {
|
if _, err := os.Stat(cfg.TitleFilePath); os.IsNotExist(err) {
|
||||||
log.Printf("Learning title file [%s] does not exist, creating one...", cfg.TitleFilePath)
|
log.Printf("Learning title file [%s] does not exist, creating one...", cfg.TitleFilePath)
|
||||||
os.WriteFile(cfg.TitleFilePath, []byte("Untitled"), 0644)
|
os.WriteFile(cfg.TitleFilePath, []byte("Untitled"), 0644)
|
||||||
|
titleText = []byte("Untitled")
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
log.Fatalf("Failed to stat title file: %v", err)
|
log.Fatalf("Failed to stat title file: %v", err)
|
||||||
|
} else {
|
||||||
|
titleText, err = os.ReadFile(cfg.TitleFilePath)
|
||||||
|
if err != nil { log.Fatalf("Failed to read title file: %v", err) }
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := &Service{
|
srv := &Service{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
ctx: ctx,
|
|
||||||
titleFileMutex: sync.Mutex{},
|
titleFileMutex: sync.Mutex{},
|
||||||
titleLastModTime: time.Unix(0, 0),
|
titleLastModTime: time.Unix(0, 0),
|
||||||
titleText: "",
|
titleText: string(titleText),
|
||||||
titleUpdated: titleUpdated,
|
titleUpdated: titleUpdated,
|
||||||
titleUpdatedBroadcast: broadcast.NewBroadcastChannel(ctx, titleUpdated),
|
titleUpdatedBroadcast: broadcast.NewBroadcastChannel(ctx, titleUpdated),
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +112,8 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
srv.titleUpdated<-string(data)
|
||||||
|
|
||||||
ctx.String(http.StatusOK, http.StatusText(http.StatusOK))
|
ctx.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -123,45 +127,3 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) {
|
||||||
http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/bye.html")
|
http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/bye.html")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Service) Run(ctx context.Context) {
|
|
||||||
failed := make(chan error)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
srv.titleFileMutex.Lock()
|
|
||||||
stat, err := os.Stat(srv.cfg.TitleFilePath)
|
|
||||||
if err != nil {
|
|
||||||
failed<-fmt.Errorf("Failed to stat title file: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if srv.titleLastModTime.Before(stat.ModTime()) {
|
|
||||||
data, err := os.ReadFile(srv.cfg.TitleFilePath)
|
|
||||||
if err != nil {
|
|
||||||
failed<-fmt.Errorf("Failed to read title file: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf(
|
|
||||||
"title updated: \"%s\" -> \"%s\"",
|
|
||||||
srv.titleText,
|
|
||||||
strings.TrimSpace(string(data)),
|
|
||||||
)
|
|
||||||
|
|
||||||
srv.titleText = strings.TrimSpace(string(data))
|
|
||||||
if !srv.titleLastModTime.IsZero() { srv.titleUpdated<-srv.titleText }
|
|
||||||
srv.titleLastModTime = stat.ModTime()
|
|
||||||
}
|
|
||||||
srv.titleFileMutex.Unlock()
|
|
||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-failed:
|
|
||||||
log.Fatalf("Learning service error: %v", err)
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
7
main.go
7
main.go
|
|
@ -10,7 +10,8 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"codeberg.org/arimelody/ari-stream-utils/learning"
|
"codeberg.org/arimelody/ari-stream-tools/learning"
|
||||||
|
"codeberg.org/arimelody/ari-stream-tools/music"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,11 +42,13 @@ func main() {
|
||||||
learningService := learning.New(ctx, learning.ServiceConfig{
|
learningService := learning.New(ctx, learning.ServiceConfig{
|
||||||
TitleFilePath: "learning-title.txt",
|
TitleFilePath: "learning-title.txt",
|
||||||
})
|
})
|
||||||
|
musicService := music.New(ctx)
|
||||||
|
|
||||||
srv := gin.Default()
|
srv := gin.Default()
|
||||||
learningService.BindRoutes(srv.Group("/learning"))
|
learningService.BindRoutes(srv.Group("/learning"))
|
||||||
|
musicService.BindRoutes(srv.Group("/music"))
|
||||||
|
|
||||||
go learningService.Run(ctx)
|
go musicService.Run(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("Now serving at http://%s:%d\n", host, port)
|
log.Printf("Now serving at http://%s:%d\n", host, port)
|
||||||
failed <- srv.Run(fmt.Sprintf("%s:%d", host, port))
|
failed <- srv.Run(fmt.Sprintf("%s:%d", host, port))
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
package mpris
|
|
||||||
292
music/music.go
Normal file
292
music/music.go
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
package music
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/arimelody/ari-stream-tools/broadcast"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MPRIS_ART_URL = "mpris:artUrl"
|
||||||
|
MPRIS_LENGTH = "mpris:length"
|
||||||
|
MPRIS_TRACK_ID = "mpris:trackid"
|
||||||
|
XESAM_TITLE = "xesam:title"
|
||||||
|
XESAM_ALBUM = "xesam:album"
|
||||||
|
XESAM_ARTISTS = "xesam:artist"
|
||||||
|
|
||||||
|
DBUS_STRAWBERRY = "org.mpris.MediaPlayer2.strawberry"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed public
|
||||||
|
var publicFS embed.FS
|
||||||
|
//go:embed pages
|
||||||
|
var pagesFS embed.FS
|
||||||
|
|
||||||
|
type (
|
||||||
|
trackMetadata struct {
|
||||||
|
ArtworkURL string
|
||||||
|
Length int64
|
||||||
|
Title string
|
||||||
|
Album string
|
||||||
|
Artists []string
|
||||||
|
}
|
||||||
|
|
||||||
|
Service struct {
|
||||||
|
track *trackMetadata
|
||||||
|
nextTrack chan *trackMetadata
|
||||||
|
nextTrackBroadcast broadcast.BroadcastChannel[*trackMetadata]
|
||||||
|
playing bool
|
||||||
|
nextPlaying chan bool
|
||||||
|
playingBroadcast broadcast.BroadcastChannel[bool]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(ctx context.Context) *Service {
|
||||||
|
nextTrack := make(chan *trackMetadata)
|
||||||
|
nextPlaying := make(chan bool)
|
||||||
|
|
||||||
|
srv := &Service{
|
||||||
|
track: nil,
|
||||||
|
nextTrack: nextTrack,
|
||||||
|
nextTrackBroadcast: broadcast.NewBroadcastChannel(ctx, nextTrack),
|
||||||
|
playing: false,
|
||||||
|
nextPlaying: nextPlaying,
|
||||||
|
playingBroadcast: broadcast.NewBroadcastChannel(ctx, nextPlaying),
|
||||||
|
}
|
||||||
|
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Service) BindRoutes(group *gin.RouterGroup) {
|
||||||
|
group.GET("/public/*path", func(ctx *gin.Context) {
|
||||||
|
path := strings.TrimPrefix(ctx.Request.URL.Path, "/music/")
|
||||||
|
http.ServeFileFS(ctx.Writer, ctx.Request, publicFS, path)
|
||||||
|
})
|
||||||
|
|
||||||
|
group.GET("/sse", func(ctx *gin.Context) {
|
||||||
|
ctx.Header("connection", "keep-alive")
|
||||||
|
|
||||||
|
nextTrack := srv.nextTrackBroadcast.Subscribe()
|
||||||
|
nextPlaying := srv.playingBroadcast.Subscribe()
|
||||||
|
|
||||||
|
if srv.track == nil { <-nextTrack }
|
||||||
|
|
||||||
|
ctx.SSEvent("music", gin.H{
|
||||||
|
"title": srv.track.Title,
|
||||||
|
"artist": artistString(srv.track),
|
||||||
|
})
|
||||||
|
ctx.SSEvent("music-state", gin.H{ "playing": srv.playing })
|
||||||
|
|
||||||
|
ticker := time.NewTicker(10 * time.Millisecond)
|
||||||
|
ctx.Stream(func(w io.Writer) bool {
|
||||||
|
select {
|
||||||
|
case track := <-nextTrack:
|
||||||
|
ctx.SSEvent("music", gin.H{
|
||||||
|
"title": track.Title,
|
||||||
|
"artist": artistString(track),
|
||||||
|
})
|
||||||
|
case playing := <-nextPlaying:
|
||||||
|
ctx.SSEvent("music-state", gin.H{ "playing": playing })
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
srv.nextTrackBroadcast.Cancel(nextTrack)
|
||||||
|
srv.playingBroadcast.Cancel(nextPlaying)
|
||||||
|
})
|
||||||
|
|
||||||
|
group.GET("/artwork", func(ctx *gin.Context) {
|
||||||
|
ctx.Header("cache-control", "no-cache")
|
||||||
|
|
||||||
|
if srv.track == nil || srv.track.ArtworkURL == "" {
|
||||||
|
http.ServeFileFS(ctx.Writer, ctx.Request, publicFS, "public/default-cover-art.png")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artworkPath := strings.TrimPrefix(srv.track.ArtworkURL, "file://")
|
||||||
|
http.ServeFile(ctx.Writer, ctx.Request, artworkPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
group.GET("/", func(ctx *gin.Context) {
|
||||||
|
http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/music.html")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Service) Run(ctx context.Context) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
conn, err := dbus.ConnectSessionBus()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to session bus: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
var data map[string]dbus.Variant
|
||||||
|
obj := conn.Object(DBUS_STRAWBERRY, "/org/mpris/MediaPlayer2")
|
||||||
|
if err := obj.Call(
|
||||||
|
"org.freedesktop.DBus.Properties.GetAll",
|
||||||
|
0,
|
||||||
|
"org.mpris.MediaPlayer2.Player",
|
||||||
|
).Store(&data); err != nil {
|
||||||
|
log.Fatalf("Failed to query media player data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, ok := data["Metadata"].Value().(map[string]dbus.Variant)
|
||||||
|
if !ok { log.Fatalf("Could not cast metadata to map[string]dbus.Variant") }
|
||||||
|
|
||||||
|
playbackStatus, ok := data["PlaybackStatus"].Value().(string)
|
||||||
|
if !ok { log.Fatalf("Could not cast metadata to string") }
|
||||||
|
|
||||||
|
// on first run, mpris:artUrl returns a temporary strawberry player artwork
|
||||||
|
// located in /tmp. this is gone by the time we attempt to read, so we need
|
||||||
|
// a way to pull the real artwork filepath at runtime.
|
||||||
|
// TODO: figure out how to pull real (not /tmp) track artwork
|
||||||
|
if srv.track, err = parseTrackMetadata(metadata); err == nil {
|
||||||
|
log.Printf("Now playing: %s - %s", artistString(srv.track), srv.track.Title)
|
||||||
|
} else {
|
||||||
|
log.Printf("Failed to parse track metadata: %v", err)
|
||||||
|
}
|
||||||
|
srv.playing = playbackStatus == "Playing"
|
||||||
|
|
||||||
|
var dbusChan chan *dbus.Signal
|
||||||
|
dbusChan, err = setupPlayerListener(conn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to session bus: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
failed := make(chan error)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
for signal := range dbusChan {
|
||||||
|
data, ok := signal.Body[1].(map[string]dbus.Variant)
|
||||||
|
if !ok { continue }
|
||||||
|
|
||||||
|
if _, ok := data["PlaybackStatus"]; ok {
|
||||||
|
playbackStatus, ok := data["PlaybackStatus"].Value().(string)
|
||||||
|
if ok {
|
||||||
|
if playbackStatus == "Playing" {
|
||||||
|
srv.playing = true
|
||||||
|
log.Print("Music playing")
|
||||||
|
} else {
|
||||||
|
srv.playing = false
|
||||||
|
log.Print("Music paused")
|
||||||
|
}
|
||||||
|
srv.nextPlaying<-srv.playing
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if _, ok := data["Metadata"]; ok {
|
||||||
|
metadata, ok := data["Metadata"].Value().(map[string]dbus.Variant)
|
||||||
|
if !ok { continue }
|
||||||
|
|
||||||
|
nextTrack, err := parseTrackMetadata(metadata)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse track metadata: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
if nextTrack.Title == srv.track.Title &&
|
||||||
|
slices.Compare(nextTrack.Artists, srv.track.Artists) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
srv.track = nextTrack
|
||||||
|
srv.nextTrack<-nextTrack
|
||||||
|
|
||||||
|
log.Printf("Now playing: %s - %s", artistString(nextTrack), nextTrack.Title)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Session bus connection lost. Reconnecting...")
|
||||||
|
conn, err = dbus.ConnectSessionBus()
|
||||||
|
dbusChan, err = setupPlayerListener(conn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to session bus: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-failed:
|
||||||
|
log.Fatalf("MPRIS/D-Bus error: %v", err)
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTrackMetadata(metadata map[string]dbus.Variant) (*trackMetadata, error) {
|
||||||
|
if _, exists := metadata[MPRIS_LENGTH]; !exists {
|
||||||
|
return nil, fmt.Errorf("Metadata does not contain %s", MPRIS_LENGTH)
|
||||||
|
}
|
||||||
|
length, ok := metadata[MPRIS_LENGTH].Value().(int64)
|
||||||
|
if !ok { return nil, fmt.Errorf("Failed to cast %s to int64", MPRIS_LENGTH) }
|
||||||
|
|
||||||
|
var artUrl string
|
||||||
|
if _, exists := metadata[MPRIS_ART_URL]; exists {
|
||||||
|
artUrl, ok = metadata[MPRIS_ART_URL].Value().(string)
|
||||||
|
if !ok { return nil, fmt.Errorf("Failed to cast %s to string", MPRIS_ART_URL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := metadata[XESAM_TITLE]; !exists {
|
||||||
|
return nil, fmt.Errorf("Metadata does not contain %s", XESAM_TITLE)
|
||||||
|
}
|
||||||
|
title, ok := metadata[XESAM_TITLE].Value().(string)
|
||||||
|
if !ok { return nil, fmt.Errorf("Failed to cast %s to string", XESAM_TITLE) }
|
||||||
|
|
||||||
|
var album string
|
||||||
|
if _, exists := metadata[XESAM_ALBUM]; exists {
|
||||||
|
album, ok = metadata[XESAM_ALBUM].Value().(string)
|
||||||
|
if !ok { return nil, fmt.Errorf("Failed to cast %s to string", XESAM_ALBUM) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var artists []string
|
||||||
|
if _, exists := metadata[XESAM_ARTISTS]; exists {
|
||||||
|
artists, ok = metadata[XESAM_ARTISTS].Value().([]string)
|
||||||
|
if !ok { return nil, fmt.Errorf("Failed to cast %s to []string", XESAM_ARTISTS) }
|
||||||
|
}
|
||||||
|
|
||||||
|
trackMetadata := &trackMetadata{
|
||||||
|
ArtworkURL: artUrl,
|
||||||
|
Length: length,
|
||||||
|
Title: title,
|
||||||
|
Album: album,
|
||||||
|
Artists: artists,
|
||||||
|
}
|
||||||
|
|
||||||
|
return trackMetadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func artistString(track *trackMetadata) string {
|
||||||
|
artists := ""
|
||||||
|
if len(track.Artists) == 0 { return "Unknown Artist" }
|
||||||
|
if len(track.Artists) == 1 { return track.Artists[0] }
|
||||||
|
artists += strings.Join(track.Artists[:len(track.Artists)-2], ", ")
|
||||||
|
artists += " & " + track.Artists[len(track.Artists) - 1]
|
||||||
|
return artists
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupPlayerListener(conn *dbus.Conn) (chan *dbus.Signal, error) {
|
||||||
|
if err := conn.AddMatchSignal(
|
||||||
|
dbus.WithMatchObjectPath("/org/mpris/MediaPlayer2"),
|
||||||
|
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
|
||||||
|
dbus.WithMatchSender(DBUS_STRAWBERRY),
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to configure D-Bus signal listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := make(chan *dbus.Signal)
|
||||||
|
conn.Signal(c)
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
31
music/pages/music.html
Normal file
31
music/pages/music.html
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Nothing is playing...</title>
|
||||||
|
<link rel="stylesheet" href="/music/public/fonts/inter/inter.css">
|
||||||
|
<link rel="stylesheet" href="/music/public/music.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="music" class="paused">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<!-- <p class="nowplaying">Now Playing</p> -->
|
||||||
|
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<img src="/music/artwork"
|
||||||
|
alt="song artwork"
|
||||||
|
class="artwork"
|
||||||
|
width="96" height="96"/>
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-center title-artist">
|
||||||
|
<p class="title text-ellipses">Untitled Track</p>
|
||||||
|
<p class="artist text-ellipses">Unknown Artist</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/music/public/music.js"></script>
|
||||||
|
<div id="cover"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
music/public/default-cover-art.png
Executable file
BIN
music/public/default-cover-art.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
music/public/fonts/inter/Inter-Black.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-Black.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-BlackItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-BlackItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-Bold.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-Bold.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-BoldItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-BoldItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-ExtraBold.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-ExtraBold.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-ExtraBoldItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-ExtraBoldItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-ExtraLight.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-ExtraLight.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-ExtraLightItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-ExtraLightItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-Italic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-Italic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-Light.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-Light.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-LightItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-LightItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-Medium.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-Medium.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-MediumItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-MediumItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-Regular.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-Regular.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-SemiBold.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-SemiBold.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-SemiBoldItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-SemiBoldItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-Thin.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-Thin.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/Inter-ThinItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/Inter-ThinItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-Black.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-Black.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-BlackItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-BlackItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-Bold.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-Bold.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-BoldItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-BoldItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-ExtraBold.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-ExtraBold.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-ExtraBoldItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-ExtraBoldItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-ExtraLight.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-ExtraLight.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-ExtraLightItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-ExtraLightItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-Italic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-Italic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-Light.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-Light.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-LightItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-LightItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-Medium.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-Medium.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-MediumItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-MediumItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-Regular.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-Regular.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-SemiBold.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-SemiBold.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-SemiBoldItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-SemiBoldItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-Thin.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-Thin.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterDisplay-ThinItalic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterDisplay-ThinItalic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterVariable-Italic.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterVariable-Italic.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
music/public/fonts/inter/InterVariable.woff2
(Stored with Git LFS)
Normal file
BIN
music/public/fonts/inter/InterVariable.woff2
(Stored with Git LFS)
Normal file
Binary file not shown.
92
music/public/fonts/inter/LICENSE.txt
Normal file
92
music/public/fonts/inter/LICENSE.txt
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
148
music/public/fonts/inter/inter.css
Normal file
148
music/public/fonts/inter/inter.css
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
/* Variable fonts usage:
|
||||||
|
:root { font-family: "Inter", sans-serif; }
|
||||||
|
@supports (font-variation-settings: normal) {
|
||||||
|
:root { font-family: "InterVariable", sans-serif; font-optical-sizing: auto; }
|
||||||
|
} */
|
||||||
|
@font-face {
|
||||||
|
font-family: InterVariable;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("InterVariable.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: InterVariable;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("InterVariable-Italic.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* static fonts */
|
||||||
|
@font-face { font-family: "Inter"; font-style: normal; font-weight: 100; font-display: swap; src: url("Inter-Thin.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: italic; font-weight: 100; font-display: swap; src: url("Inter-ThinItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: normal; font-weight: 200; font-display: swap; src: url("Inter-ExtraLight.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: italic; font-weight: 200; font-display: swap; src: url("Inter-ExtraLightItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: normal; font-weight: 300; font-display: swap; src: url("Inter-Light.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: italic; font-weight: 300; font-display: swap; src: url("Inter-LightItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("Inter-Regular.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: italic; font-weight: 400; font-display: swap; src: url("Inter-Italic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("Inter-Medium.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: italic; font-weight: 500; font-display: swap; src: url("Inter-MediumItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("Inter-SemiBold.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: italic; font-weight: 600; font-display: swap; src: url("Inter-SemiBoldItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: normal; font-weight: 700; font-display: swap; src: url("Inter-Bold.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: italic; font-weight: 700; font-display: swap; src: url("Inter-BoldItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: normal; font-weight: 800; font-display: swap; src: url("Inter-ExtraBold.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: italic; font-weight: 800; font-display: swap; src: url("Inter-ExtraBoldItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: normal; font-weight: 900; font-display: swap; src: url("Inter-Black.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "Inter"; font-style: italic; font-weight: 900; font-display: swap; src: url("Inter-BlackItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 100; font-display: swap; src: url("InterDisplay-Thin.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 100; font-display: swap; src: url("InterDisplay-ThinItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 200; font-display: swap; src: url("InterDisplay-ExtraLight.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 200; font-display: swap; src: url("InterDisplay-ExtraLightItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 300; font-display: swap; src: url("InterDisplay-Light.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 300; font-display: swap; src: url("InterDisplay-LightItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 400; font-display: swap; src: url("InterDisplay-Regular.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 400; font-display: swap; src: url("InterDisplay-Italic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 500; font-display: swap; src: url("InterDisplay-Medium.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 500; font-display: swap; src: url("InterDisplay-MediumItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 600; font-display: swap; src: url("InterDisplay-SemiBold.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 600; font-display: swap; src: url("InterDisplay-SemiBoldItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 700; font-display: swap; src: url("InterDisplay-Bold.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 700; font-display: swap; src: url("InterDisplay-BoldItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 800; font-display: swap; src: url("InterDisplay-ExtraBold.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 800; font-display: swap; src: url("InterDisplay-ExtraBoldItalic.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 900; font-display: swap; src: url("InterDisplay-Black.woff2") format("woff2"); }
|
||||||
|
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 900; font-display: swap; src: url("InterDisplay-BlackItalic.woff2") format("woff2"); }
|
||||||
|
|
||||||
|
@font-feature-values InterVariable {
|
||||||
|
@character-variant {
|
||||||
|
cv01: 1; cv02: 2; cv03: 3; cv04: 4; cv05: 5; cv06: 6; cv07: 7; cv08: 8;
|
||||||
|
cv09: 9; cv10: 10; cv11: 11; cv12: 12; cv13: 13;
|
||||||
|
alt-1: 1; /* Alternate one */
|
||||||
|
alt-3: 9; /* Flat-top three */
|
||||||
|
open-4: 2; /* Open four */
|
||||||
|
open-6: 3; /* Open six */
|
||||||
|
open-9: 4; /* Open nine */
|
||||||
|
lc-l-with-tail: 5; /* Lower-case L with tail */
|
||||||
|
simplified-u: 6; /* Simplified u */
|
||||||
|
alt-double-s: 7; /* Alternate German double s */
|
||||||
|
uc-i-with-serif: 8; /* Upper-case i with serif */
|
||||||
|
uc-g-with-spur: 10; /* Capital G with spur */
|
||||||
|
single-story-a: 11; /* Single-story a */
|
||||||
|
compact-lc-f: 12; /* Compact f */
|
||||||
|
compact-lc-t: 13; /* Compact t */
|
||||||
|
}
|
||||||
|
@styleset {
|
||||||
|
ss01: 1; ss02: 2; ss03: 3; ss04: 4; ss05: 5; ss06: 6; ss07: 7; ss08: 8;
|
||||||
|
open-digits: 1; /* Open digits */
|
||||||
|
disambiguation: 2; /* Disambiguation (with zero) */
|
||||||
|
disambiguation-except-zero: 4; /* Disambiguation (no zero) */
|
||||||
|
round-quotes-and-commas: 3; /* Round quotes & commas */
|
||||||
|
square-punctuation: 7; /* Square punctuation */
|
||||||
|
square-quotes: 8; /* Square quotes */
|
||||||
|
circled-characters: 5; /* Circled characters */
|
||||||
|
squared-characters: 6; /* Squared characters */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@font-feature-values Inter {
|
||||||
|
@character-variant {
|
||||||
|
cv01: 1; cv02: 2; cv03: 3; cv04: 4; cv05: 5; cv06: 6; cv07: 7; cv08: 8;
|
||||||
|
cv09: 9; cv10: 10; cv11: 11; cv12: 12; cv13: 13;
|
||||||
|
alt-1: 1; /* Alternate one */
|
||||||
|
alt-3: 9; /* Flat-top three */
|
||||||
|
open-4: 2; /* Open four */
|
||||||
|
open-6: 3; /* Open six */
|
||||||
|
open-9: 4; /* Open nine */
|
||||||
|
lc-l-with-tail: 5; /* Lower-case L with tail */
|
||||||
|
simplified-u: 6; /* Simplified u */
|
||||||
|
alt-double-s: 7; /* Alternate German double s */
|
||||||
|
uc-i-with-serif: 8; /* Upper-case i with serif */
|
||||||
|
uc-g-with-spur: 10; /* Capital G with spur */
|
||||||
|
single-story-a: 11; /* Single-story a */
|
||||||
|
compact-lc-f: 12; /* Compact f */
|
||||||
|
compact-lc-t: 13; /* Compact t */
|
||||||
|
}
|
||||||
|
@styleset {
|
||||||
|
ss01: 1; ss02: 2; ss03: 3; ss04: 4; ss05: 5; ss06: 6; ss07: 7; ss08: 8;
|
||||||
|
open-digits: 1; /* Open digits */
|
||||||
|
disambiguation: 2; /* Disambiguation (with zero) */
|
||||||
|
disambiguation-except-zero: 4; /* Disambiguation (no zero) */
|
||||||
|
round-quotes-and-commas: 3; /* Round quotes & commas */
|
||||||
|
square-punctuation: 7; /* Square punctuation */
|
||||||
|
square-quotes: 8; /* Square quotes */
|
||||||
|
circled-characters: 5; /* Circled characters */
|
||||||
|
squared-characters: 6; /* Squared characters */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@font-feature-values InterDisplay {
|
||||||
|
@character-variant {
|
||||||
|
cv01: 1; cv02: 2; cv03: 3; cv04: 4; cv05: 5; cv06: 6; cv07: 7; cv08: 8;
|
||||||
|
cv09: 9; cv10: 10; cv11: 11; cv12: 12; cv13: 13;
|
||||||
|
alt-1: 1; /* Alternate one */
|
||||||
|
alt-3: 9; /* Flat-top three */
|
||||||
|
open-4: 2; /* Open four */
|
||||||
|
open-6: 3; /* Open six */
|
||||||
|
open-9: 4; /* Open nine */
|
||||||
|
lc-l-with-tail: 5; /* Lower-case L with tail */
|
||||||
|
simplified-u: 6; /* Simplified u */
|
||||||
|
alt-double-s: 7; /* Alternate German double s */
|
||||||
|
uc-i-with-serif: 8; /* Upper-case i with serif */
|
||||||
|
uc-g-with-spur: 10; /* Capital G with spur */
|
||||||
|
single-story-a: 11; /* Single-story a */
|
||||||
|
compact-lc-f: 12; /* Compact f */
|
||||||
|
compact-lc-t: 13; /* Compact t */
|
||||||
|
}
|
||||||
|
@styleset {
|
||||||
|
ss01: 1; ss02: 2; ss03: 3; ss04: 4; ss05: 5; ss06: 6; ss07: 7; ss08: 8;
|
||||||
|
open-digits: 1; /* Open digits */
|
||||||
|
disambiguation: 2; /* Disambiguation (with zero) */
|
||||||
|
disambiguation-except-zero: 4; /* Disambiguation (no zero) */
|
||||||
|
round-quotes-and-commas: 3; /* Round quotes & commas */
|
||||||
|
square-punctuation: 7; /* Square punctuation */
|
||||||
|
square-quotes: 8; /* Square quotes */
|
||||||
|
circled-characters: 5; /* Circled characters */
|
||||||
|
squared-characters: 6; /* Squared characters */
|
||||||
|
}
|
||||||
|
}
|
||||||
120
music/public/music.css
Normal file
120
music/public/music.css
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
:root {
|
||||||
|
--sp-1: 1px;
|
||||||
|
--sp-2: 2px;
|
||||||
|
--sp-3: 4px;
|
||||||
|
--sp-4: 8px;
|
||||||
|
--sp-5: 16px;
|
||||||
|
|
||||||
|
--col-fg: #ffffff;
|
||||||
|
|
||||||
|
--smooth: cubic-bezier(.1, 1, .5, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
font-family: Inter, sans-serif;
|
||||||
|
font-feature-settings: 'liga' 1, 'calt' 1;
|
||||||
|
|
||||||
|
color: var(--col-fg);
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
text-shadow:
|
||||||
|
var(--sp-1) var(--sp-1) var(--sp-4) #000,
|
||||||
|
var(--sp-1) var(--sp-2) #0008;
|
||||||
|
}
|
||||||
|
@supports (font-variation-settings: normal) {
|
||||||
|
body { font-family: InterVariable, sans-serif; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-row {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-1 { gap: var(--sp-1); }
|
||||||
|
.gap-2 { gap: var(--sp-2); }
|
||||||
|
.gap-3 { gap: var(--sp-3); }
|
||||||
|
.gap-4 { gap: var(--sp-4); }
|
||||||
|
.gap-5 { gap: var(--sp-5); }
|
||||||
|
|
||||||
|
.text-ellipses {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow-x: clip;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#music {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: min-content;
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowplaying {
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: .5;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: var(--sp-3);
|
||||||
|
background: #0008;
|
||||||
|
box-shadow:
|
||||||
|
0 0 var(--sp-5) #000c,
|
||||||
|
var(--sp-1) var(--sp-1) var(--sp-2) #0004;
|
||||||
|
transition: transform .5s var(--smooth), opacity .5s, filter .5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-artist {
|
||||||
|
max-width: calc(100vw - 2em - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
padding: 0 var(--sp-5);
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: transform .5s var(--smooth), opacity .1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
padding: 0 var(--sp-5);
|
||||||
|
margin-top: calc(0px - var(--sp-3));
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform .5s var(--smooth), opacity .1s;
|
||||||
|
transition-delay: .05s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#music.paused, #music.switching {
|
||||||
|
.artwork {
|
||||||
|
transform: scale(0.8);
|
||||||
|
opacity: .5;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title, .artist {
|
||||||
|
transform: translateX(-16px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
music/public/music.js
Normal file
48
music/public/music.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
const musicEl = document.getElementById("music");
|
||||||
|
const artworkEl = document.querySelector(".artwork");
|
||||||
|
const titleEl = document.querySelector(".title");
|
||||||
|
const artistEl = document.querySelector(".artist");
|
||||||
|
|
||||||
|
async function sync() {
|
||||||
|
const eventSource = new EventSource("/music/sse");
|
||||||
|
eventSource.addEventListener("open", () => {
|
||||||
|
console.log("Connected.");
|
||||||
|
|
||||||
|
eventSource.addEventListener("music", event => {
|
||||||
|
const nextTrack = JSON.parse(event.data)
|
||||||
|
console.log(`Now playing: ${nextTrack.artist} - ${nextTrack.title}`)
|
||||||
|
|
||||||
|
musicEl.classList.add("switching");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
musicEl.classList.remove("switching");
|
||||||
|
titleEl.innerText = nextTrack.title;
|
||||||
|
artistEl.innerText = nextTrack.artist;
|
||||||
|
artworkEl.src = "/music/artwork?r=" + Math.round(Math.random() * 1_000_000);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("music-state", event => {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
console.log(data.playing ? "Music playing." : "Music paused.");
|
||||||
|
if (data.playing)
|
||||||
|
musicEl.classList.remove("paused");
|
||||||
|
else
|
||||||
|
musicEl.classList.add("paused");
|
||||||
|
});
|
||||||
|
|
||||||
|
const errListener = eventSource.addEventListener("error", () => {
|
||||||
|
eventSource.removeEventListener("error", errListener);
|
||||||
|
eventSource.close();
|
||||||
|
console.error("Connection lost, or an error has occurred.");
|
||||||
|
console.log("Attempting to reconnect...");
|
||||||
|
|
||||||
|
// TODO: stop reconnecting after 10 failed attempts
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
sync();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sync();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue