1193 lines
47 KiB
Svelte
1193 lines
47 KiB
Svelte
<script lang="ts">
|
||
let log_lvl: number = $state(0);
|
||
interface Props {
|
||
/** @type {import('./$types').LayoutData} */
|
||
data: any;
|
||
children?: import('svelte').Snippet;
|
||
}
|
||
|
||
let { data, children }: Props = $props();
|
||
|
||
// *** Import Svelte specific
|
||
import { untrack } from 'svelte';
|
||
// import { onMount, tick } from 'svelte';
|
||
import { goto } from '$app/navigation';
|
||
import { sineIn } from 'svelte/easing';
|
||
|
||
// *** Import other supporting libraries
|
||
import { liveQuery } from 'dexie';
|
||
import { Drawer, Modal } from 'flowbite-svelte';
|
||
import { listen, idle, onIdle, restartCountdown } from 'svelte-idle';
|
||
|
||
import {
|
||
LoaderCircle,
|
||
Satellite,
|
||
MapPin,
|
||
Laptop,
|
||
BedDouble,
|
||
MousePointer,
|
||
Wifi,
|
||
Network,
|
||
X,
|
||
CalendarDays,
|
||
Clock,
|
||
Biohazard,
|
||
Search,
|
||
GraduationCap,
|
||
Info,
|
||
ZoomIn,
|
||
Minimize2,
|
||
Monitor,
|
||
List
|
||
} from '@lucide/svelte';
|
||
|
||
// *** Import Aether specific variables and functions
|
||
import type { key_val } from '$lib/stores/ae_stores';
|
||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||
import { api } from '$lib/api/api';
|
||
import { db_events } from '$lib/ae_events/db_events';
|
||
import {
|
||
ae_snip,
|
||
ae_loc,
|
||
ae_sess,
|
||
ae_api,
|
||
ae_trig,
|
||
slct,
|
||
slct_trigger,
|
||
time
|
||
} from '$lib/stores/ae_stores';
|
||
import {
|
||
events_loc,
|
||
events_sess,
|
||
events_slct,
|
||
events_trigger,
|
||
events_trig
|
||
} from '$lib/stores/ae_events_stores';
|
||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||
|
||
import Launcher_cfg from '../launcher_cfg.svelte';
|
||
import Launcher_menu from '../launcher_menu.svelte';
|
||
import Launcher_session_view from '../launcher_session_view.svelte';
|
||
import Element_websocket from '$lib/elements/element_websocket.svelte';
|
||
|
||
// *** Set initial variables
|
||
// NOTE: Derived from data.account_id (prop) instead of $slct.account_id (store)
|
||
// to prevent circular dependency loops during hydration.
|
||
let ae_acct = $derived(data[data.account_id]);
|
||
|
||
import { online } from 'svelte/reactivity/window';
|
||
|
||
$ae_sess.disable_sys_nav = true;
|
||
$ae_sess.disable_sys_header = true;
|
||
$ae_sess.disable_sys_footer = true;
|
||
|
||
if (!$events_loc?.launcher) {
|
||
$events_loc.launcher = {
|
||
app_mode: 'default',
|
||
controller: 'local',
|
||
controller_group_code: 'launcher-00',
|
||
ws_connect: false,
|
||
hide_drawer__cfg: true,
|
||
hide_drawer__debug: true
|
||
};
|
||
}
|
||
|
||
// Generate a stable per-device client ID on first load and persist it.
|
||
// events_loc is backed by svelte-persisted-store (localStorage) so this
|
||
// survives page reloads. Without this, client_id falls back to Date.now()
|
||
// inside element_websocket — a new ID on every reload, which breaks
|
||
// direct-target WS messages and doesn't match V3 Vision ID expectations.
|
||
if (!$events_loc.launcher.controller_client_id) {
|
||
$events_loc.launcher.controller_client_id = crypto.randomUUID();
|
||
}
|
||
|
||
// Unified Selection Sync (Refactored 2026-02-11)
|
||
// WHY: We track URL params directly to ensure the UI reacts instantly to navigation.
|
||
// We use untrack for store writes to prevent circular dependency loops.
|
||
$effect(() => {
|
||
const url_session_id = data.url.searchParams.get('session_id');
|
||
const path_location_id = data.params.event_location_id;
|
||
const path_event_id = data.params.event_id;
|
||
|
||
// WHY: URL params for display overrides — lets you share a clean link
|
||
// (e.g. tablet PWA, digital poster kiosk) without the recipient needing
|
||
// to configure settings manually on their device.
|
||
// These are read on every URL change (reactive to SPA navigation), which
|
||
// is better than the root layout's onMount which only fires once on load.
|
||
// Values persist in localStorage so they survive page reloads within the
|
||
// same device; the URL param always wins when present.
|
||
//
|
||
// Supported params:
|
||
// ?iframe=true/false — hide/show global sys & debug menus
|
||
// ?launcher_menu=hide/show — hide/show left session/location panel
|
||
// ?launcher_header=hide/show — hide/show "Æ Launcher v3" header bar
|
||
// ?launcher_footer=hide/show — hide/show the status footer
|
||
//
|
||
// Example clean poster-kiosk link:
|
||
// /events/{id}/launcher/{loc_id}?session_id={sess_id}
|
||
// &iframe=true&launcher_menu=hide&launcher_header=hide
|
||
const param_iframe = data.url.searchParams.get('iframe');
|
||
const param_launcher_menu = data.url.searchParams.get('launcher_menu');
|
||
const param_launcher_header = data.url.searchParams.get('launcher_header');
|
||
const param_launcher_footer = data.url.searchParams.get('launcher_footer');
|
||
|
||
if (log_lvl > 1) {
|
||
console.log(`[Launcher Sync] URL Change: event=${path_event_id}, loc=${path_location_id}, sess=${url_session_id}`);
|
||
}
|
||
|
||
untrack(() => {
|
||
if ($events_slct.event_id !== path_event_id) {
|
||
$events_slct.event_id = path_event_id;
|
||
}
|
||
if ($events_slct.event_location_id !== path_location_id) {
|
||
$events_slct.event_location_id = path_location_id;
|
||
}
|
||
// CRITICAL: Ensure session_id is synced to store so LiveQueries react
|
||
if ($events_slct.event_session_id !== url_session_id) {
|
||
if (log_lvl) console.log(`[Launcher Sync] Updating store session_id: ${url_session_id}`);
|
||
$events_slct.event_session_id = url_session_id;
|
||
}
|
||
|
||
// Apply display overrides from URL params (when present)
|
||
if (param_iframe === 'true') $ae_loc.iframe = true;
|
||
else if (param_iframe === 'false') $ae_loc.iframe = false;
|
||
|
||
if (param_launcher_menu === 'hide') $events_loc.launcher.hide__launcher_menu = true;
|
||
else if (param_launcher_menu === 'show') $events_loc.launcher.hide__launcher_menu = false;
|
||
|
||
if (param_launcher_header === 'hide') $events_loc.launcher.hide__launcher_header = true;
|
||
else if (param_launcher_header === 'show') $events_loc.launcher.hide__launcher_header = false;
|
||
|
||
if (param_launcher_footer === 'hide') $events_loc.launcher.hide__launcher_footer = true;
|
||
else if (param_launcher_footer === 'show') $events_loc.launcher.hide__launcher_footer = false;
|
||
});
|
||
|
||
// Strip launcher display params from the URL after applying them — same pattern
|
||
// as root layout does for ?theme / ?theme_mode. These are "one-shot" apply params;
|
||
// keeping them in the URL is misleading because toggling the CFG panel would desync.
|
||
// ?iframe is intentionally left in the URL because it is a persistent mode flag.
|
||
if (param_launcher_menu || param_launcher_header || param_launcher_footer) {
|
||
const clean_url = new URL(data.url.href);
|
||
clean_url.searchParams.delete('launcher_menu');
|
||
clean_url.searchParams.delete('launcher_header');
|
||
clean_url.searchParams.delete('launcher_footer');
|
||
goto(clean_url.pathname + clean_url.search + clean_url.hash, {
|
||
replaceState: true,
|
||
noScroll: true,
|
||
keepFocus: true
|
||
});
|
||
}
|
||
});
|
||
|
||
// String-Only ID Vision: Sync the device ID from the native environment
|
||
const native_dev = $derived($ae_loc.native_device);
|
||
$effect(() => {
|
||
if (native_dev) {
|
||
untrack(() => {
|
||
$events_slct.event_device_id =
|
||
native_dev.event_device_id ||
|
||
native_dev.id ||
|
||
native_dev.event_device_id_random ||
|
||
native_dev.id_random;
|
||
});
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
if (ae_acct) {
|
||
untrack(() => {
|
||
const new_location_obj_li = ae_acct.slct.event_location_obj_li ?? [''];
|
||
// Compare by extracting IDs only — object identity (===) won't work for
|
||
// plain JS objects from the store. Joining IDs is cheap and avoids a full
|
||
// JSON.stringify of potentially large location objects on every navigation.
|
||
const current_obj_ids = ($events_slct.event_location_obj_li ?? [])
|
||
.map((o: any) => o?.event_location_id ?? o)
|
||
.join(',');
|
||
const new_obj_ids = new_location_obj_li
|
||
.map((o: any) => o?.event_location_id ?? o)
|
||
.join(',');
|
||
if (current_obj_ids !== new_obj_ids) {
|
||
$events_slct.event_location_obj_li = new_location_obj_li;
|
||
}
|
||
|
||
const new_id_li__event_location = ae_acct.slct.id_li__event_location ?? [''];
|
||
// ID list contains plain strings — join-compare is O(n) and avoids JSON.stringify.
|
||
if (($events_slct.id_li__event_location ?? []).join(',') !== new_id_li__event_location.join(',')) {
|
||
$events_slct.id_li__event_location = new_id_li__event_location;
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// *** Functions and Logic
|
||
|
||
// Event
|
||
let lq__event_obj = liveQuery(async () => {
|
||
const id = $events_slct?.event_id;
|
||
if (!id) return null;
|
||
if (log_lvl > 1) console.log(`lq__event_obj: event_id = ${id}`);
|
||
return await db_events.event.get(id);
|
||
});
|
||
|
||
// Event Device
|
||
let lq__event_device_obj = liveQuery(async () => {
|
||
const id = $events_slct.event_device_id;
|
||
if (!id) return null;
|
||
return await db_events.device.get(id);
|
||
});
|
||
|
||
// Event File - For Event
|
||
let lq__event_event_file_obj_li = liveQuery(async () => {
|
||
const id = $events_slct.event_id;
|
||
if (!id) return [];
|
||
return await db_events.file
|
||
.where('for_id')
|
||
.equals(id)
|
||
.sortBy('filename');
|
||
});
|
||
|
||
// Event File - For Location
|
||
// $derived.by: must recreate the observable when event_location_id changes.
|
||
// Plain liveQuery() only re-fires when Dexie detects a change in the initially
|
||
// watched index range — if the id starts as null and changes to a real value,
|
||
// Dexie never fires because a different range was added (not the null range).
|
||
let lq__location_event_file_obj_li = $derived.by(() => {
|
||
const id = $events_slct.event_location_id;
|
||
return liveQuery(async () => {
|
||
if (!id) return [];
|
||
return await db_events.file
|
||
.where('for_id')
|
||
.equals(id)
|
||
.sortBy('filename');
|
||
});
|
||
});
|
||
|
||
// Event Location — same reason as above
|
||
let lq__event_location_obj = $derived.by(() => {
|
||
const id = $events_slct.event_location_id;
|
||
return liveQuery(async () => {
|
||
if (!id) return null;
|
||
return await db_events.location.get(id);
|
||
});
|
||
});
|
||
|
||
let lq__event_location_obj_li = liveQuery(async () => {
|
||
const id = $events_slct.event_id;
|
||
if (!id) return [];
|
||
return await db_events.location
|
||
.where('event_id')
|
||
.equals(id)
|
||
.sortBy('name');
|
||
});
|
||
|
||
// $derived.by: must recreate when event_location_id changes (see comment above).
|
||
let lq__event_session_obj_li = $derived.by(() => {
|
||
const id = $events_slct.event_location_id;
|
||
return liveQuery(async () => {
|
||
if (!id) return [];
|
||
if (log_lvl > 1)
|
||
console.log(`LQ - Event Session list location_id: ${id}`);
|
||
// Note: .reverse() before .sortBy() is a no-op — sortBy always re-sorts.
|
||
return await db_events.session
|
||
.where('event_location_id')
|
||
.equals(id)
|
||
.sortBy('name');
|
||
});
|
||
});
|
||
|
||
// Event Session (Main View Trigger - Needed for Global Header/Idle)
|
||
// $derived.by: capture ID in outer closure so Svelte tracks it as a dependency.
|
||
// liveQuery callback runs in Dexie's async context where Svelte tracking is off.
|
||
let lq__event_session_obj = $derived.by(() => {
|
||
const id = $events_slct.event_session_id;
|
||
return liveQuery(() => db_events.session.get(id));
|
||
});
|
||
|
||
// Store sync effects — keep liveQuery closures pure (data-only) and sync to
|
||
// $events_slct here in reactive effects instead. Comparing updated_on + id is
|
||
// O(1) vs O(serialized-bytes) for JSON.stringify and avoids running inside a
|
||
// Dexie async context where Svelte's reactivity tracking is undefined.
|
||
$effect(() => {
|
||
const result = $lq__event_obj;
|
||
if (result) {
|
||
untrack(() => {
|
||
if (result.updated_on !== $events_slct.event_obj?.updated_on ||
|
||
result.id !== $events_slct.event_obj?.id) {
|
||
$events_slct.event_obj = { ...result };
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
const result = $lq__event_device_obj;
|
||
if (result) {
|
||
untrack(() => {
|
||
if (result.updated_on !== $events_slct.event_device_obj?.updated_on ||
|
||
result.id !== $events_slct.event_device_obj?.id) {
|
||
$events_slct.event_device_obj = { ...result };
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
const results = $lq__event_session_obj_li;
|
||
if (results) {
|
||
untrack(() => {
|
||
const current = $events_slct.event_session_obj_li ?? [];
|
||
// Compare by joining IDs — O(n) string compare vs O(n*m) JSON.stringify.
|
||
const new_ids = (results as any[]).map((r: any) => r.id ?? r.event_session_id).join(',');
|
||
const cur_ids = current.map((r: any) => r.id ?? r.event_session_id).join(',');
|
||
if (new_ids !== cur_ids) {
|
||
$events_slct.event_session_obj_li = [...(results as any[])];
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
let trigger_handle_ws_conn = $state(false);
|
||
let trigger_handle_ws_recv = $state(false);
|
||
let trigger_handle_ws_sent = $state(false);
|
||
|
||
/* *** BEGIN *** Handle WebSocket events */
|
||
function handle_ws_conn(ws_conn_status: any) {
|
||
if (log_lvl) console.log('*** handle_ws_conn() ***', ws_conn_status);
|
||
if (ws_conn_status.status == 'connected') {
|
||
$events_sess.launcher.ws = { status: 'connected' };
|
||
} else {
|
||
$events_sess.launcher.ws = { status: 'disconnected' };
|
||
}
|
||
}
|
||
|
||
function handle_ws_recv(ws_recv_status: any) {
|
||
if (log_lvl) console.log('*** handle_ws_recv() ***', ws_recv_status);
|
||
// V3 schema uses msg_type instead of type
|
||
if (ws_recv_status.msg_type == 'cmd' && ws_recv_status.cmd) {
|
||
let cmd = ws_recv_status.cmd;
|
||
if ($events_loc.launcher.controller != 'remote') return;
|
||
|
||
if (cmd.startsWith('ae_load:')) {
|
||
let cmd_parts = cmd.split(':');
|
||
let obj_parts = cmd_parts[1].split('=');
|
||
let obj_type = obj_parts[0];
|
||
let obj_id = obj_parts[1];
|
||
|
||
if (obj_type == 'event_session') {
|
||
$events_slct.event_session_id = obj_id;
|
||
let new_url = new URL(data.url);
|
||
new_url.pathname = `/events/${data.params.event_id}/launcher/${$events_slct.event_location_id}`;
|
||
new_url.searchParams.set(
|
||
'session_id',
|
||
$events_slct.event_session_id
|
||
);
|
||
goto(new_url.toString(), { replaceState: false });
|
||
}
|
||
} else if (cmd.startsWith('ae_download:')) {
|
||
let cmd_parts = cmd.split(':');
|
||
let obj_parts = cmd_parts[1].split('=');
|
||
let obj_type = obj_parts[0];
|
||
let obj_id = obj_parts[1];
|
||
let obj_filename = cmd_parts[2];
|
||
|
||
api.get_object({
|
||
api_cfg: $ae_api,
|
||
endpoint: `/v3/action/hosted_file/${obj_id}/download`,
|
||
params: {
|
||
filename: obj_filename,
|
||
key: $ae_api.account_id
|
||
},
|
||
filename: obj_filename,
|
||
return_blob: true,
|
||
auto_download: true,
|
||
log_lvl: 1
|
||
});
|
||
} else if (cmd.startsWith('ae_open:')) {
|
||
let cmd_parts = cmd.split(':');
|
||
let obj_parts = cmd_parts[1].split('=');
|
||
let obj_id = obj_parts[1];
|
||
|
||
if (obj_parts[0] == 'event_file') {
|
||
$events_sess.launcher.modal__open_event_file_id = null;
|
||
clearInterval(idle_timer_interval);
|
||
saver_looping = false;
|
||
restartCountdown();
|
||
$events_slct.event_file_id = obj_id;
|
||
$events_sess.launcher.modal__open_event_file_id = obj_id;
|
||
// Look up the file object from Dexie so the modal can render the image.
|
||
// The remote device has no event_file_obj in scope — only the ID was sent over WS.
|
||
// Also look up the parent presentation to use its name as the modal title
|
||
// (cleaner for the LCD display than a raw filename).
|
||
db_events.file.get(obj_id).then(async (file_obj: any) => {
|
||
if (file_obj) {
|
||
$events_sess.launcher.modal__event_file_obj = file_obj;
|
||
const presentation = file_obj.for_id
|
||
? await db_events.presentation.get(file_obj.for_id)
|
||
: null;
|
||
$events_sess.launcher.modal__title =
|
||
presentation?.name ?? file_obj.filename ?? '';
|
||
}
|
||
});
|
||
}
|
||
} else if (cmd.startsWith('ae_close:')) {
|
||
if (cmd.split(':')[1] == 'event_file_modal') {
|
||
$events_sess.launcher.modal__open_event_file_id = null;
|
||
}
|
||
clearInterval(idle_timer_interval);
|
||
saver_looping = false;
|
||
restartCountdown();
|
||
} else if (cmd.startsWith('ae_zoom:')) {
|
||
// WHY: Controller can push zoom state to the remote display so both
|
||
// devices stay in sync (e.g. operator zooms to show detail to an attendee
|
||
// while the wall screen also zooms in for the room to see).
|
||
const zoom_target = cmd.split(':')[1];
|
||
if (zoom_target === 'fit') modal_zoom_fit = true;
|
||
else if (zoom_target === 'zoom') modal_zoom_fit = false;
|
||
} else if (cmd.startsWith('ae_refresh:')) {
|
||
if (cmd.split(':')[1] == 'now') location.reload();
|
||
} else if (cmd.startsWith('ae_mode:')) {
|
||
// WHY: Allows a controller to remotely push a display mode preset
|
||
// (e.g. switch all poster kiosks into kiosk mode without touching each device).
|
||
const mode_target = cmd.split(':')[1];
|
||
if (mode_target === 'poster') {
|
||
$ae_loc.iframe = true;
|
||
$events_loc.launcher.hide__launcher_menu = true;
|
||
$events_loc.launcher.hide__launcher_header = true;
|
||
$events_loc.launcher.hide__launcher_footer = true;
|
||
} else if (mode_target === 'oral') {
|
||
$ae_loc.iframe = false;
|
||
$events_loc.launcher.hide__launcher_menu = false;
|
||
$events_loc.launcher.hide__launcher_header = false;
|
||
$events_loc.launcher.hide__launcher_footer = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function handle_ws_sent(ws_sent_status: any) {
|
||
$events_sess.launcher.controller_cmd = null;
|
||
$events_sess.launcher.controller_trigger_send = null;
|
||
}
|
||
|
||
$effect(() => {
|
||
if (trigger_handle_ws_conn) {
|
||
handle_ws_conn(trigger_handle_ws_conn);
|
||
trigger_handle_ws_conn = false;
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
if (trigger_handle_ws_recv) {
|
||
handle_ws_recv(trigger_handle_ws_recv);
|
||
trigger_handle_ws_recv = false;
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
if (trigger_handle_ws_sent) {
|
||
handle_ws_sent(trigger_handle_ws_sent);
|
||
trigger_handle_ws_sent = false;
|
||
}
|
||
});
|
||
|
||
if (!$events_loc.launcher.idle_timer)
|
||
$events_loc.launcher.idle_timer = 5 * 60 * 1000;
|
||
if (!$events_loc.launcher.idle_cycle)
|
||
$events_loc.launcher.idle_cycle = 5 * 1000;
|
||
if (!$events_loc.launcher.idle_loop_period)
|
||
$events_loc.launcher.idle_loop_period = 3 * 60 * 1000;
|
||
|
||
listen({
|
||
timer: $events_loc.launcher.idle_timer,
|
||
cycle: $events_loc.launcher.idle_cycle
|
||
});
|
||
|
||
let idle_timer_interval: any = $state();
|
||
let saver_looping: boolean = $state(false);
|
||
// Tracks fit vs. zoom mode for the poster modal.
|
||
// Reset to fit whenever a new poster opens so every poster starts clean.
|
||
let modal_zoom_fit: boolean = $state(true);
|
||
|
||
function handle_idle_client() {
|
||
if (
|
||
$lq__event_session_obj &&
|
||
$lq__event_session_obj?.type_code == 'poster'
|
||
) {
|
||
if (saver_looping) return false;
|
||
saver_looping = true;
|
||
|
||
idle_timer_interval = setInterval(
|
||
() => {
|
||
if ($events_loc.launcher.screen_saver_img_kv) {
|
||
const keys = Object.keys(
|
||
$events_loc.launcher.screen_saver_img_kv
|
||
);
|
||
const rand_index = Math.floor(
|
||
Math.random() * keys.length
|
||
);
|
||
let event_file_obj =
|
||
$events_loc.launcher.screen_saver_img_kv[
|
||
keys[rand_index]
|
||
];
|
||
|
||
$events_slct.event_file_id =
|
||
event_file_obj.event_file_id;
|
||
$events_slct.event_file_obj = event_file_obj;
|
||
$events_sess.launcher.modal__open_event_file_id = null;
|
||
$events_sess.launcher.modal__title =
|
||
event_file_obj.filename ?? '*';
|
||
$events_sess.launcher.modal__open_event_file_id =
|
||
$events_slct.event_file_id;
|
||
$events_sess.launcher.modal__event_file_obj =
|
||
event_file_obj;
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
$events_loc.launcher.idle_loop_period ?? 2 * 60 * 1000
|
||
);
|
||
} else {
|
||
saver_looping = false;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
onIdle(() => {
|
||
clearInterval(idle_timer_interval);
|
||
handle_idle_client();
|
||
});
|
||
|
||
$effect(() => {
|
||
if (!$idle) {
|
||
clearInterval(idle_timer_interval);
|
||
saver_looping = false;
|
||
}
|
||
});
|
||
|
||
// Reset to fit-mode whenever a different poster is opened.
|
||
$effect(() => {
|
||
if ($events_sess.launcher.modal__open_event_file_id) {
|
||
modal_zoom_fit = true;
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>
|
||
Æ:
|
||
{$lq__event_location_obj?.name ?? '-- not set --'}
|
||
({$lq__event_session_obj?.name ?? 'Æ loading...'}) - Launcher v3 -
|
||
{$events_loc?.title}
|
||
</title>
|
||
</svelte:head>
|
||
|
||
<div
|
||
class:mt-12={!$events_loc.launcher.hide__launcher_header}
|
||
class:mt-2={$events_loc.launcher.hide__launcher_header}
|
||
class="
|
||
static
|
||
m-auto
|
||
border-x border-gray-200 dark:border-gray-600
|
||
mb-16 sm:mb-12
|
||
h-full
|
||
w-full max-w-7xl
|
||
transition-all
|
||
"
|
||
>
|
||
<header
|
||
id="Main-Header"
|
||
class:hidden={$events_loc.launcher.hide__launcher_header}
|
||
class="
|
||
z-20
|
||
absolute top-0 left-0 right-0
|
||
w-full max-w-7xl
|
||
h-12
|
||
p-1 px-12 m-auto
|
||
|
||
flex flex-row items-center justify-around sm:justify-between
|
||
|
||
text-sm
|
||
|
||
bg-slate-200 dark:bg-slate-800
|
||
|
||
opacity-95 hover:opacity-100
|
||
transition-colors duration-300
|
||
"
|
||
>
|
||
<h3 class="h4 text-center italic text-surface-600-400">
|
||
<!-- Menu toggle: needs a real tap target for tablet/touch operators -->
|
||
<button
|
||
type="button"
|
||
class="px-2 py-1 rounded hover:bg-surface-500/10 transition-colors"
|
||
onclick={() => {
|
||
$events_loc.launcher.hide__launcher_menu =
|
||
!$events_loc.launcher.hide__launcher_menu;
|
||
}}
|
||
title="Toggle Launcher menu"
|
||
>
|
||
<Satellite class="text-base mx-1 inline-block text-gray-500" />
|
||
<abbr title="Aether - Events Module Launcher">
|
||
Æ Launcher
|
||
<span
|
||
class="text-xs align-super font-normal"
|
||
title="Version 3">v3</span
|
||
>
|
||
</abbr>
|
||
</button>
|
||
</h3>
|
||
|
||
{#if $lq__event_obj}
|
||
<h2
|
||
class="hidden md:inline-block h3 text-center text-surface-600-400"
|
||
>
|
||
{$lq__event_obj.cfg_json?.short_name}
|
||
</h2>
|
||
<h3
|
||
class="h4 text-center italic text-surface-600-400"
|
||
title="Location ID: {$lq__event_location_obj?.event_location_id} Name: {$lq__event_location_obj?.name}"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="text-base"
|
||
onclick={() => {
|
||
$ae_loc.edit_mode = !$ae_loc.edit_mode;
|
||
}}
|
||
title="Toggle Edit Mode to show location options and more"
|
||
>
|
||
<MapPin size="1em" />
|
||
<span class="sr-only">Location:</span>
|
||
</button>
|
||
{$lq__event_location_obj?.name}
|
||
</h3>
|
||
{:else}
|
||
<div class="flex flex-row gap-1 items-center justify-center">
|
||
<LoaderCircle size="1em" class="animate-spin mx-1" />
|
||
<span>Loading event...</span>
|
||
</div>
|
||
{/if}
|
||
</header>
|
||
|
||
<div
|
||
class="
|
||
h-full min-w-full w-full max-w-full
|
||
flex flex-col sm:flex-row flex-wrap sm:flex-nowrap gap-0
|
||
items-center
|
||
justify-start sm:justify-center
|
||
py-1 px-0.5
|
||
bg-gray-100 dark:bg-gray-900
|
||
|
||
"
|
||
>
|
||
<section
|
||
id="Main-Nav-Menu"
|
||
class="event_launcher_menu
|
||
h-full
|
||
basis-1/5
|
||
min-w-56 md:min-w-64 lg:min-w-72
|
||
max-w-xs
|
||
pt-0.5 pr-0.5
|
||
flex flex-col gap-1 items-center justify-start
|
||
overflow-y-auto
|
||
|
||
border-r border-gray-200 dark:border-gray-700
|
||
"
|
||
class:hidden={$events_loc.launcher.hide__launcher_menu}
|
||
>
|
||
<Launcher_menu
|
||
{lq__event_obj}
|
||
{lq__event_event_file_obj_li}
|
||
{lq__location_event_file_obj_li}
|
||
slct__event_file_id={$events_slct.event_file_id}
|
||
{lq__event_location_obj_li}
|
||
{lq__event_location_obj}
|
||
slct__event_location_id={$events_slct.event_location_id}
|
||
bind:loading__session_li_status={
|
||
$events_sess.launcher.loading__session_li_status
|
||
}
|
||
{lq__event_session_obj_li}
|
||
bind:loading__session_id_status={
|
||
$events_sess.launcher.loading__session_id_status
|
||
}
|
||
bind:slct__event_session_id={$events_slct.event_session_id}
|
||
bind:trigger_reload__event_session_obj_id={
|
||
$events_sess.launcher.trigger_reload__event_session_obj_id
|
||
}
|
||
bind:trigger_reload__event_session_obj_li={
|
||
$events_sess.launcher.trigger_reload__event_session_obj_li
|
||
}
|
||
bind:trigger_reload__event_location_obj_li={
|
||
$events_sess.launcher.trigger_reload__event_location_obj_li
|
||
}
|
||
></Launcher_menu>
|
||
</section>
|
||
|
||
<section
|
||
id="Main-Content"
|
||
class="event_launcher_main
|
||
h-full
|
||
min-w-xs
|
||
max-w-full
|
||
py-1 px-0.5
|
||
basis-4/5
|
||
flex flex-col gap-1
|
||
items-center
|
||
justify-center
|
||
overflow-y-auto
|
||
"
|
||
>
|
||
{#if !$events_slct.event_location_id}
|
||
<div
|
||
class="flex flex-row items-center justify-center p-8 opacity-50"
|
||
>
|
||
<MapPin size="1.5em" class="mx-2" />
|
||
<span>Please select a location from the menu</span>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if $events_slct.event_session_id}
|
||
<Launcher_session_view
|
||
bind:slct__event_session_id={$events_slct.event_session_id}
|
||
></Launcher_session_view>
|
||
{:else if $events_slct.event_location_id}
|
||
<!-- Location selected but no session chosen yet — prompt operator -->
|
||
<div
|
||
class="flex flex-col items-center justify-center p-8 opacity-50"
|
||
>
|
||
<LoaderCircle class="animate-spin mb-2" />
|
||
<span>Select a session from the menu</span>
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
</div>
|
||
|
||
{@render children?.()}
|
||
</div>
|
||
|
||
<footer
|
||
id="Main-Footer"
|
||
class:hidden={$events_loc.launcher.hide__launcher_footer}
|
||
class="
|
||
z-20
|
||
absolute bottom-0 left-0 right-0
|
||
w-full max-w-7xl
|
||
p-1 m-auto
|
||
|
||
flex flex-row items-center justify-between
|
||
|
||
text-xs
|
||
|
||
bg-gray-200 border-t border-gray-300
|
||
dark:bg-gray-800 dark:border-gray-600
|
||
|
||
opacity-70 hover:opacity-100
|
||
transition-opacity duration-500
|
||
"
|
||
>
|
||
<div
|
||
class="slct_location_name transition-colors duration-300"
|
||
title="Location ID: {$lq__event_location_obj?.event_location_id} Name: {$lq__event_location_obj?.name} | Device ID: {$lq__event_device_obj?.event_device_id} Name: {$lq__event_device_obj?.name}"
|
||
>
|
||
<!-- Edit mode toggle: needs tap target for tablet operators -->
|
||
<button
|
||
type="button"
|
||
class="px-1.5 py-1 rounded hover:bg-surface-500/10 transition-colors"
|
||
onclick={() => {
|
||
$ae_loc.edit_mode = !$ae_loc.edit_mode;
|
||
}}
|
||
title="Toggle Edit Mode to show location options and more"
|
||
>
|
||
<span class="sr-only">Location:</span>
|
||
<MapPin size="1em" />
|
||
</button>
|
||
{$lq__event_location_obj?.name}
|
||
{#if $lq__event_device_obj?.name}
|
||
<Laptop size="1em" class="mx-1" />
|
||
{$lq__event_device_obj?.name}
|
||
{/if}
|
||
</div>
|
||
|
||
<span
|
||
class:preset-tonal-warning={!$idle}
|
||
class:preset-tonal-success={$idle}
|
||
class="group px-2 py-0.5 rounded-md transition-colors duration-300"
|
||
title="The user is currently {$idle ? 'idle' : 'active'}"
|
||
>
|
||
{#if $idle}
|
||
<BedDouble size="1em" class="mx-1" />
|
||
<span class="hidden group-hover:inline"> Idle </span>
|
||
{:else}
|
||
<MousePointer size="1em" class="mx-1" />
|
||
<span class="hidden group-hover:inline"> Active </span>
|
||
{/if}
|
||
</span>
|
||
|
||
<span
|
||
class="group px-2 py-0.5 rounded-md transition-colors duration-300"
|
||
title="Online status = {online?.current}"
|
||
>
|
||
<Wifi size="1em" class="mx-1" />
|
||
{online?.current ? '' : 'Offline!'}
|
||
</span>
|
||
|
||
<span
|
||
class:hidden={!$events_loc.launcher.ws_connect}
|
||
class:preset-tonal-warning={$events_sess.launcher.ws_connect_status !=
|
||
'connected'}
|
||
class:preset-tonal-success={$events_sess.launcher.ws_connect_status ==
|
||
'connected'}
|
||
class="group px-2 py-0.5 rounded-md transition-colors duration-300"
|
||
title="WebSocket is {$events_sess.launcher.ws_connect_status ==
|
||
'connected'
|
||
? 'connected'
|
||
: 'disconnected'} API: {$ae_api?.base_url}"
|
||
>
|
||
{#if $events_sess.launcher.ws_connect_status == 'connected'}
|
||
<Network size="1em" class="mx-1 text-green-700" />
|
||
<span class="hidden group-hover:inline"> WS Connected </span>
|
||
{:else}
|
||
<X size="1em" class="mx-1" />
|
||
<span class="hidden group-hover:inline"> WS Disconnected </span>
|
||
{/if}
|
||
</span>
|
||
|
||
<!-- Font size cycler: allows onsite operators to adjust text size without needing
|
||
access to the global sys menu, which may be unavailable in kiosk/locked setups. -->
|
||
<button
|
||
type="button"
|
||
onclick={() => {
|
||
const mode = $ae_loc.font_size_mode;
|
||
if (!mode || mode === 'default') $ae_loc.font_size_mode = 'larger';
|
||
else if (mode === 'larger') $ae_loc.font_size_mode = 'smaller';
|
||
else $ae_loc.font_size_mode = 'default';
|
||
}}
|
||
class="group px-2 py-0.5 rounded-md font-mono font-bold hover:bg-surface-500/10 transition-colors duration-200"
|
||
title="Font size: {$ae_loc.font_size_mode ?? 'default'} — tap to cycle (default → larger → smaller)"
|
||
>
|
||
{#if $ae_loc.font_size_mode === 'larger'}
|
||
<span>A+</span>
|
||
{:else if $ae_loc.font_size_mode === 'smaller'}
|
||
<span class="text-[0.7em]">A−</span>
|
||
{:else}
|
||
<span>A</span>
|
||
{/if}
|
||
</button>
|
||
|
||
<div
|
||
class="current_datetime font-mono px-2 hover:font-bold hover:bg-white dark:hover:bg-slate-700 transition-colors"
|
||
>
|
||
<span class="hidden md:inline">
|
||
<CalendarDays size="1em" />
|
||
{ae_util.iso_datetime_formatter($time, 'date_full_no_year')}
|
||
</span>
|
||
<span class="hidden sm:inline">
|
||
<Clock size="1em" />
|
||
</span>
|
||
{#if $events_loc.launcher?.time_hours == 12}
|
||
{ae_util.iso_datetime_formatter($time, 'time_12_long')}
|
||
{:else}
|
||
{ae_util.iso_datetime_formatter($time, 'time_long')}
|
||
{/if}
|
||
</div>
|
||
</footer>
|
||
|
||
<div class="absolute top-0 left-0 z-20 text-center">
|
||
<button
|
||
type="button"
|
||
onclick={() => ($events_loc.launcher.hide_drawer__cfg = false)}
|
||
class="btn btn-sm p-3 preset-tonal-error hover:preset-filled-error-500 transition-colors duration-300"
|
||
class:opacity-25={!$ae_loc.trusted_access}
|
||
class:hover:opacity-75={!$ae_loc.trusted_access}
|
||
>
|
||
<Biohazard size="1em" />
|
||
<span class="hidden">Launcher Config</span>
|
||
</button>
|
||
</div>
|
||
|
||
<Drawer
|
||
dismissable={false}
|
||
onclick={() => ($events_loc.launcher.hide_drawer__cfg = true)}
|
||
class="bg-orange-50 dark:bg-slate-800 opacity-90 hover:opacity-97 transition-all duration-300 border border-gray-300 dark:border-gray-600 w-full md:w-96 lg:w-[32rem]"
|
||
placement="left"
|
||
{...{
|
||
transitionType: 'fly',
|
||
transitionParams: {
|
||
x: -520,
|
||
duration: 200,
|
||
easing: sineIn
|
||
}
|
||
}}
|
||
bind:hidden={$events_loc.launcher.hide_drawer__cfg}
|
||
id="sidebar1"
|
||
>
|
||
<!-- Stop-propagation wrapper: prevents clicks inside the visual panel from
|
||
bubbling up to the <dialog> element. The onclick on the <Drawer> above
|
||
is spread through to the native <dialog> by Flowbite, overriding its
|
||
broken outsideclose detection. With this wrapper, ONLY genuine backdrop
|
||
clicks (outside the visible panel) reach the dialog and close the drawer. -->
|
||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||
<div role="presentation" onclick={(e) => e.stopPropagation()}>
|
||
<Launcher_cfg></Launcher_cfg>
|
||
|
||
<hr class="my-2 border-gray-300 dark:border-gray-600" />
|
||
|
||
<div
|
||
class="flex flex-row flex-wrap gap-0.5 items-center justify-center max-w-md"
|
||
>
|
||
<a
|
||
href="/events/{$events_slct.event_id}"
|
||
class="btn btn-sm preset-tonal-primary hover:preset-filled-primary-500"
|
||
>
|
||
<Search size="1em" class="m-1" />
|
||
Session Search
|
||
</a>
|
||
{#if $events_slct?.event_location_id}
|
||
<a
|
||
href="/events/{$events_slct.event_id}/location/{$events_slct.event_location_id}"
|
||
class="btn btn-sm preset-tonal-primary hover:preset-filled-primary-500"
|
||
>
|
||
<MapPin size="1em" class="m-1" />
|
||
View Selected Location
|
||
</a>
|
||
{/if}
|
||
{#if $events_slct?.event_session_id}
|
||
<a
|
||
href="/events/{$events_slct.event_id}/session/{$events_slct.event_session_id}"
|
||
class="btn btn-sm preset-tonal-primary hover:preset-filled-primary-500"
|
||
>
|
||
<GraduationCap size="1em" class="m-1" />
|
||
View Selected Session
|
||
</a>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</Drawer>
|
||
|
||
<Drawer
|
||
activateClickOutside={false}
|
||
class="bg-red-50 dark:bg-slate-900 opacity-75 hover:opacity-95 transition-all duration-300"
|
||
placement="bottom"
|
||
{...{
|
||
transitionType: 'fly',
|
||
transitionParams: {
|
||
y: 320,
|
||
duration: 200,
|
||
easing: sineIn
|
||
}
|
||
}}
|
||
bind:hidden={$events_loc.launcher.hide_drawer__debug}
|
||
id="sidebar2"
|
||
>
|
||
<div class="flex flex-row items-center justify-between">
|
||
<h2
|
||
class="text-center mb-4 text-base font-semibold text-gray-500 dark:text-gray-400"
|
||
>
|
||
Debug
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
onclick={() => ($events_loc.launcher.hide_drawer__debug = true)}
|
||
class="mb-4 dark:text-white"
|
||
>
|
||
<X size="1em" />
|
||
<span class="hidden">Close Debug Drawer</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div>
|
||
<pre class="text-xs">
|
||
{JSON.stringify($events_loc.launcher, null, 2)}
|
||
</pre>
|
||
<hr />
|
||
<pre class="text-xs">
|
||
{JSON.stringify($ae_api, null, 2)}
|
||
</pre>
|
||
</div>
|
||
</Drawer>
|
||
|
||
<Modal
|
||
open={$events_sess.launcher?.modal__open_event_file_id}
|
||
autoclose={false}
|
||
placement="top-center"
|
||
class="
|
||
bg-gray-500/90 dark:bg-gray-800/90 text-gray-800 dark:text-gray-200
|
||
rounded-lg border-gray-200 dark:border-gray-700
|
||
divide-y divide-gray-200 dark:divide-gray-700 shadow-md
|
||
relative
|
||
flex flex-col items-center justify-center
|
||
{$events_loc.launcher.controller == 'remote' ? 'min-h-full' : ''}
|
||
min-w-full
|
||
"
|
||
bodyClass="p-0 space-y-0 overflow-auto flex flex-col gap-1 items-center justify-center pb-14"
|
||
headerClass={`fixed top-0 right-0 left-0 p-1 md:p-2 flex flex-row items-center ${$events_loc.launcher.controller == 'remote' ? 'hidden' : ''} bg-white dark:bg-gray-800 opacity-50 ${$events_loc.launcher.hide__modal_header_title ? 'justify-center' : 'justify-between'}`}
|
||
footerClass="text-center hidden"
|
||
>
|
||
{#snippet header()}
|
||
<h3
|
||
class:hidden={$events_loc.launcher.hide__modal_header_title}
|
||
class="text-lg font-semibold opacity-20 hover:opacity-100 transition-all"
|
||
>
|
||
{$events_sess.launcher?.modal__title ?? 'Digital Poster Display'}
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
class="btn flex-row-reverse group transition-all justify-self-end"
|
||
onclick={() => {
|
||
$events_sess.launcher.modal__open_event_file_id = null;
|
||
}}
|
||
title="Close Modal"
|
||
>
|
||
<X size="1em" class="my-1.5" />
|
||
<span class="hidden group-hover:inline"> Close</span>
|
||
</button>
|
||
{/snippet}
|
||
|
||
<!-- WHY: overflow-auto on wrapper + touch-action: pinch-zoom on the img enables
|
||
native pinch-to-zoom on mobile/tablet without any JS library.
|
||
Toggling modal_zoom_fit switches between 'fit to viewport' (default, clean display)
|
||
and 'natural size' mode where the operator can pan/scroll and pinch-zoom freely
|
||
for closer inspection or accessibility accommodation. -->
|
||
<div
|
||
class="w-full flex-1 flex items-center justify-center"
|
||
class:overflow-auto={!modal_zoom_fit}
|
||
class:overflow-hidden={modal_zoom_fit}
|
||
>
|
||
{#if $events_sess.launcher.modal__event_file_obj?.hosted_file_id}
|
||
<!-- WHY: Use hosted_file endpoint (not event_file) — the event_file download
|
||
endpoint requires auth headers that a plain <img> tag cannot send (→ 403).
|
||
The hosted_file endpoint accepts key=account_id as a query param and is
|
||
the proven browser-compatible path for direct file display. -->
|
||
<img
|
||
src="{$ae_api.base_url}/v3/action/hosted_file/{$events_sess.launcher
|
||
.modal__event_file_obj.hosted_file_id}/download?return_file=true&filename={encodeURIComponent(
|
||
$events_sess.launcher.modal__event_file_obj.filename ?? ''
|
||
)}&key={$ae_api.account_id}"
|
||
alt="Poster: {$events_sess.launcher.modal__title}"
|
||
ondblclick={() => {
|
||
modal_zoom_fit = !modal_zoom_fit;
|
||
// Sync zoom state to the remote display when acting as controller.
|
||
if ($events_loc.launcher.controller == 'local_push' && $events_sess.launcher.ws_connect_status == 'connected') {
|
||
$events_sess.launcher.controller_cmd = `ae_zoom:${modal_zoom_fit ? 'fit' : 'zoom'}`;
|
||
$events_sess.launcher.controller_trigger_send = true;
|
||
}
|
||
}}
|
||
title="Double-tap to toggle zoom / fit"
|
||
class="block transition-[max-height,max-width,width] duration-200"
|
||
class:max-h-[85dvh]={modal_zoom_fit}
|
||
class:max-w-full={modal_zoom_fit}
|
||
class:w-auto={modal_zoom_fit}
|
||
class:object-contain={modal_zoom_fit}
|
||
class:cursor-zoom-in={modal_zoom_fit}
|
||
class:cursor-zoom-out={!modal_zoom_fit}
|
||
style="touch-action: pinch-zoom;"
|
||
/>
|
||
{:else}
|
||
<div class="flex flex-row items-center justify-center p-4">
|
||
<Info size="1em" class="mx-1" />
|
||
<span>No image selected</span>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Bottom control bar — hidden on the remote display (operator-free kiosk) -->
|
||
<!-- WHY: pb-14 on bodyClass reserves space so these buttons don't obscure poster content. -->
|
||
<div
|
||
class="
|
||
absolute bottom-0 left-0 right-0
|
||
flex flex-row items-center justify-between gap-2
|
||
p-1.5
|
||
bg-black/30 backdrop-blur-sm
|
||
"
|
||
class:hidden={$events_loc.launcher.controller == 'remote'}
|
||
>
|
||
<!-- Zoom / Fit toggle: accessibility accommodation — lets operators and general
|
||
public zoom in to read details, pinch on mobile, or double-tap the image. -->
|
||
<button
|
||
type="button"
|
||
onclick={() => {
|
||
modal_zoom_fit = !modal_zoom_fit;
|
||
// Sync zoom state to the remote display when acting as controller.
|
||
if ($events_loc.launcher.controller == 'local_push' && $events_sess.launcher.ws_connect_status == 'connected') {
|
||
$events_sess.launcher.controller_cmd = `ae_zoom:${modal_zoom_fit ? 'fit' : 'zoom'}`;
|
||
$events_sess.launcher.controller_trigger_send = true;
|
||
}
|
||
}}
|
||
class="btn btn-sm preset-tonal-surface opacity-80 hover:opacity-100 transition-opacity"
|
||
title={modal_zoom_fit
|
||
? 'Pan / Zoom mode — pinch or double-tap image to zoom'
|
||
: 'Fit image to screen'}
|
||
>
|
||
{#if modal_zoom_fit}
|
||
<ZoomIn size="1em" class="mr-1" />
|
||
<span class="hidden sm:inline">Zoom</span>
|
||
{:else}
|
||
<Minimize2 size="1em" class="mr-1" />
|
||
<span class="hidden sm:inline">Fit</span>
|
||
{/if}
|
||
</button>
|
||
|
||
<!-- Close Both: sends WS close to remote display AND dismisses this modal.
|
||
Remote screensaver will resume cycling after idle timeout. -->
|
||
<button
|
||
type="button"
|
||
onclick={() => {
|
||
$events_sess.launcher.controller_cmd = `ae_close:event_file_modal`;
|
||
$events_sess.launcher.controller_trigger_send = true;
|
||
$events_sess.launcher.modal__title = '';
|
||
$events_sess.launcher.modal__open_event_file_id = null;
|
||
$events_sess.launcher.modal__event_file_obj = null;
|
||
}}
|
||
class="btn btn-sm preset-tonal-error opacity-80 hover:opacity-100 transition-all"
|
||
class:hidden={$events_loc.launcher.controller != 'local_push' ||
|
||
$events_sess.launcher.ws_connect_status != 'connected'}
|
||
title="Close poster on this device and on the remote display (screensaver resumes)"
|
||
>
|
||
<Monitor size="1em" class="mr-1" />
|
||
<X size="1em" />
|
||
<span class="hidden sm:inline ml-1">Close Both</span>
|
||
</button>
|
||
|
||
<!-- Back to List: dismisses this controller's view only.
|
||
Remote display keeps showing the current poster until next action or screensaver. -->
|
||
<button
|
||
type="button"
|
||
onclick={() => {
|
||
$events_sess.launcher.modal__title = '';
|
||
$events_sess.launcher.modal__open_event_file_id = null;
|
||
$events_sess.launcher.modal__event_file_obj = null;
|
||
}}
|
||
class="btn btn-sm preset-tonal-surface border border-surface-400/50 opacity-80 hover:opacity-100 transition-all"
|
||
class:hidden={!$ae_loc.trusted_access &&
|
||
($events_loc.launcher.controller != 'local_push' ||
|
||
$events_sess.launcher.ws_connect_status != 'connected')}
|
||
title="Close poster on this device only — remote display keeps showing"
|
||
>
|
||
<List size="1em" class="mr-1" />
|
||
<span class="hidden sm:inline">Back to List</span>
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
|
||
{#if $events_loc.launcher.controller_group_code && $events_loc.launcher.ws_connect}
|
||
<Element_websocket
|
||
{log_lvl}
|
||
bind:ws_connect={$events_loc.launcher.ws_connect}
|
||
bind:ws_connect_status={$events_sess.launcher.ws_connect_status}
|
||
ws_server={$ae_api.fqdn}
|
||
api_key={$ae_api.api_secret_key}
|
||
jwt={$ae_loc.jwt}
|
||
bind:group_id={$events_loc.launcher.controller_group_code}
|
||
bind:client_id={$events_loc.launcher.controller_client_id}
|
||
bind:cmd={$events_sess.launcher.controller_cmd}
|
||
bind:trigger_send={$events_sess.launcher.controller_trigger_send}
|
||
bind:trigger_connect={$events_sess.launcher.trigger__ws_connect}
|
||
bind:trigger_disconnect={$events_sess.launcher.trigger__ws_disconnect}
|
||
bind:hide__ws_element={$events_loc.launcher.hide__ws_element}
|
||
bind:hide__ws_form={$events_loc.launcher.hide__ws_form}
|
||
bind:hide__ws_messages={$events_loc.launcher.hide__ws_messages}
|
||
bind:hide__ws_commands={$events_loc.launcher.hide__ws_commands}
|
||
bind:ws_conn_status={trigger_handle_ws_conn}
|
||
bind:ws_recv_status={trigger_handle_ws_recv}
|
||
bind:ws_sent_status={trigger_handle_ws_sent}
|
||
/>
|
||
{/if}
|