Initial interface

This commit is contained in:
nemunaire 2023-11-13 13:18:43 +01:00
commit e2013351d1
23 changed files with 3237 additions and 0 deletions

17
ui/src/app.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="fr" class="d-flex flex-column mh-100 h-100">
<head>
<meta charset="utf-8" />
<meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff"/>
<meta name="author" content="nemucorp">
<meta name="robots" content="none">
<base href="/">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="flex-fill d-flex flex-column">
<noscript>Si la page ne charge pas, essayez <a href="/nojs.html">la version sans JavaScript</a>.</noscript>
<div class="flex-fill d-flex flex-column justify-content-between" style="min-height: 100%">%sveltekit.body%</div>
</body>
</html>

75
ui/src/hathoris.scss Normal file
View file

@ -0,0 +1,75 @@
// Your variable overrides can go here, e.g.:
// $h1-font-size: 3rem;
//$primary: #ff485a;
//$secondary: #ff7b88;
$blue: #2a9fd6;
$indigo: #6610f2;
$purple: #6f42c1;
$pink: #e83e8c;
$red: #c00;
$orange: #fd7e14;
$yellow: #f80;
$green: #77b300;
$teal: #20c997;
$cyan: #93c;
$primary: $purple;
$success: $green;
$info: $cyan;
$warning: $yellow;
$danger: $red;
$min-contrast-ratio: 2.25;
$enable-shadows: true;
$enable-gradients: true;
$enable-responsive-font-sizes: true;
$link-color: $primary;
$navbar-padding-y: 0;
$nav-link-padding-y: 0.2rem;
@import "bootstrap/scss/bootstrap";
a.btn, button.btn {
text-decoration: none !important;
}
.fixed-bottom .nav-item a {
border-top: 1px solid $pink;
}
.fixed-bottom .nav-item a.active {
background: white;
color: $pink;
}
.marquee {
height: 1.5em;
overflow: hidden;
position: relative;
}
.marquee p {
position: absolute;
width: 100%;
height: 100%;
margin: 0;
text-align: center;
transform: translateX(100%);
animation: scroll-left 10s linear infinite;
}
@include media-breakpoint-up(md) {
.marquee p {
animation: scroll-left 25s linear infinite;
}
}
@include media-breakpoint-up(xl) {
.marquee p {
animation: scroll-left 45s linear infinite;
}
}
@keyframes scroll-left {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}

View file

@ -0,0 +1,110 @@
<script>
function refreshMixers() {
const mxrs = fetch('api/mixer', {headers: {'Accept': 'application/json'}}).then((res) => res.json());
mxrs.then((m) => {
mixers = m;
altering_mixer = null;
})
}
export let showReadOnly = false;
export let advanced = false;
let mixers = null;
refreshMixers();
setInterval(refreshMixers, 5000);
let altering_mixer = null;
async function alterMixer(mixer, values) {
if (altering_mixer) altering_mixer.abort();
altering_mixer = setTimeout(() => {
fetch(`api/mixer/${mixer.NumID}/values`, {headers: {'Accept': 'application/json'}, method: 'POST', body: JSON.stringify(values ? values : (advanced ? mixer.values : [mixer.values[0]]))}).then(refreshMixers);
altering_mixer = null;
}, 450);
}
</script>
{#if !mixers}
<div class="card-body d-flex flex-fill justify-content-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
{:else}
<ul class="list-group list-group-flush">
{#each mixers as mixer (mixer.NumID)}
{#if showReadOnly || mixer.RW}
<li class="list-group-item py-3">
{#if mixer.items}
<label for={mixer.Name + '0'} class="form-label">{mixer.Name}</label>
{#if mixer.values}
{#each mixer.values as cur, idx}
<select
class="form-select"
disabled={!mixer.RW}
id={mixer.Name + idx}
bind:value={cur}
on:change={() => alterMixer(mixer)}
>
{#each mixer.items as opt, idx}
<option value={idx}>{opt}</option>
{/each}
</select>
{/each}
{/if}
{:else if mixer.Type === "INTEGER"}
<label for={mixer.Name + '0'} class="form-label">{mixer.Name}</label>
{#if mixer.values}
<div class="badge bg-primary float-end">
{mixer.DBScale ? ((mixer.DBScale.Min + mixer.DBScale.Step * mixer.values[0]) + ' dB') : mixer.values[0]}
</div>
{#each mixer.values as cur, idx}
{#if advanced || idx === 0}
<input
type="range"
class="form-range"
disabled={!mixer.RW}
id={mixer.Name + idx}
min={mixer.Min}
max={mixer.Max}
step={mixer.Step}
title={mixer.DBScale ? ((mixer.DBScale.Min + mixer.DBScale.Step * cur) + ' dB') : cur}
bind:value={cur}
on:change={() => alterMixer(mixer)}
>
{/if}
{/each}
{/if}
{:else if mixer.Type === "BOOLEAN"}
{#if mixer.RW}
<div class="btn-group" role="group" aria-label="Basic example">
<button
class="btn"
class:btn-secondary={!mixer.values.reduce((a,b) => a || b)}
class:btn-primary={mixer.values.reduce((a,b) => a || b)}
on:click={() => alterMixer(mixer, [!mixer.values.reduce((a,b) => a || b)])}
>
{mixer.Name}
</button>
{#if advanced && mixer.values.length > 1}
{#each mixer.values as cur, ichan}
<button
class="btn btn-sm"
class:btn-secondary={!cur}
class:btn-primary={cur}
on:click={() => alterMixer(mixer, mixer.values.map((v, i) => i == ichan ? !v : v))}
>
{ichan+1}
</button>
{/each}
{/if}
</div>
{:else}
<label class="form-label">{mixer.Name}</label>
{/if}
{/if}
</li>
{/if}
{/each}
</ul>
{/if}

View file

@ -0,0 +1,45 @@
<script>
import { sources } from '$lib/stores/sources';
let activating_source = null;
async function clickSource(src) {
activating_source = src.id;
if (src.enabled) {
await src.deactivate();
await sources.refresh();
} else {
await src.activate();
await sources.refresh();
}
activating_source = null;
}
</script>
{#if !$sources}
<div class="d-flex flex-fill justify-content-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
{:else}
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-6 row-cols-xl-auto justify-content-center g-2">
{#each Object.keys($sources) as source}
<div class="col d-flex flex-column justify-content-center align-items-center">
<button
class="btn btn-lg"
class:btn-primary={$sources[source].enabled}
class:btn-secondary={!$sources[source].enabled}
disabled={activating_source !== null}
on:click={() => clickSource($sources[source])}
>
{#if activating_source && activating_source === source}
<div class="spinner-grow spinner-grow-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
{/if}
{$sources[source].name}
</button>
</div>
{/each}
</div>
{/if}

1
ui/src/lib/index.js Normal file
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

57
ui/src/lib/source.js Normal file
View file

@ -0,0 +1,57 @@
export class Source {
constructor(id, res) {
this.id = id;
if (res) {
this.update(res);
}
}
update({ name, enabled, active }) {
this.name = name;
this.enabled = enabled;
this.active = active;
}
async activate() {
await fetch(`api/sources/${this.id}/enable`, {headers: {'Accept': 'application/json'}, method: 'POST'});
}
async deactivate() {
await fetch(`api/sources/${this.id}/disable`, {headers: {'Accept': 'application/json'}, method: 'POST'});
}
async currently() {
const data = await fetch(`api/sources/${this.id}/currently`, {headers: {'Accept': 'application/json'}});
if (data.status == 200) {
return await data.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
}
export async function getSources() {
const res = await fetch(`api/sources`, {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 Source(k, data[k]);
});
return data;
}
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function getSource(sid) {
const res = await fetch(`api/sources/${sid}`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return new Source(sid, await res.json());
} else {
throw new Error((await res.json()).errmsg);
}
}

View file

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

1
ui/src/routes/+layout.js Normal file
View file

@ -0,0 +1 @@
export const ssr = false;

View file

@ -0,0 +1,20 @@
<script>
import '../hathoris.scss'
import "bootstrap-icons/font/bootstrap-icons.css";
import { sources } from '$lib/stores/sources';
sources.refresh();
setInterval(sources.refresh, 5000);
const version = fetch('api/version', {headers: {'Accept': 'application/json'}}).then((res) => res.json())
</script>
<svelte:head>
<title>Hathoris</title>
</svelte:head>
<div class="flex-fill d-flex flex-column bg-light">
<div class="container-fluid flex-fill d-flex flex-column justify-content-center">
<slot></slot>
</div>
</div>

View file

@ -0,0 +1,75 @@
<script>
import Mixer from '$lib/components/Mixer.svelte';
import SourceSelection from '$lib/components/SourceSelection.svelte';
import { activeSources } from '$lib/stores/sources';
let mixerAdvanced = false;
</script>
<div class="my-2">
<SourceSelection />
</div>
<div class="container">
{#if $activeSources.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>
<strong>{source.name}&nbsp;:</strong>
{#await source.currently()}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
{:then title}
{title}
{:catch error}
activée
{/await}
</div>
</div>
</div>
{/each}
</marquee>
{/if}
<div class="row">
<div class="col">
<div class="card my-2">
<h4 class="card-header">
<div class="d-flex justify-content-between">
<div>
<i class="bi bi-sliders"></i>
Mixer
</div>
<button
class="btn btn-sm"
class:btn-info={mixerAdvanced}
class:btn-secondary={!mixerAdvanced}
on:click={() => { mixerAdvanced = !mixerAdvanced; }}
>
<i class="bi bi-alt"></i>
</button>
</div>
</h4>
<Mixer advanced={mixerAdvanced} />
</div>
</div>
<div class="col">
<div class="card my-2">
<h4 class="card-header">
<i class="bi bi-speaker"></i>
Sources
</h4>
<div class="card-body">
</div>
</div>
</div>
</div>
</div>