New features

This commit is contained in:
nemunaire 2023-11-13 18:50:42 +01:00
parent c67b43ab3c
commit a9c6cdcd0f
16 changed files with 584 additions and 19 deletions

87
api/inputs.go Normal file
View File

@ -0,0 +1,87 @@
package api
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.nemunai.re/nemunaire/hathoris/config"
"git.nemunai.re/nemunaire/hathoris/inputs"
)
type InputState struct {
Name string `json:"name"`
Active bool `json:"active"`
Controlable bool `json:"controlable"`
}
func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
router.GET("/inputs", func(c *gin.Context) {
ret := map[string]*InputState{}
for k, inp := range inputs.SoundInputs {
_, controlable := inp.(inputs.ControlableInput)
ret[k] = &InputState{
Name: inp.GetName(),
Active: inp.IsActive(),
Controlable: controlable,
}
}
c.JSON(http.StatusOK, ret)
})
inputsRoutes := router.Group("/inputs/:input")
inputsRoutes.Use(InputHandler)
inputsRoutes.GET("", func(c *gin.Context) {
src := c.MustGet("input").(inputs.SoundInput)
c.JSON(http.StatusOK, &InputState{
Name: src.GetName(),
Active: src.IsActive(),
})
})
inputsRoutes.GET("/settings", func(c *gin.Context) {
c.JSON(http.StatusOK, c.MustGet("input"))
})
inputsRoutes.GET("/currently", func(c *gin.Context) {
src := c.MustGet("input").(inputs.SoundInput)
if !src.IsActive() {
c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{"errmsg": "Input not active"})
return
}
c.JSON(http.StatusOK, src.CurrentlyPlaying())
})
inputsRoutes.POST("/pause", func(c *gin.Context) {
input, ok := c.MustGet("input").(inputs.ControlableInput)
if !ok {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": "The source doesn't support that"})
return
}
err := input.TogglePause()
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to pause the input: %s", err.Error())})
return
}
c.JSON(http.StatusOK, true)
})
}
func InputHandler(c *gin.Context) {
src, ok := inputs.SoundInputs[c.Param("input")]
if !ok {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Input not found: %s", c.Param("input"))})
return
}
c.Set("input", src)
c.Next()
}

View File

@ -9,6 +9,7 @@ import (
func DeclareRoutes(router *gin.Engine, cfg *config.Config) {
apiRoutes := router.Group("/api")
declareInputsRoutes(cfg, apiRoutes)
declareSourcesRoutes(cfg, apiRoutes)
declareVolumeRoutes(cfg, apiRoutes)
}

View File

@ -2,18 +2,21 @@ package api
import (
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
"git.nemunai.re/nemunaire/hathoris/config"
"git.nemunai.re/nemunaire/hathoris/inputs"
"git.nemunai.re/nemunaire/hathoris/sources"
)
type SourceState struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Active *bool `json:"active,omitempty"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Active *bool `json:"active,omitempty"`
Controlable bool `json:"controlable,omitempty"`
}
func declareSourcesRoutes(cfg *config.Config, router *gin.RouterGroup) {
@ -22,11 +25,13 @@ func declareSourcesRoutes(cfg *config.Config, router *gin.RouterGroup) {
for k, src := range sources.SoundSources {
active := src.IsActive()
_, controlable := src.(inputs.ControlableInput)
ret[k] = &SourceState{
Name: src.GetName(),
Enabled: src.IsEnabled(),
Active: &active,
Name: src.GetName(),
Enabled: src.IsEnabled(),
Active: &active,
Controlable: controlable,
}
}
@ -69,6 +74,21 @@ func declareSourcesRoutes(cfg *config.Config, router *gin.RouterGroup) {
sourcesRoutes.POST("/enable", func(c *gin.Context) {
src := c.MustGet("source").(sources.SoundSource)
if src.IsEnabled() {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "The source is already enabled"})
return
}
// Disable all sources
for k, src := range sources.SoundSources {
if src.IsEnabled() {
err := src.Disable()
if err != nil {
log.Printf("Unable to disable %s: %s", k, err.Error())
}
}
}
err := src.Enable()
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to enable the source: %s", err.Error())})
@ -88,6 +108,22 @@ func declareSourcesRoutes(cfg *config.Config, router *gin.RouterGroup) {
c.JSON(http.StatusOK, true)
})
sourcesRoutes.POST("/pause", func(c *gin.Context) {
src := c.MustGet("source").(sources.SoundSource)
if !src.IsActive() {
c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{"errmsg": "Source not active"})
return
}
s, ok := src.(inputs.ControlableInput)
if !ok {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": "The source doesn't support"})
return
}
c.JSON(http.StatusOK, s.TogglePause())
})
}
func SourceHandler(c *gin.Context) {

2
go.mod
View File

@ -5,6 +5,8 @@ go 1.21
require (
github.com/DexterLB/mpvipc v0.0.0-20230829142118-145d6eabdc37
github.com/gin-gonic/gin v1.9.1
github.com/godbus/dbus/v5 v5.0.6
github.com/leberKleber/go-mpris v1.1.0
)
require (

11
go.sum
View File

@ -25,6 +25,8 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@ -34,6 +36,10 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leberKleber/go-mpris v1.1.0 h1:bHAnmUjVoxAs4uMHH9lfQ8bOm284UWtI7JhLvkiF7O8=
github.com/leberKleber/go-mpris v1.1.0/go.mod h1:OwKywFZwFGC0p/8xBUTUXMIFZy0Rq/7C6EayfeASTA0=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
@ -43,6 +49,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -80,8 +88,9 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

15
inputs/interfaces.go Normal file
View File

@ -0,0 +1,15 @@
package inputs
import ()
var SoundInputs = map[string]SoundInput{}
type SoundInput interface {
GetName() string
IsActive() bool
CurrentlyPlaying() *string
}
type ControlableInput interface {
TogglePause() error
}

149
inputs/mpris/input.go Normal file
View File

@ -0,0 +1,149 @@
package mpris
import (
"fmt"
"log"
"strings"
"git.nemunai.re/nemunaire/hathoris/inputs"
"github.com/godbus/dbus/v5"
"github.com/leberKleber/go-mpris"
)
type MPRISClient struct {
Id string
Name string
Path string
}
var KNOWN_CLIENTS = []MPRISClient{
MPRISClient{"shairport", "ShairportSync", "org.mpris.MediaPlayer2.ShairportSync."},
MPRISClient{"firefox", "Firefox", "org.mpris.MediaPlayer2.firefox."},
}
type MPRISInput struct {
player *mpris.Player
Name string
Path string
}
var dbusConn *dbus.Conn
func init() {
var err error
dbusConn, err = dbus.ConnectSessionBus()
if err != nil {
dbusConn, err = dbus.ConnectSystemBus()
if err != nil {
log.Println("Unable to connect to DBus. MPRIS will be unavailable:", err.Error())
return
}
}
var s []string
err = dbusConn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&s)
if err != nil {
log.Println("DBus unavailable:", err.Error())
return
}
log.Println("Available DBus entries:", strings.Join(s, ","))
for _, ss := range s {
for _, c := range KNOWN_CLIENTS {
if strings.HasPrefix(ss, c.Path) {
inputs.SoundInputs[c.Id] = &MPRISInput{
Name: c.Name,
Path: c.Path,
}
}
}
}
}
func (i *MPRISInput) getPlayer() (*mpris.Player, error) {
if i.player == nil {
var s []string
err := dbusConn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&s)
if err != nil {
return nil, err
}
for _, ss := range s {
if strings.HasPrefix(ss, i.Path) {
player := mpris.NewPlayerWithConnection(ss, dbusConn)
if err != nil {
return nil, err
}
i.player = &player
break
}
}
if i.player == nil {
return nil, fmt.Errorf("Unable to find such dBus entry")
}
}
return i.player, nil
}
func (i *MPRISInput) GetName() string {
return i.Name
}
func (i *MPRISInput) IsActive() bool {
p, err := i.getPlayer()
if err != nil || p == nil {
log.Println(err)
return false
}
_, err = p.Metadata()
return err == nil
}
func (i *MPRISInput) CurrentlyPlaying() *string {
p, err := i.getPlayer()
if err != nil || p == nil {
log.Println(err)
return nil
}
meta, err := p.Metadata()
if err != nil {
log.Println(err)
return nil
}
var infos []string
if artists, err := meta.XESAMArtist(); err == nil {
for _, artist := range artists {
if artist != "" {
infos = append(infos, artist)
}
}
}
if title, err := meta.XESAMTitle(); err == nil && title != "" {
infos = append(infos, title)
}
ret := strings.Join(infos, " - ")
return &ret
}
func (i *MPRISInput) TogglePause() error {
p, err := i.getPlayer()
if err != nil {
return err
}
if ok, err := p.CanPause(); err != nil {
return err
} else if !ok {
return fmt.Errorf("The player doesn't support pause")
}
p.Pause()
return nil
}

View File

@ -7,6 +7,7 @@ import (
"syscall"
"git.nemunai.re/nemunaire/hathoris/config"
_ "git.nemunai.re/nemunaire/hathoris/inputs/mpris"
_ "git.nemunai.re/nemunaire/hathoris/sources/amp1_gpio"
_ "git.nemunai.re/nemunaire/hathoris/sources/mpv"
_ "git.nemunai.re/nemunaire/hathoris/sources/spdif"

View File

@ -28,7 +28,7 @@ func init() {
}
func (s *AMP1GPIOSource) GetName() string {
return "entrée analogique"
return "analog."
}
func (s *AMP1GPIOSource) read() ([]byte, error) {

View File

@ -98,20 +98,60 @@ func (s *MPVSource) Enable() (err error) {
_, err = conn.Get("media-title")
}
conn.Set("ao-volume", 50)
conn.Set("ao-volume", 1)
err = conn.Set("pause", false)
if err != nil {
return err
}
var pfc interface{}
pfc, err = conn.Get("paused-for-cache")
for err == nil && !pfc.(bool) {
time.Sleep(250 * time.Millisecond)
pfc, err = conn.Get("paused-for-cache")
}
err = nil
s.FadeIn(conn, 3, 50)
}
return
}
func (s *MPVSource) FadeIn(conn *mpvipc.Connection, speed int, level int) {
volume, err := conn.Get("ao-volume")
if err != nil {
volume = 1.0
}
for i := int(volume.(float64)) + 1; i <= level; i += speed {
conn.Set("ao-volume", i)
time.Sleep(time.Duration(300/speed) * time.Millisecond)
}
}
func (s *MPVSource) FadeOut(conn *mpvipc.Connection, speed int) {
volume, err := conn.Get("ao-volume")
if err == nil {
for i := int(volume.(float64)) - 1; i > 0; i -= speed {
if conn.Set("ao-volume", i) == nil {
time.Sleep(time.Duration(300/speed) * time.Millisecond)
}
}
}
}
func (s *MPVSource) Disable() error {
if s.process != nil {
if s.process.Process != nil {
conn := mpvipc.NewConnection(s.ipcSocket)
err := conn.Open()
if err == nil {
s.FadeOut(conn, 3)
conn.Close()
}
s.process.Process.Kill()
}
}
@ -139,3 +179,36 @@ func (s *MPVSource) CurrentlyPlaying() string {
return "-"
}
func (s *MPVSource) TogglePause() error {
if s.ipcSocket == "" {
return fmt.Errorf("Not supported")
}
conn := mpvipc.NewConnection(s.ipcSocket)
err := conn.Open()
if err != nil {
return err
}
defer conn.Close()
paused, err := conn.Get("pause")
if err != nil {
return err
}
if !paused.(bool) {
s.FadeOut(conn, 5)
}
err = conn.Set("pause", !paused.(bool))
if err != nil {
return err
}
if paused.(bool) {
s.FadeIn(conn, 5, 50)
}
return nil
}

View File

@ -0,0 +1,66 @@
<script>
import { activeInputs, inputsList } from '$lib/stores/inputs';
import { activeSources } from '$lib/stores/sources';
export let showInactives = false;
</script>
<ul class="list-group list-group-flush">
{#if $activeSources.length === 0 && ((showInactives && $inputsList.length === 0) || (!showInactives && $activeInputs.length === 0))}
<li class="list-group-item py-3">
<span class="text-muted">
Aucune source active.
</span>
</li>
{/if}
{#each $activeSources as source}
<li class="list-group-item py-3 d-flex justify-content-between">
<div>
<strong>{source.name}</strong>
{#await source.currently()}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
{:then title}
<span class="text-muted">{title}</span>
{/await}
</div>
{#if source.controlable}
<div>
<button
class="btn btn-sm btn-primary"
on:click={() => source.playpause()}
>
<i class="bi bi-pause"></i>
</button>
</div>
{/if}
</li>
{/each}
{#each $inputsList as input}
{#if showInactives || input.active}
<li class="list-group-item py-3 d-flex justify-content-between">
<div>
<strong>{input.name}</strong>
{#await input.currently()}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
{:then title}
<span class="text-muted">{title}</span>
{/await}
</div>
{#if input.controlable}
<div>
<button
class="btn btn-sm btn-primary"
on:click={() => input.playpause()}
>
<i class="bi bi-pause"></i>
</button>
</div>
{/if}
</li>
{/if}
{/each}
</ul>

56
ui/src/lib/input.js Normal file
View File

@ -0,0 +1,56 @@
export class Input {
constructor(id, res) {
this.id = id;
if (res) {
this.update(res);
}
}
update({ name, active, controlable }) {
this.name = name;
this.active = active;
this.controlable = controlable;
}
async currently() {
const data = await fetch(`api/inputs/${this.id}/currently`, {headers: {'Accept': 'application/json'}});
if (data.status == 200) {
return await data.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
async playpause() {
const data = await fetch(`api/inputs/${this.id}/pause`, {headers: {'Accept': 'application/json'}, method: 'POST'});
if (data.status != 200) {
throw new Error((await res.json()).errmsg);
}
}
}
export async function getInputs() {
const res = await fetch(`api/inputs`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
const data = await res.json();
if (data == null) {
return {}
} else {
Object.keys(data).forEach((k) => {
data[k] = new Input(k, data[k]);
});
return data;
}
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function getInput(sid) {
const res = await fetch(`api/inputs/${sid}`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return new Input(sid, await res.json());
} else {
throw new Error((await res.json()).errmsg);
}
}

View File

@ -6,10 +6,11 @@ export class Source {
}
}
update({ name, enabled, active }) {
update({ name, enabled, active, controlable }) {
this.name = name;
this.enabled = enabled;
this.active = active;
this.controlable = controlable;
}
async activate() {
@ -28,6 +29,13 @@ export class Source {
throw new Error((await res.json()).errmsg);
}
}
async playpause() {
const data = await fetch(`api/sources/${this.id}/pause`, {headers: {'Accept': 'application/json'}, method: 'POST'});
if (data.status != 200) {
throw new Error((await res.json()).errmsg);
}
}
}
export async function getSources() {

View File

@ -0,0 +1,41 @@
import { derived, writable } from 'svelte/store';
import { getInputs } from '$lib/input'
function createInputsStore() {
const { subscribe, set, update } = writable(null);
return {
subscribe,
set: (v) => {
update((m) => v);
},
refresh: async () => {
const list = await getInputs();
update((m) => list);
return list;
},
};
}
export const inputs = createInputsStore();
export const inputsList = derived(
inputs,
($inputs) => {
if (!$inputs) {
return [];
}
return Object.keys($inputs).map((k) => $inputs[k]);
},
);
export const activeInputs = derived(
inputsList,
($inputsList) => {
return $inputsList.filter((s) => s.active);
},
);

View File

@ -6,6 +6,10 @@
sources.refresh();
setInterval(sources.refresh, 5000);
import { inputs } from '$lib/stores/inputs';
inputs.refresh();
setInterval(inputs.refresh, 4500);
const version = fetch('api/version', {headers: {'Accept': 'application/json'}}).then((res) => res.json())
</script>

View File

@ -1,7 +1,9 @@
<script>
import Inputs from '$lib/components/Inputs.svelte';
import Mixer from '$lib/components/Mixer.svelte';
import SourceSelection from '$lib/components/SourceSelection.svelte';
import { activeSources } from '$lib/stores/sources';
import { activeInputs } from '$lib/stores/inputs';
let mixerAdvanced = false;
</script>
@ -11,7 +13,7 @@
</div>
<div class="container">
{#if $activeSources.length === 0}
{#if $activeSources.length === 0 && $activeInputs.length === 0}
<div class="text-muted text-center mt-1 mb-1">
Aucune source active pour l'instant.
</div>
@ -21,15 +23,31 @@
<div class="d-inline-block me-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>{source.name}&nbsp;:</strong>
{#await source.currently()}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div> <span class="text-muted">@ {source.name}</span>
{:then title}
{title}
<strong>{title}</strong> <span class="text-muted">@ {source.name}</span>
{:catch error}
activée
{source.name} activée
{/await}
</div>
</div>
</div>
{/each}
{#each $activeInputs as input}
<div class="d-inline-block me-3">
<div class="d-flex justify-content-between align-items-center">
<div>
{#await input.currently()}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div> <span class="text-muted">@ {input.name}</span>
{:then title}
<strong>{title}</strong> <span class="text-muted">@ {input.name}</span>
{:catch error}
{input.name} activée
{/await}
</div>
</div>
@ -39,7 +57,7 @@
{/if}
<div class="row">
<div class="col">
<div class="col-md">
<div class="card my-2">
<h4 class="card-header">
<div class="d-flex justify-content-between">
@ -61,14 +79,13 @@
</div>
</div>
<div class="col">
<div class="col-md">
<div class="card my-2">
<h4 class="card-header">
<i class="bi bi-speaker"></i>
Sources
</h4>
<div class="card-body">
</div>
<Inputs />
</div>
</div>
</div>