fully-functioning music status!

- accessible via /music
- only detects strawberry for now
This commit is contained in:
ari melody 2026-06-13 03:09:21 +01:00
parent f838edeadb
commit 8a1fabf91a
Signed by: ari
GPG key ID: 60B5F0386E3DDB7E
53 changed files with 923 additions and 51 deletions

58
.air.toml Normal file
View 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
View file

@ -0,0 +1 @@
*.woff2 filter=lfs diff=lfs merge=lfs -text

1
.gitignore vendored
View file

@ -1 +1,2 @@
tmp/
learning-title.txt

3
go.mod
View file

@ -1,4 +1,4 @@
module codeberg.org/arimelody/ari-stream-utils
module codeberg.org/arimelody/ari-stream-tools
go 1.26.4
@ -16,6 +16,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // 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/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect

2
go.sum
View file

@ -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-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=

View file

@ -3,7 +3,6 @@ package learning
import (
"context"
"embed"
"fmt"
"io"
"log"
"net/http"
@ -12,7 +11,7 @@ import (
"sync"
"time"
"codeberg.org/arimelody/ari-is-learning/broadcast"
"codeberg.org/arimelody/ari-stream-tools/broadcast"
"github.com/gin-gonic/gin"
)
@ -29,7 +28,6 @@ type (
Service struct {
cfg ServiceConfig
ctx context.Context
titleFileMutex sync.Mutex
titleLastModTime time.Time
titleText string
@ -39,21 +37,25 @@ type (
)
func New(ctx context.Context, cfg ServiceConfig) *Service {
titleText := []byte{}
titleUpdated := make(chan string)
if _, err := os.Stat(cfg.TitleFilePath); os.IsNotExist(err) {
log.Printf("Learning title file [%s] does not exist, creating one...", cfg.TitleFilePath)
os.WriteFile(cfg.TitleFilePath, []byte("Untitled"), 0644)
titleText = []byte("Untitled")
} else if err != nil {
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{
cfg: cfg,
ctx: ctx,
titleFileMutex: sync.Mutex{},
titleLastModTime: time.Unix(0, 0),
titleText: "",
titleText: string(titleText),
titleUpdated: titleUpdated,
titleUpdatedBroadcast: broadcast.NewBroadcastChannel(ctx, titleUpdated),
}
@ -110,6 +112,8 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) {
return
}
srv.titleUpdated<-string(data)
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")
})
}
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():
}
}

View file

@ -10,7 +10,8 @@ import (
"strconv"
"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"
)
@ -41,11 +42,13 @@ func main() {
learningService := learning.New(ctx, learning.ServiceConfig{
TitleFilePath: "learning-title.txt",
})
musicService := music.New(ctx)
srv := gin.Default()
learningService.BindRoutes(srv.Group("/learning"))
musicService.BindRoutes(srv.Group("/music"))
go learningService.Run(ctx)
go musicService.Run(ctx)
go func() {
log.Printf("Now serving at http://%s:%d\n", host, port)
failed <- srv.Run(fmt.Sprintf("%s:%d", host, port))

View file

@ -1 +0,0 @@
package mpris

292
music/music.go Normal file
View 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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

View 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.

View 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 &amp; 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 &amp; 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 &amp; 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
View 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
View 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();