Files
PLG-MuDiCS/control/frontend/src/routes/ControlView.svelte
T
2026-06-14 19:09:20 +02:00

370 lines
11 KiB
Svelte

<script lang="ts">
import {
ArrowBigLeft,
ArrowBigRight,
ChevronDown,
Keyboard,
Power,
PowerOff,
Presentation,
SquareTerminal,
TextAlignStart,
TrafficCone,
Globe
} from 'lucide-svelte';
import Button from '$lib/components/Button.svelte';
import PopUp from '$lib/components/PopUp.svelte';
import type { PopupContent } from '$lib/ts/types';
import KeyInput from './KeyInput.svelte';
import {
send_keyboard_input,
show_blackscreen,
shutdown,
startup,
open_website
} from '$lib/ts/api_handler';
import {
get_display_by_id,
run_on_all_selected_displays,
selected_online_display_ids
} from '$lib/ts/stores/displays';
import { selected_display_ids } from '$lib/ts/stores/select';
import TipTapInput from './TipTapInput.svelte';
import { db } from '$lib/ts/database';
import { liveQuery, type Observable } from 'dexie';
import TextInput from '$lib/components/TextInput.svelte';
import { add_to_keyboard_queue } from '$lib/ts/utils';
import { onMount } from 'svelte';
let all_display_states: Observable<'on' | 'off' | 'mixed'> | undefined = $state();
$effect(() => {
const ids = $selected_display_ids;
all_display_states = liveQuery(() => all_state(ids));
});
let popup_content: PopupContent = $state({
open: false,
snippet: null,
title: ''
});
let current_text = $state('');
const key_pressed = $state({
ArrowRight: false,
ArrowLeft: false
});
function popup_close_function() {
popup_content.open = false;
}
const show_send_keys_popup = () => {
popup_content = {
open: true,
snippet: send_keys_popup,
title: 'Tastatur-Eingaben Senden',
title_icon: Keyboard,
window_class: 'h-full'
};
};
const show_text_popup = () => {
popup_content = {
open: true,
snippet: text_popup,
title: 'Text Anzeigen',
title_icon: TextAlignStart,
window_class: 'size-full'
};
};
const show_website_popup = () => {
popup_content = {
open: true,
snippet: website_popup,
title: 'Webseite Anzeigen',
window_class: 'w-xl',
title_icon: Globe
};
};
async function all_state(selected_display_ids: string[]): Promise<'on' | 'off' | 'mixed'> {
const selected_displays = await Promise.all(
selected_display_ids.map(async (id) => await get_display_by_id(id))
);
const selected_display_states = selected_displays.map((display) => display?.status ?? '');
if (selected_display_states.every((state) => state === 'app_online')) {
return 'on';
} else if (selected_display_states.every((state) => state !== 'app_online')) {
return 'off';
} else {
return 'mixed';
}
}
async function ask_shutdown() {
popup_content = {
open: true,
snippet: ask_shutdown_popup,
title: 'Bildschirm Herunterfahren',
title_icon: PowerOff
};
}
async function shutdown_action() {
popup_content.open = false;
await run_on_all_selected_displays(async (d) => {
if (await shutdown(d.ip)) {
db.displays.update(d.id, {
status: 'app_offline',
preview: { currently_updating: false, url: null }
});
}
}, false);
}
async function startup_action() {
await run_on_all_selected_displays(
async (d) => {
if (!d.mac) return;
startup(d.mac); // no await here because we want to be fast
db.displays.update(d.id, { status: 'app_offline' });
},
false,
$selected_display_ids
);
}
async function send_single_key_press(key: string, action: 'press' | 'release') {
await run_on_all_selected_displays((d) => send_keyboard_input(d.ip, [{ key, action }]));
}
let website_url = $state('');
let website_url_valid = $state(false);
function validate_website_url(url: string): [boolean, string] {
if (url === '') return [true, ''];
const regex = /^https?:\/\/[\w-]+(\.[\w-]+)+([\w\-._~:/?#[\]@!$&'()*+,;=.])*/;
if (regex.test(url)) {
return [true, ''];
}
return [false, 'Ungültige URL'];
}
async function send_website() {
popup_content.open = false;
await run_on_all_selected_displays((d) => open_website(d.ip, website_url));
}
function has_open_popup(): boolean {
return document.querySelector('.popup') !== null;
}
function handle_key(
event: KeyboardEvent,
key: 'ArrowRight' | 'ArrowLeft',
action: 'press' | 'release'
) {
if (event.key === key) {
const current_press_state: boolean = key_pressed[key];
if (
(action === 'press' && (current_press_state === true || has_open_popup())) ||
(action === 'release' && current_press_state === false)
)
return;
key_pressed[key] = !current_press_state;
add_to_keyboard_queue(async () => {
await run_on_all_selected_displays(
(d) => send_keyboard_input(d.ip, [{ key, action }]),
true
);
});
}
}
function add_remove_event_listeners(
add_remove_function: (type: string, listener: (e: KeyboardEvent) => void) => void
) {
const actions = {
keydown: 'press',
keyup: 'release'
} as const;
const keys = ['ArrowRight', 'ArrowLeft'] as const;
for (const [action_type, action_value] of Object.entries(actions)) {
for (const key of keys) {
add_remove_function(action_type, (e: KeyboardEvent) => handle_key(e, key, action_value));
}
}
}
onMount(() => {
add_remove_event_listeners(window.addEventListener);
return () => {
add_remove_event_listeners(window.removeEventListener);
};
});
</script>
{#snippet website_popup()}
<div class="flex flex-col gap-2">
<TextInput
title="URL (mit http:// oder https://)"
placeholder="https://example.com"
bind:current_value={website_url}
bind:current_valid={website_url_valid}
is_valid_function={validate_website_url}
enter_mode="submit"
enter_function={send_website}
/>
<div class="flex flex-row justify-end gap-2">
<Button className="button space font-bold" click_function={popup_close_function}>
Abbrechen
</Button>
<Button
click_function={send_website}
disabled={!website_url_valid || website_url === ''}
className="button success space">Anzeigen</Button
>
</div>
</div>
{/snippet}
{#snippet ask_shutdown_popup()}
<p>Bist du sicher, dass du alle ausgewählten Displays herunterfahren möchtest?</p>
<div class="flex flex-row justify-end gap-2">
<Button className="button space font-bold" click_function={() => (popup_content.open = false)}>
Abbrechen
</Button>
<Button click_function={shutdown_action} className="button error space">Herunterfahren</Button>
</div>
{/snippet}
{#snippet send_keys_popup()}
<KeyInput {popup_close_function} />
{/snippet}
{#snippet text_popup()}
<TipTapInput bind:text={current_text} />
{/snippet}
<div class="grid grid-rows-[2.5rem_auto] bg-stone-800 rounded-2xl min-w-0">
<div class="text-xl font-bold pl-3 content-center bg-stone-700 rounded-t-2xl truncate min-w-0">
Bildschirme Steuern
</div>
<div class="relative flex flex-col gap-2 p-2 overflow-auto">
<div class="flex flex-row justify-between gap-2">
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 w-75 justify-normal">
<button
title="Vorherige Folie (Pfeil nach Links) [gedrückt halten möglich]"
class="px-9 {key_pressed.ArrowLeft
? 'bg-stone-500'
: 'bg-stone-700'} {$selected_online_display_ids.length === 0
? 'text-stone-500 cursor-not-allowed'
: 'hover:bg-stone-600 active:bg-stone-500 cursor-pointer'} py-2 rounded-xl flex justify-center items-center transition-colors duration-200"
disabled={$selected_online_display_ids.length === 0 || key_pressed.ArrowLeft}
onmousedown={() => {
add_to_keyboard_queue(async () => await send_single_key_press('ArrowLeft', 'press'));
}}
onmouseup={() => {
add_to_keyboard_queue(
async () => await send_single_key_press('ArrowLeft', 'release')
);
}}
>
<ArrowBigLeft />
</button>
<button
title="Nächste Folie (Pfeil nach Rechts) [gedrückt halten möglich]"
class="px-9 {key_pressed.ArrowRight
? 'bg-stone-500 cursor-not-allowed'
: `bg-stone-700 ${
$selected_online_display_ids.length === 0
? 'text-stone-500 cursor-not-allowed'
: 'hover:bg-stone-600 active:bg-stone-500 cursor-pointer'
}`} py-2 rounded-xl flex justify-center items-center transition-colors duration-200"
disabled={$selected_online_display_ids.length === 0 || key_pressed.ArrowRight}
onmousedown={() => {
add_to_keyboard_queue(async () => await send_single_key_press('ArrowRight', 'press'));
}}
onmouseup={() => {
add_to_keyboard_queue(
async () => await send_single_key_press('ArrowRight', 'release')
);
}}
>
<ArrowBigRight />
</button>
</div>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
click_function={show_text_popup}
disabled={$selected_online_display_ids.length === 0}
><TextAlignStart /> Text Anzeigen</Button
>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={$selected_online_display_ids.length === 0}
click_function={show_website_popup}><Globe /> Webseite Anzeigen</Button
>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={$selected_online_display_ids.length === 0}
click_function={async () => {
await run_on_all_selected_displays((d) => show_blackscreen(d.ip));
}}><Presentation />Blackout</Button
>
<div class="flex flex-row justify-normal">
<Button className="rounded-r-none pl-3 flex gap-3 grow w-65 justify-normal" disabled>
<TrafficCone /> Fallback-Bild Anzeigen
</Button>
<Button className="rounded-l-none flex grow-0 w-10" disabled><ChevronDown /></Button>
</div>
<Button
className="px-3 flex gap-3 w-75 justify-normal"
disabled={$selected_online_display_ids.length === 0}
click_function={show_send_keys_popup}><Keyboard /> Tastatur-Eingaben Senden</Button
>
</div>
<div class="flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2">
<Button
className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
disabled={$all_display_states === 'on' || $selected_display_ids.length === 0}
click_function={startup_action}
>
<Power /> Bildschirm Hochfahren
</Button>
<Button
className="px-3 flex gap-3 w-full xl:w-75 justify-normal"
disabled={$all_display_states === 'off' || $selected_online_display_ids.length === 0}
click_function={ask_shutdown}
>
<PowerOff /> Bildschirm Herunterfahren</Button
>
</div>
<Button className="px-3 flex gap-3 w-full xl:w-75 justify-normal" disabled>
<SquareTerminal />
Shell-Befehl Ausführen
</Button>
</div>
</div>
<PopUp
content={popup_content}
close_function={popup_close_function}
className="rounded-b-2xl"
snippet_container_class="size-full"
/>
</div>
</div>