Compare commits

...

22 commits

Author SHA1 Message Date
coon 1b694b3a9e static: add custom style to range input control 2023-12-02 18:15:55 +01:00
coon c6cd31423d static: add shiny borders to bottom controls 2023-12-02 18:15:55 +01:00
coon 56dc5f3ca9 static: adjust sanic logo 2023-12-02 18:15:55 +01:00
coon a07a7a65a4 static: add gradient background for top controls 2023-12-02 18:15:55 +01:00
coon 49c1ba5ea0 static: add table header gradient 2023-12-02 18:15:55 +01:00
coon e6578d4ba1 static: comment out dashed debug boarder for div containers 2023-12-02 18:15:54 +01:00
coon 383fea7cb7 static: use different font 2023-12-02 18:15:54 +01:00
coon 8a6c4c0567 static: add playback arrow image + red highlight background color 2023-12-02 18:15:54 +01:00
coon c633905561 static: some formatting on table entries 2023-12-02 18:15:54 +01:00
coon 4d10f41ffe static: add playback arrow to position row 2023-12-02 18:15:54 +01:00
coon 88179a5f8b static: use "old school" way for alternate coloring of tables 2023-12-02 18:15:52 +01:00
coon a3faf4423e static: use different color for footer 2023-12-02 18:14:05 +01:00
coon bcdcc2750d static: use alternating colors on table 2023-12-02 18:14:05 +01:00
coon 21f1acb699 static: make tables occupy whole width 2023-12-02 18:14:03 +01:00
coon e50e54e8d8 static: add basic file tree view 2023-12-02 18:13:04 +01:00
coon 3b3e95bd7a static: w3c compliance stuff 2023-12-02 18:13:04 +01:00
coon 30ce2e6a52 static: add dummy track lists for queue and playlist tracklist 2023-12-02 18:13:03 +01:00
coon 292e4b1d63 static: index.html: add playlist_controls + playlist_tracklist div containers 2023-12-02 18:13:03 +01:00
coon ff93d57e28 static: work on basic layout + top controls 2023-12-02 18:13:03 +01:00
coon 983e73adc3 static: add sanic logo image 2023-12-02 18:11:28 +01:00
coon 52e37fcacb static: start of flexbox implementation 2023-12-02 18:11:28 +01:00
XenGi 32291bccb8
added some api calls 2023-12-02 17:53:44 +01:00
14 changed files with 889 additions and 24 deletions

3
Makefile Normal file
View file

@ -0,0 +1,3 @@
mpd:
mkdir -p /tmp/sanic/{music,playlists}
mpd --no-daemon ./mpd.conf

20
NOTES.md Normal file
View file

@ -0,0 +1,20 @@
# mpd commands
- [x] previous track
- [x] next track
- [x] stop
- [x] play/pause
- [x] set track progress
- [x] set volume
- [x] repeat queue
- [x] shuffle queue
- [ ] set fade
- [ ] add track to queue
- [ ] rm track from queue
- [ ] move track in queue
- [ ] clear queue
- [ ] list tracks in music db
- [ ] list playlists
- [ ] save playlist
- [ ] delete playlist

View file

@ -28,11 +28,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1694616124, "lastModified": 1699950847,
"narHash": "sha256-c49BVhQKw3XDRgt+y+uPAbArtgUlMXCET6VxEBmzHXE=", "narHash": "sha256-xN/yVtqHb7kimHA/WvQFrEG5WS38t0K+A/W+j/WhQWM=",
"owner": "tweag", "owner": "tweag",
"repo": "gomod2nix", "repo": "gomod2nix",
"rev": "f95720e89af6165c8c0aa77f180461fe786f3c21", "rev": "05c993c9a5bd55a629cd45ed49951557b7e9c61a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -43,11 +43,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1697915759, "lastModified": 1701237617,
"narHash": "sha256-WyMj5jGcecD+KC8gEs+wFth1J1wjisZf8kVZH13f1Zo=", "narHash": "sha256-Ryd8xpNDY9MJnBFDYhB37XSFIxCPVVVXAbInNPa95vs=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "51d906d2341c9e866e48c2efcaac0f2d70bfd43e", "rev": "85306ef2470ba705c97ce72741d56e42d0264015",
"type": "github" "type": "github"
}, },
"original": { "original": {

214
server.go
View file

@ -1,13 +1,17 @@
package main package main
import ( import (
"fmt"
"github.com/fhs/gompd/v2/mpd"
"github.com/labstack/echo-contrib/echoprometheus" "github.com/labstack/echo-contrib/echoprometheus"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"golang.org/x/net/websocket" "golang.org/x/net/websocket"
"log"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time"
"github.com/labstack/echo/v4"
) )
func main() { func main() {
@ -25,6 +29,7 @@ func main() {
e.GET("/metrics", echoprometheus.NewHandler()) e.GET("/metrics", echoprometheus.NewHandler())
e.GET("/", func(c echo.Context) (err error) { e.GET("/", func(c echo.Context) (err error) {
// HTTP/2 Server Push
pusher, ok := c.Response().Writer.(http.Pusher) pusher, ok := c.Response().Writer.(http.Pusher)
if ok { if ok {
if err = pusher.Push("/style.css", nil); err != nil { if err = pusher.Push("/style.css", nil); err != nil {
@ -40,6 +45,17 @@ func main() {
return c.File("index.html") return c.File("index.html")
}) })
g := e.Group("/api")
g.GET("/previous_track", previousTrack)
g.GET("/next_track", nextTrack)
g.GET("/stop", stopPlayback)
g.GET("/play", resumePlayback)
g.GET("/pause", pausePlayback)
g.GET("/seek/:seconds", seek)
g.GET("/repeat", toggleRepeat)
g.GET("/random", toggleRandom)
g.GET("/volume/:level", setVolume)
e.GET("/ws", wsServe) e.GET("/ws", wsServe)
e.Logger.Fatal(e.StartTLS(":1323", "cert.pem", "key.pem")) e.Logger.Fatal(e.StartTLS(":1323", "cert.pem", "key.pem"))
@ -47,33 +63,217 @@ func main() {
} }
func wsServe(c echo.Context) error { func wsServe(c echo.Context) error {
fmt.Println("wsServe")
websocket.Handler(func(ws *websocket.Conn) { websocket.Handler(func(ws *websocket.Conn) {
defer ws.Close() defer ws.Close()
fmt.Println("handler")
for { for {
// Read // Read
msg := "" msg := ""
err := websocket.Message.Receive(ws, &msg) err := websocket.Message.Receive(ws, &msg)
if err != nil { if err != nil {
c.Logger().Error(err) c.Logger().Error(err)
} break
// Forward MPD communication } else {
if strings.HasPrefix(strings.ToUpper(msg), "MPD#") { if strings.HasPrefix(strings.ToUpper(msg), "MPD#") {
// Forward MPD communication
// TODO: forward request to mpd and response back to client // TODO: forward request to mpd and response back to client
err := websocket.Message.Send(ws, "MPD command received, processing... processing...") err := websocket.Message.Send(ws, "MPD command received, processing... processing...")
if err != nil { if err != nil {
c.Logger().Error(err) c.Logger().Error(err)
} }
}
} else if strings.HasPrefix(strings.ToUpper(msg), "YT#") {
// Download video link as audio file // Download video link as audio file
if strings.HasPrefix(strings.ToUpper(msg), "YT#") {
// TODO: implement yt-dlp integration // TODO: implement yt-dlp integration
err := websocket.Message.Send(ws, "YT-DLP command received, processing... processing...") err := websocket.Message.Send(ws, "YT-DLP command received, processing... processing...")
if err != nil { if err != nil {
c.Logger().Error(err) c.Logger().Error(err)
} }
} }
//fmt.Printf("%s\n", msg) }
//fmt.Println(msg)
} }
}).ServeHTTP(c.Response(), c.Request()) }).ServeHTTP(c.Response(), c.Request())
return nil return nil
} }
// API calls
func previousTrack(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
err = conn.Previous()
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func nextTrack(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
err = conn.Next()
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func stopPlayback(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
err = conn.Stop()
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func resumePlayback(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
err = conn.Pause(false)
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func pausePlayback(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
err = conn.Pause(true)
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func seek(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
seconds, err := strconv.Atoi(c.Param("seconds"))
if err != nil {
log.Fatalln(err)
}
if seconds < 0 {
return c.String(http.StatusBadRequest, "seconds must be positive integer")
}
err = conn.SeekCur(time.Duration(seconds)*time.Second, false)
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func toggleRepeat(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
status, err := conn.Status()
if err != nil {
log.Fatalln(err)
}
if status["repeat"] == "1" {
err = conn.Repeat(false)
} else {
err = conn.Repeat(true)
}
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func toggleRandom(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
status, err := conn.Status()
if err != nil {
log.Fatalln(err)
}
if status["toggleRandom"] == "1" {
err = conn.Random(false)
} else {
err = conn.Random(true)
}
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}
func setVolume(c echo.Context) error {
// Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
level, err := strconv.Atoi(c.Param("level"))
if err != nil {
log.Fatalln(err)
}
if level > 100 || level < 0 {
return c.String(http.StatusBadRequest, "Volume must be between 0 and 100")
}
err = conn.SetVolume(level)
if err != nil {
log.Fatalln(err)
}
return c.String(http.StatusNoContent, "")
}

264
static/flexbox/index.html Normal file
View file

@ -0,0 +1,264 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sanic - Flexbox layout</title>
<link rel="stylesheet" href="rangeinput.css">
<link rel="stylesheet" href="treeview.css">
<link rel="stylesheet" href="sanic.css">
<script src="sanic.js"></script>
</head>
<body>
<div id="flexbox-container">
<div id="controls-top">
<div id="top-left-controls">
<button>Login</button>
<button>Config</button>
</div>
<div id="playback-controls">
<div>
<button>⏮️</button>
<button>⏯️</button>
<button>⏹️</button>
<button>⏭️</button>
</div>
<div>
<label for="progress"></label>
<input type="range" id="progress" name="progress" min="0" max="100" value="0">
</div>
</div>
<div id="queue-volume-controls">
<div id="queue-controls">
<div id="queue-playback-order-controls">
<div>
<input type="checkbox" id="repeat" name="repeat">
<label for="repeat">repeat</label>
</div>
<div>
<input type="checkbox" id="shuffle" name="shuffle">
<label for="shuffle">shuffle</label>
</div>
</div>
<div id="queue-xfade-control">
<div>
xfade
</div>
<div id="queue-xfade-buttons">
<button>-</button>
<div id="xfade">00</div>
<button>+</button>
</div>
</div>
</div>
<div id="volume-control">
<label for="volume"></label>
<input id="volume" name="volume" type="range" min="1" max="100">
</div>
</div>
<div class="track-info">
<p>Now playing: 00:00:00/100:00:00</p>
<label for="track">track</label>
<input type="text" id="track" name="track" disabled>
</div>
<div id="top-logo-container">
<div id="top-logo">
<img id="sanic-logo" alt="sanic logo" src="../img/sanic-logo.webp">
Sanic
</div>
</div>
</div>
<div id="queue">
<table id="queue-table">
<thead>
<tr>
<th>Position</th>
<th>Artist</th>
<th>Title</th>
<th>Album</th>
<th>Genre</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>1</td>
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="even">
<td>2</td>
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="odd playing">
<td class="playing">3</td>
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="even">
<td>4</td>
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="odd">
<td>5</td>
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="even">
<td>6</td>
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="odd">
<td>7</td>
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
</tbody>
</table>
</div>
<div id="controls_bottom">
<div id="playlist_controls">
<ul class="tree">
<li>
<details open>
<summary>/</summary>
<ul>
<li>
<details>
<summary>00_music</summary>
<ul>
<li>autosort</li>
<li>reimport</li>
<li>unsortable</li>
<li>youtube</li>
</ul>
</details>
</li>
<li>
<details>
<summary>01_incoming</summary>
<ul>
<li>coon</li>
<li>cascha</li>
<li>Xen</li>
</ul>
</details>
</li>
<li>
<details>
<summary>02_megablast</summary>
<ul>
<li>dnb</li>
<li>mix</li>
</ul>
</details>
</li>
<li>
<details>
<summary>03_mfs</summary>
<ul>
<li>ambient</li>
<li>electronic</li>
</ul>
</details>
</li>
</ul>
</details>
</li>
</ul>
</div>
<div id="playlist_tracklist">
<table>
<thead>
<tr>
<th>Artist</th>
<th>Title</th>
<th>Album</th>
<th>Genre</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="even">
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="odd">
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="even">
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="odd">
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="even">
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
<tr class="odd">
<td>Chakra</td>
<td>Love Shines Through (Martin Roth's in Electro Love Remix)</td>
<td>undefined</td>
<td>undefined</td>
<td>9:29</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="footer">
sanic mpd web ui 0.1.0 - by XenGi and coon &copy; 2023
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,77 @@
/* https://www.smashingmagazine.com/2021/12/create-custom-range-input-consistent-browsers/ */
/********** Range Input Styles **********/
/* Range Reset */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
/* Removes default focus */
input[type="range"]:focus {
outline: none;
}
/***** Chrome, Safari, Opera and Edge Chromium styles *****/
/* slider track */
input[type="range"]::-webkit-slider-runnable-track {
height: 5px;
width: 70px;
border: 1px solid #1a2445;
border-right-color: #3a506b;
border-bottom-color: #3a506b;
background-color: #2e2e27;
background-repeat: repeat-x;
}
/* slider thumb */
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; /* Override default look */
appearance: none;
margin-top: -12px; /* Centers thumb on the track */
/* custom styles */
height: 11px;
margin-top: -4px;
width: 5px;
background: #640000;
border: 1px solid #812b25;
border-right-color: #21110f;
border-bottom-color: #21110f;
}
input[type="range"]:focus::-webkit-slider-thumb {
border: 1px solid #21110f;
border-right-color: #812b25;
border-bottom-color: #812b25;
}
/******** Firefox styles ********/
/* slider track */
input[type="range"]::-moz-range-track {
background-color: #053a5f;
border-radius: 0.5rem;
height: 0.5rem;
}
/* slider thumb */
input[type="range"]::-moz-range-thumb {
border: none; /*Removes extra border that FF applies*/
border-radius: 0; /*Removes default border-radius that FF applies*/
/* custom styles */
background-color: #5cd5eb;
height: 2rem;
width: 1rem;
}
input[type="range"]:focus::-moz-range-thumb {
border: 1px solid #053a5f;
outline: 3px solid #053a5f;
outline-offset: 0.125rem;
}

203
static/flexbox/sanic.css Normal file
View file

@ -0,0 +1,203 @@
html, body {
background-color: #09101d;
color: #bbb;
height: 99%;
}
div {
/* border: 1px dashed white; */
font-weight: normal;
font-family: Arial, Helvetica, sans-serif;
font-size: 13px;
}
button {
background-color: #28374a;
color: #bbb;
}
input {
background-color: #28374a;
color: white;
}
table {
width: 100%;
table-layout: fixed;
border-spacing: 0pt;
}
thead {
background-repeat: repeat-x;
background-image: url(../img/table-header-gradient.png);
}
th {
font-weight: bold;
padding: 2px 2px 2px 14px;
border: solid #1c2c1a;
border-width: 0 1px 0 0;
cursor: pointer;
}
td {
padding: 1px 1px 1px 0.5em;
border: solid black;
border-width: 0 1px 1px 0;
text-align: left;
}
#queue-table tr td:first-of-type {
padding-left: 16px;
}
/* This is probably a better way to generate alternate coloring on tables. However,
background color for selected track is overwritten this way. Therefore the "old
school" way of alternate coloring is used for now.
table tr:nth-child(odd) td {
background:#1e1f1a;
}
table tr:nth-child(even) td {
background:#171812;
}
*/
table tr.odd {
background-color: #1e1f1a;
}
table tr.even {
background-color: #171812;
}
#queue-table tr.playing {
background-color: #490b00;
}
td.playing {
background-image: url(../img/playback.png);
background-repeat: no-repeat;
background-position: left center;
}
#flexbox-container {
display: flex;
flex-direction: column;
height: 100%;
}
#controls-top {
display: flex;
flex-direction: row;
padding: 5px;
background-repeat: repeat-x;
background-image: url(../img/top-controls-bg.png);
}
#controls-top div {
border-right: 1px solid black;
}
#top-left-controls {
display: flex;
flex-direction: column;
width: 100px;
}
#top-left-controls button {
text-align: left;
}
#playback-controls {
display: flex;
flex-direction: column;
width: 160px;
}
#progress {
margin-left: 10px;
}
#volume {
margin-left: 10px;
}
#queue-volume-controls {
width: 150px;
}
#queue-controls {
display: flex;
flex-direction: row;
}
#queue-xfade-control {
display: flex;
flex-direction: column;
text-align: center;
}
#queue-xfade-buttons {
display: flex;
flex-direction: row;
}
#top-logo {
display: flex;
flex-direction: column;
text-align: center;
color: white;
width: 50px;
}
#top-logo-container {
display: flex;
flex-grow: 1;
justify-content: flex-end;
}
#sanic-logo {
width: 36px;
padding-left: 8px;
}
#xfade {
width: 20px;
}
#queue {
display: flex;
flex-direction: column;
flex-grow: 1;
border-bottom: 4px ridge #3a506b;
}
#controls_bottom {
display: flex;
flex-direction: row;
flex-grow: 2;
}
#playlist_controls {
background-color: #171812;;
/* flex-grow: 1; */
width: 20%; /* frickel? */
border-right: 4px ridge #3a506b;
}
#playlist_tracklist {
display: flex;
flex-direction: column;
width: 80%; /* frickel? */
}
#footer {
background-color: #041936;
text-align: right;
}

0
static/flexbox/sanic.js Normal file
View file

View file

@ -0,0 +1,91 @@
/* https://iamkate.com/code/tree-views/ */
/* Custom properties */
.tree {
--spacing : 1.0rem;
--radius : 10px;
}
/* Padding */
.tree li {
display : block;
position : relative;
padding-left : calc(2 * var(--spacing) - var(--radius) - 2px + 10px);
}
.tree ul {
margin-left : calc(var(--radius) - var(--spacing));
padding-left : 0;
}
/* Vertical lines */
.tree ul li {
border-left : 2px solid #ddd;
}
.tree ul li:last-child {
border-color : transparent;
}
/* Horizontal lines */
.tree ul li::before {
content : '';
display : block;
position : absolute;
top : calc(var(--spacing) / -2);
left : -2px;
width : calc(var(--spacing) + 2px);
height : calc(var(--spacing) + 1px);
border : solid #ddd;
border-width : 0 0 2px 2px;
}
/* Summaries */
.tree summary {
display : block;
cursor : pointer;
}
.tree summary::marker,
.tree summary::-webkit-details-marker {
display : none;
}
.tree summary:focus {
outline : none;
}
.tree summary:focus-visible {
outline : 1px dotted #000;
}
/* Markers */
.tree li::after,
.tree summary::before {
content : '';
display : block;
position : absolute;
top : calc(var(--spacing) / 2 - var(--radius));
left : calc(var(--spacing) - var(--radius) - 1px);
width : calc(2 * var(--radius));
height : calc(2 * var(--radius));
border-radius : 50%;
background : #ddd;
}
/* Expand and collapse buttons */
.tree summary::before {
z-index : 1;
background : #696 url('../img/expand-collapse.svg') 0 0;
}
.tree details[open] > summary::before {
background-position : calc(-2 * var(--radius)) 0;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20">
<g fill="#fff">
<path d="m5 9h4v-4h2v4h4v2h-4v4h-2v-4h-4z"/>
<path d="m25 9h10v2h-10z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 199 B

BIN
static/img/playback.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

BIN
static/img/sanic-logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB