Initial interface
This commit is contained in:
parent
5d0a210e6d
commit
e2013351d1
23 changed files with 3237 additions and 0 deletions
17
ui/src/app.html
Normal file
17
ui/src/app.html
Normal 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
75
ui/src/hathoris.scss
Normal 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%); }
|
||||
}
|
||||
110
ui/src/lib/components/Mixer.svelte
Normal file
110
ui/src/lib/components/Mixer.svelte
Normal 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}
|
||||
45
ui/src/lib/components/SourceSelection.svelte
Normal file
45
ui/src/lib/components/SourceSelection.svelte
Normal 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
1
ui/src/lib/index.js
Normal 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
57
ui/src/lib/source.js
Normal 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);
|
||||
}
|
||||
}
|
||||
41
ui/src/lib/stores/sources.js
Normal file
41
ui/src/lib/stores/sources.js
Normal 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
1
ui/src/routes/+layout.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const ssr = false;
|
||||
20
ui/src/routes/+layout.svelte
Normal file
20
ui/src/routes/+layout.svelte
Normal 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>
|
||||
75
ui/src/routes/+page.svelte
Normal file
75
ui/src/routes/+page.svelte
Normal 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} :</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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue