Compare commits
4 Commits
1ae02a7d47
...
4862d8b26a
Author | SHA1 | Date | |
---|---|---|---|
4862d8b26a | |||
7cedd74706 | |||
207a4562e6 | |||
eb2eeee3c7 |
@ -4,12 +4,18 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/hathoris/config"
|
||||
"git.nemunai.re/nemunaire/hathoris/settings"
|
||||
)
|
||||
|
||||
func DeclareRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
type SettingsGetter func() *settings.Settings
|
||||
|
||||
type SettingsReloader func() error
|
||||
|
||||
func DeclareRoutes(router *gin.Engine, cfg *config.Config, getsettings SettingsGetter, reloadsettings SettingsReloader) {
|
||||
apiRoutes := router.Group("/api")
|
||||
|
||||
declareInputsRoutes(cfg, apiRoutes)
|
||||
declareSettingsRoutes(cfg, getsettings, reloadsettings, apiRoutes)
|
||||
declareSourcesRoutes(cfg, apiRoutes)
|
||||
declareVolumeRoutes(cfg, apiRoutes)
|
||||
}
|
||||
|
64
api/settings.go
Normal file
64
api/settings.go
Normal file
@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/hathoris/config"
|
||||
"git.nemunai.re/nemunaire/hathoris/settings"
|
||||
"git.nemunai.re/nemunaire/hathoris/sources"
|
||||
)
|
||||
|
||||
type loadableSourceExposed struct {
|
||||
Description string `json:"description"`
|
||||
Fields []*sources.SourceField `json:"fields"`
|
||||
}
|
||||
|
||||
func declareSettingsRoutes(cfg *config.Config, getsettings SettingsGetter, reloadsettings SettingsReloader, router *gin.RouterGroup) {
|
||||
router.GET("/settings", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, getsettings())
|
||||
})
|
||||
|
||||
router.POST("/settings", func(c *gin.Context) {
|
||||
var params settings.Settings
|
||||
|
||||
// Parse settings
|
||||
err := c.ShouldBindJSON(¶ms)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something is wrong in received settings: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload current settings
|
||||
*getsettings() = params
|
||||
|
||||
// Save settings
|
||||
getsettings().Save(cfg.SettingsPath)
|
||||
|
||||
err = reloadsettings()
|
||||
if err != nil {
|
||||
log.Println("Unable to reload settings:", err.Error())
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to reload settings: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, getsettings())
|
||||
})
|
||||
|
||||
router.GET("/settings/loadable_sources", func(c *gin.Context) {
|
||||
ret := map[string]loadableSourceExposed{}
|
||||
|
||||
for k, ls := range sources.LoadableSources {
|
||||
ret[k] = loadableSourceExposed{
|
||||
Description: ls.Description,
|
||||
Fields: sources.GenFields(ls.SourceDefinition),
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ret)
|
||||
})
|
||||
|
||||
}
|
21
app.go
21
app.go
@ -49,7 +49,9 @@ func NewApp(cfg *config.Config) *App {
|
||||
|
||||
// Register routes
|
||||
ui.DeclareRoutes(router, cfg)
|
||||
api.DeclareRoutes(router, cfg)
|
||||
api.DeclareRoutes(router, cfg, func() *settings.Settings {
|
||||
return app.settings
|
||||
}, app.loadCustomSources)
|
||||
|
||||
router.GET("/api/version", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"version": Version})
|
||||
@ -73,7 +75,7 @@ func (app *App) loadCustomSources() error {
|
||||
if newss, ok := sources.LoadableSources[csrc.Source]; !ok {
|
||||
return fmt.Errorf("Unable to load source #%d: %q: no such source registered", id, csrc.Source)
|
||||
} else {
|
||||
src, err := newss(csrc.KV)
|
||||
src, err := newss.LoadSource(csrc.KV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to load source #%d (%s): %w", id, csrc.Source, err)
|
||||
}
|
||||
@ -82,6 +84,21 @@ func (app *App) loadCustomSources() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete old custom sources, no more in config
|
||||
for id1 := range sources.SoundSources {
|
||||
found := false
|
||||
for id2, csrc := range app.settings.CustomSources {
|
||||
if id1 == fmt.Sprintf("%s-%d", csrc.Source, id2) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
delete(sources.SoundSources, id1)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -10,7 +10,7 @@ import (
|
||||
|
||||
type CustomSource struct {
|
||||
Source string `json:"src"`
|
||||
KV map[string]string `json:"kv"`
|
||||
KV map[string]interface{} `json:"kv"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
|
53
sources/fields.go
Normal file
53
sources/fields.go
Normal file
@ -0,0 +1,53 @@
|
||||
package sources
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SourceField struct {
|
||||
Id string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func GenFields(data interface{}) (fields []*SourceField) {
|
||||
if data != nil {
|
||||
dataMeta := reflect.Indirect(reflect.ValueOf(data)).Type()
|
||||
|
||||
for i := 0; i < dataMeta.NumField(); i += 1 {
|
||||
field := dataMeta.Field(i)
|
||||
if field.IsExported() {
|
||||
fields = append(fields, GenField(field))
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func GenField(field reflect.StructField) (f *SourceField) {
|
||||
f = &SourceField{
|
||||
Id: field.Name,
|
||||
Type: field.Type.String(),
|
||||
Label: field.Tag.Get("label"),
|
||||
Placeholder: field.Tag.Get("placeholder"),
|
||||
Default: field.Tag.Get("default"),
|
||||
Description: field.Tag.Get("description"),
|
||||
}
|
||||
|
||||
jsonTag := field.Tag.Get("json")
|
||||
jsonTuples := strings.Split(jsonTag, ",")
|
||||
if len(jsonTuples) > 0 && len(jsonTuples[0]) > 0 {
|
||||
f.Id = jsonTuples[0]
|
||||
}
|
||||
|
||||
if f.Label == "" {
|
||||
f.Label = field.Name
|
||||
}
|
||||
|
||||
return
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
package sources
|
||||
|
||||
import ()
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
var (
|
||||
LoadableSources = map[string]LoadaleSource{}
|
||||
@ -19,4 +21,17 @@ type PlayingSource interface {
|
||||
CurrentlyPlaying() string
|
||||
}
|
||||
|
||||
type LoadaleSource func(map[string]string) (SoundSource, error)
|
||||
type LoadaleSource struct {
|
||||
LoadSource func(map[string]interface{}) (SoundSource, error)
|
||||
Description string
|
||||
SourceDefinition interface{}
|
||||
}
|
||||
|
||||
func Unmarshal(in map[string]interface{}, out interface{}) error {
|
||||
jin, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(jin, out)
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DexterLB/mpvipc"
|
||||
@ -17,31 +17,23 @@ import (
|
||||
type MPVSource struct {
|
||||
process *exec.Cmd
|
||||
ipcSocketDir string
|
||||
Name string
|
||||
Options []string
|
||||
File string
|
||||
Name string `json:"name"`
|
||||
Options []string `json:"opts"`
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
sources.LoadableSources["mpv"] = NewMPVSource
|
||||
sources.LoadableSources["mpv"] = sources.LoadaleSource{
|
||||
LoadSource: NewMPVSource,
|
||||
Description: "Play any file, stream or URL through mpv",
|
||||
SourceDefinition: &MPVSource{},
|
||||
}
|
||||
}
|
||||
|
||||
func NewMPVSource(kv map[string]string) (sources.SoundSource, error) {
|
||||
func NewMPVSource(kv map[string]interface{}) (sources.SoundSource, error) {
|
||||
var s MPVSource
|
||||
|
||||
if name, ok := kv["name"]; ok {
|
||||
s.Name = name
|
||||
}
|
||||
|
||||
if opts, ok := kv["opts"]; ok {
|
||||
s.Options = strings.Split(opts, " ")
|
||||
}
|
||||
|
||||
if file, ok := kv["file"]; ok {
|
||||
s.File = file
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
err := sources.Unmarshal(kv, &s)
|
||||
return &s, err
|
||||
}
|
||||
|
||||
func (s *MPVSource) GetName() string {
|
||||
@ -75,14 +67,24 @@ func (s *MPVSource) Enable() (err error) {
|
||||
|
||||
s.process = exec.Command("mpv", opts...)
|
||||
if err = s.process.Start(); err != nil {
|
||||
log.Println("Unable to launch mpv:", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := s.process.Wait()
|
||||
if err != nil {
|
||||
var exiterr *exec.ExitError
|
||||
if errors.As(err, &exiterr) {
|
||||
if exiterr.ExitCode() > 0 {
|
||||
log.Printf("mpv exited with error code = %d", exiterr.ExitCode())
|
||||
} else {
|
||||
log.Print("mpv exited successfully")
|
||||
}
|
||||
} else {
|
||||
s.process.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
if s.ipcSocketDir != "" {
|
||||
os.RemoveAll(s.ipcSocketDir)
|
||||
@ -106,6 +108,7 @@ func (s *MPVSource) Enable() (err error) {
|
||||
err = conn.Open()
|
||||
}
|
||||
if err != nil {
|
||||
log.Println("Unable to connect to mpv socket:", err.Error())
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
@ -120,15 +123,21 @@ func (s *MPVSource) Enable() (err error) {
|
||||
|
||||
err = conn.Set("pause", false)
|
||||
if err != nil {
|
||||
log.Println("Unable to unpause:", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
var pfc interface{}
|
||||
pfc, err = conn.Get("core-idle")
|
||||
|
||||
for err == nil && pfc.(bool) {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
pfc, err = conn.Get("core-idle")
|
||||
}
|
||||
err = nil
|
||||
|
||||
if err != nil {
|
||||
log.Println("Unable to retrieve core-idle status:", err.Error())
|
||||
}
|
||||
|
||||
s.FadeIn(conn, 3, 50)
|
||||
}
|
||||
|
@ -64,6 +64,7 @@ func DeclareRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
}
|
||||
|
||||
router.GET("/", serveOrReverse("", cfg))
|
||||
router.GET("/settings", serveOrReverse("", cfg))
|
||||
|
||||
router.GET("/_app/*_", serveOrReverse("", cfg))
|
||||
router.GET("/img/*_", serveOrReverse("", cfg))
|
||||
|
58
ui/src/lib/components/SettingInput.svelte
Normal file
58
ui/src/lib/components/SettingInput.svelte
Normal file
@ -0,0 +1,58 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import BasicInput from '$lib/components/SettingInputBasic.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let field = { };
|
||||
export let id = null;
|
||||
export let value = undefined;
|
||||
|
||||
if (field.type && (value === undefined || value === null)) {
|
||||
if (field.type.startsWith('[]')) {
|
||||
value = [];
|
||||
} else if (field.type === 'int') {
|
||||
value = 0;
|
||||
} else {
|
||||
value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function addElement() {
|
||||
if (field.type.endsWith('int')) {
|
||||
value.push(0);
|
||||
} else {
|
||||
value.push("");
|
||||
}
|
||||
value = value;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if field.type.startsWith("[]")}
|
||||
{#each value as val, k}
|
||||
{#if k !== 0}<br>{/if}
|
||||
<BasicInput
|
||||
{field}
|
||||
{id}
|
||||
bind:value={value[k]}
|
||||
on:change={(e) => dispatch('change', e)}
|
||||
on:input={(e) => dispatch('input', e)}
|
||||
/>
|
||||
{/each}
|
||||
<button
|
||||
class="btn btn-sm btn-info"
|
||||
on:click={addElement}
|
||||
on:keypress={addElement}
|
||||
>
|
||||
<i class="bi bi-plus" />
|
||||
</button>
|
||||
{:else}
|
||||
<BasicInput
|
||||
{field}
|
||||
{id}
|
||||
bind:value={value}
|
||||
on:change={(e) => dispatch('change', e)}
|
||||
on:input={(e) => dispatch('input', e)}
|
||||
/>
|
||||
{/if}
|
31
ui/src/lib/components/SettingInputBasic.svelte
Normal file
31
ui/src/lib/components/SettingInputBasic.svelte
Normal file
@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let field = { };
|
||||
export let id = null;
|
||||
export let value = "";
|
||||
</script>
|
||||
|
||||
{#if field.type.endsWith('int')}
|
||||
<input
|
||||
type="number"
|
||||
id={id}
|
||||
class="form-control form-control-sm"
|
||||
placeholder={field.placeholder}
|
||||
bind:value={value}
|
||||
on:change={(e) => dispatch('change', e)}
|
||||
on:input={(e) => dispatch('input', e)}
|
||||
>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
id={id}
|
||||
class="form-control form-control-sm"
|
||||
placeholder={field.placeholder}
|
||||
bind:value={value}
|
||||
on:change={(e) => dispatch('change', e)}
|
||||
on:input={(e) => dispatch('input', e)}
|
||||
>
|
||||
{/if}
|
20
ui/src/lib/components/SettingsButton.svelte
Normal file
20
ui/src/lib/components/SettingsButton.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export { className as class };
|
||||
let className = '';
|
||||
|
||||
function showModal() {
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<a
|
||||
href={$page.route.id == '/settings' ? "/" : "/settings"}
|
||||
class="btn btn-link"
|
||||
class:text-muted={$page.route.id == '/settings'}
|
||||
>
|
||||
<i class="bi bi-gear"></i>
|
||||
</a>
|
||||
</div>
|
26
ui/src/lib/custom_source.js
Normal file
26
ui/src/lib/custom_source.js
Normal file
@ -0,0 +1,26 @@
|
||||
export class CustomSource {
|
||||
constructor(res) {
|
||||
if (res) {
|
||||
this.update(res);
|
||||
}
|
||||
}
|
||||
|
||||
update({ src, kv }) {
|
||||
this.src = src;
|
||||
this.kv = kv;
|
||||
}
|
||||
}
|
||||
|
||||
export async function retrieveLoadableSources() {
|
||||
const res = await fetch(`api/settings/loadable_sources`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
if (data == null) {
|
||||
return {}
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
54
ui/src/lib/settings.js
Normal file
54
ui/src/lib/settings.js
Normal file
@ -0,0 +1,54 @@
|
||||
import { CustomSource } from '$lib/custom_source.js';
|
||||
|
||||
export class Settings {
|
||||
constructor(res) {
|
||||
if (res) {
|
||||
this.update(res);
|
||||
}
|
||||
}
|
||||
|
||||
update({ custom_sources }) {
|
||||
if (custom_sources) {
|
||||
this.custom_sources = custom_sources.map((e) => new CustomSource(e));
|
||||
} else {
|
||||
this.custom_sources = [];
|
||||
}
|
||||
}
|
||||
|
||||
async save(avoidStructUpdate) {
|
||||
const res = await fetch('api/settings', {
|
||||
headers: {'Accept': 'application/json'},
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
if (!avoidStructUpdate) {
|
||||
if (data == null) {
|
||||
this.update({});
|
||||
} else {
|
||||
this.update(data);
|
||||
}
|
||||
return this;
|
||||
} else {
|
||||
return new Settings(data);
|
||||
}
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSettings() {
|
||||
const res = await fetch(`api/settings`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
if (data == null) {
|
||||
return {}
|
||||
} else {
|
||||
return new Settings(data);
|
||||
}
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
24
ui/src/lib/stores/loadable-sources.js
Normal file
24
ui/src/lib/stores/loadable-sources.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { derived, writable } from 'svelte/store';
|
||||
|
||||
import { retrieveLoadableSources } from '$lib/custom_source'
|
||||
|
||||
function createLoadableSourcesStore() {
|
||||
const { subscribe, set, update } = writable(null);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
set: (v) => {
|
||||
update((m) => v);
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const map = await retrieveLoadableSources();
|
||||
update((m) => map);
|
||||
return map;
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const loadablesSources = createLoadableSourcesStore();
|
@ -1,15 +1,18 @@
|
||||
<script>
|
||||
import '../hathoris.scss'
|
||||
import '../hathoris.scss';
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
|
||||
import { sources } from '$lib/stores/sources';
|
||||
import { activeSources, sources } from '$lib/stores/sources';
|
||||
sources.refresh();
|
||||
setInterval(sources.refresh, 5000);
|
||||
|
||||
import { inputs } from '$lib/stores/inputs';
|
||||
import { activeInputs, inputs } from '$lib/stores/inputs';
|
||||
inputs.refresh();
|
||||
setInterval(inputs.refresh, 4500);
|
||||
|
||||
import SettingsButton from '$lib/components/SettingsButton.svelte';
|
||||
import SourceSelection from '$lib/components/SourceSelection.svelte';
|
||||
|
||||
const version = fetch('api/version', {headers: {'Accept': 'application/json'}}).then((res) => res.json())
|
||||
</script>
|
||||
|
||||
@ -18,7 +21,54 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill d-flex flex-column">
|
||||
<div class="container-fluid flex-fill d-flex flex-column justify-content-center">
|
||||
<div class="container-fluid flex-fill d-flex flex-column justify-content-start">
|
||||
<div class="position-absolute" style="right: 0.5rem">
|
||||
<SettingsButton />
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<SourceSelection />
|
||||
</div>
|
||||
|
||||
{#if $activeSources.length === 0 && $activeInputs.length === 0}
|
||||
<div class="text-muted text-center mt-1 mb-1">
|
||||
Aucune source active pour l'instant.
|
||||
</div>
|
||||
{:else}
|
||||
<marquee>
|
||||
{#each $activeSources as source}
|
||||
<div class="d-inline-block me-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
{#if source.currentTitle}
|
||||
<strong>{source.currentTitle}</strong> <span class="text-muted">@ {source.name}</span>
|
||||
{:else}
|
||||
{source.name} activée
|
||||
{/if}
|
||||
</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>
|
||||
{#if input.streams.length}
|
||||
{#each Object.keys(input.streams) as idstream}
|
||||
{@const title = input.streams[idstream]}
|
||||
<strong>{title}</strong>
|
||||
{/each}
|
||||
<span class="text-muted">@ {input.name}</span>
|
||||
{:else}
|
||||
{input.name} activée
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</marquee>
|
||||
{/if}
|
||||
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,7 +2,6 @@
|
||||
import Applications from '$lib/components/Applications.svelte';
|
||||
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';
|
||||
|
||||
@ -10,50 +9,7 @@
|
||||
let showInactiveInputs = false;
|
||||
</script>
|
||||
|
||||
<div class="my-3">
|
||||
<SourceSelection />
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
{#if $activeSources.length === 0 && $activeInputs.length === 0}
|
||||
<div class="text-muted text-center mt-1 mb-1">
|
||||
Aucune source active pour l'instant.
|
||||
</div>
|
||||
{:else}
|
||||
<marquee>
|
||||
{#each $activeSources as source}
|
||||
<div class="d-inline-block me-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
{#if source.currentTitle}
|
||||
<strong>{source.currentTitle}</strong> <span class="text-muted">@ {source.name}</span>
|
||||
{:else}
|
||||
{source.name} activée
|
||||
{/if}
|
||||
</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>
|
||||
{#if input.streams.length}
|
||||
{#each Object.keys(input.streams) as idstream}
|
||||
{@const title = input.streams[idstream]}
|
||||
<strong>{title}</strong>
|
||||
{/each}
|
||||
<span class="text-muted">@ {input.name}</span>
|
||||
{:else}
|
||||
{input.name} activée
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</marquee>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<div class="card my-3">
|
||||
|
24
ui/src/routes/settings/+page.js
Normal file
24
ui/src/routes/settings/+page.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getSettings } from '$lib/settings';
|
||||
import { loadablesSources } from '$lib/stores/loadable-sources.js';
|
||||
|
||||
export const load = async({ parent, fetch }) => {
|
||||
const data = await parent();
|
||||
|
||||
await loadablesSources.refresh();
|
||||
|
||||
let settings;
|
||||
try {
|
||||
settings = await getSettings();
|
||||
} catch (err) {
|
||||
throw error(err.status, err.statusText);
|
||||
}
|
||||
|
||||
const version = fetch('/api/version').then((res) => res.json());
|
||||
|
||||
return {
|
||||
...data,
|
||||
settings,
|
||||
version,
|
||||
};
|
||||
}
|
153
ui/src/routes/settings/+page.svelte
Normal file
153
ui/src/routes/settings/+page.svelte
Normal file
@ -0,0 +1,153 @@
|
||||
<script>
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
import Input from '$lib/components/SettingInput.svelte';
|
||||
import { loadablesSources } from '$lib/stores/loadable-sources';
|
||||
|
||||
export let data;
|
||||
|
||||
async function submitSettings(avoidStructUpdate) {
|
||||
return await data.settings.save(avoidStructUpdate);
|
||||
}
|
||||
|
||||
function addCustomSource() {
|
||||
data.settings.custom_sources.push({ edit_name: true, kv: { name: "" } });
|
||||
data.settings.custom_sources = data.settings.custom_sources;
|
||||
|
||||
tick().then(() => {
|
||||
document.getElementById("src_name_" + (data.settings.custom_sources.length - 1)).focus();
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteCustomSource(isrc) {
|
||||
data.settings.custom_sources.splice(isrc);
|
||||
data.settings = await submitSettings();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="card my-3">
|
||||
<h4 class="card-header">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<i class="bi bi-gear"></i>
|
||||
General Settings
|
||||
</div>
|
||||
</div>
|
||||
</h4>
|
||||
<div class="card-body">
|
||||
<span class="text-muted">Version:</span>
|
||||
{#await data.version then version}
|
||||
{version.version}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card my-3">
|
||||
<h4 class="card-header">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<i class="bi bi-cassette"></i>
|
||||
Custom Sources
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
on:click={() => addCustomSource()}
|
||||
on:keypress={() => addCustomSource()}
|
||||
>
|
||||
<i class="bi bi-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</h4>
|
||||
<div class="list-group list-group-flush">
|
||||
{#each data.settings.custom_sources as source, isrc}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<h5
|
||||
class="mb-0"
|
||||
on:click={() => source.edit_name = true}
|
||||
>
|
||||
{#if source.kv && source.kv.name !== undefined}
|
||||
{#if !source.edit_name}
|
||||
{source.kv.name}
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
id={"src_name_" + isrc}
|
||||
class="form-control"
|
||||
placeholder="Source's name"
|
||||
bind:value={data.settings.custom_sources[isrc].kv["name"]}
|
||||
on:input={submitSettings}
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
{source.src} #{isrc}
|
||||
{/if}
|
||||
</h5>
|
||||
{#if source.src}
|
||||
<small class="badge bg-secondary">{source.src}</small>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
on:click={() => deleteCustomSource(isrc)}
|
||||
on:keypress={() => deleteCustomSource(isrc)}
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
{#if source.src && source.kv}
|
||||
{#if $loadablesSources[source.src].fields}
|
||||
{#each $loadablesSources[source.src].fields.filter((e) => e.id !== "name") as field (field.id)}
|
||||
<div class="d-flex gap-3 mb-2">
|
||||
<label for={"input_" + isrc + "_" + field.id} class="form-label">
|
||||
{field.label}
|
||||
</label>
|
||||
<Input
|
||||
id={"input_" + isrc + "_" + field.id}
|
||||
{field}
|
||||
bind:value={data.settings.custom_sources[isrc].kv[field.id]}
|
||||
on:input={submitSettings}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each Object.keys(source.kv).filter((e) => e !== "name") as k}
|
||||
<div class="d-flex gap-3 mb-2">
|
||||
<label for={"input_" + isrc + "_" + k} class="form-label">
|
||||
{k}
|
||||
</label>
|
||||
<Input
|
||||
id={"input_" + isrc + "_" + k}
|
||||
{field}
|
||||
bind:value={data.settings.custom_sources[isrc].kv[k]}
|
||||
on:input={submitSettings}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
Choose a new custom source:
|
||||
<div class="d-flex gap-3">
|
||||
{#if $loadablesSources}
|
||||
{#each Object.keys($loadablesSources) as kls}
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
title={$loadablesSources[kls].description}
|
||||
on:click={() => data.settings.custom_sources[isrc].src = kls}
|
||||
on:keypress={() => data.settings.custom_sources[isrc].src = kls}
|
||||
>
|
||||
{kls}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
Loading…
x
Reference in New Issue
Block a user