feat(launcher): poster modal zoom sync, clean close buttons

- Add modal_zoom_fit state (default: fit); resets on every new poster open
- Zoom/Fit toggle button + image double-tap on controller tablet
- Both zoom triggers send ae_zoom:fit/zoom over WS to remote display (local_push)
- ae_zoom: handler added to handle_ws_recv() for remote device
- Replace 3 scattered close buttons with single bottom control bar:
    - [Zoom/Fit] always visible on controller
    - [Close Both] sends ae_close WS + clears local modal (local_push only)
    - [Back to List] clears local modal only; remote keeps showing current poster
- Bottom bar hidden on controller=remote (kiosk display-screen mode)
- native pinch-to-zoom via touch-action: pinch-zoom on img (no JS library needed)
- pb-14 on modal bodyClass prevents buttons from overlapping poster content
This commit is contained in:
Scott Idem
2026-03-12 19:57:33 -04:00
parent 90615ad5cc
commit b18cda98b7

View File

@@ -371,6 +371,13 @@
clearInterval(idle_timer_interval); clearInterval(idle_timer_interval);
saver_looping = false; saver_looping = false;
restartCountdown(); 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:')) { } else if (cmd.startsWith('ae_refresh:')) {
if (cmd.split(':')[1] == 'now') location.reload(); if (cmd.split(':')[1] == 'now') location.reload();
} }
@@ -417,6 +424,9 @@
let idle_timer_interval: any = $state(); let idle_timer_interval: any = $state();
let saver_looping: boolean = $state(false); 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() { function handle_idle_client() {
if ( if (
@@ -473,6 +483,13 @@
saver_looping = false; 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> </script>
<svelte:head> <svelte:head>
@@ -908,7 +925,7 @@
{$events_loc.launcher.controller == 'remote' ? 'min-h-full' : ''} {$events_loc.launcher.controller == 'remote' ? 'min-h-full' : ''}
min-w-full min-w-full
" "
bodyClass="p-0 space-y-0 overflow-y-auto flex flex-col gap-1 items-center justify-center" 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'}`} 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" footerClass="text-center hidden"
> >
@@ -932,96 +949,130 @@
</button> </button>
{/snippet} {/snippet}
<button <!-- WHY: overflow-auto on wrapper + touch-action: pinch-zoom on the img enables
type="button" native pinch-to-zoom on mobile/tablet without any JS library.
onclick={() => { Toggling modal_zoom_fit switches between 'fit to viewport' (default, clean display)
$events_sess.launcher.controller_cmd = `ae_close:event_file_modal`; and 'natural size' mode where the operator can pan/scroll and pinch-zoom freely
$events_sess.launcher.controller_trigger_send = true; for closer inspection or accessibility accommodation. -->
}} <div
class=" class="w-full flex-1 flex items-center justify-center"
absolute top-0 right-12 class:overflow-auto={!modal_zoom_fit}
m-1 p-1 class:overflow-hidden={modal_zoom_fit}
btn btn-sm
preset-tonal-error preset-outlined-error hover:preset-filled-success-200-800
opacity-80 hover:opacity-100 active:opactiy-100
transition-all
"
class:hidden={$events_loc.launcher.controller != 'local_push' ||
$events_sess.launcher.ws_connect_status != 'connected'}
title="Close the remote device's display of the poster"
> >
<span class="fas fa-times m-1"></span> {#if $events_sess.launcher.modal__event_file_obj?.hosted_file_id}
<span class="fas fa-tv"></span> <!-- WHY: Use hosted_file endpoint (not event_file) — the event_file download
Close Remote Poster Display Only endpoint requires auth headers that a plain <img> tag cannot send (→ 403).
</button> 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">
<span class="fas fa-info-circle mx-1"></span>
<span>No image selected</span>
</div>
{/if}
</div>
{#if $events_sess.launcher.modal__event_file_obj?.hosted_file_id} <!-- Bottom control bar — hidden on the remote display (operator-free kiosk) -->
<!-- WHY: Use hosted_file endpoint (not event_file) — the event_file download <!-- WHY: pb-14 on bodyClass reserves space so these buttons don't obscure poster content. -->
endpoint requires auth headers that a plain <img> tag cannot send (→ 403). <div
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"
class="min-h-28 min-w-md max-h-full max-w-full"
/>
{:else}
<div class="flex flex-row items-center justify-center p-4">
<span class="fas fa-info-circle mx-1"></span>
<span>No image selected</span>
</div>
{/if}
<button
type="button"
onclick={() => {
$events_sess.launcher.controller_cmd = `ae_close:event_file_modal`;
$events_sess.launcher.controller_trigger_send = true;
}}
class=" class="
absolute bottom-0 left-12 absolute bottom-0 left-0 right-0
m-1 p-1 flex flex-row items-center justify-between gap-2
btn btn-sm p-1.5
preset-tonal-error preset-outlined-error hover:preset-filled-success-200-800 bg-black/30 backdrop-blur-sm
opacity-80 hover:opacity-100 active:opactiy-100
transition-all
" "
class:hidden={$events_loc.launcher.controller != 'local_push' || class:hidden={$events_loc.launcher.controller == 'remote'}
$events_sess.launcher.ws_connect_status != 'connected'}
title="Close the remote device's display of the poster"
> >
<span class="fas fa-times m-1"></span> <!-- Zoom / Fit toggle: accessibility accommodation — lets operators and general
<span class="fas fa-tv"></span> public zoom in to read details, pinch on mobile, or double-tap the image. -->
Close Remote Poster Display Only <button
</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}
<span class="fas fa-search-plus mr-1"></span>
<span class="hidden sm:inline">Zoom</span>
{:else}
<span class="fas fa-compress mr-1"></span>
<span class="hidden sm:inline">Fit</span>
{/if}
</button>
<button <!-- Close Both: sends WS close to remote display AND dismisses this modal.
type="button" Remote screensaver will resume cycling after idle timeout. -->
onclick={() => { <button
$events_sess.launcher.modal__title = ''; type="button"
$events_sess.launcher.modal__open_event_file_id = null; onclick={() => {
$events_sess.launcher.modal__event_file_obj = null; $events_sess.launcher.controller_cmd = `ae_close:event_file_modal`;
}} $events_sess.launcher.controller_trigger_send = true;
class=" $events_sess.launcher.modal__title = '';
absolute bottom-0 right-12 $events_sess.launcher.modal__open_event_file_id = null;
m-1 p-1 $events_sess.launcher.modal__event_file_obj = null;
btn btn-sm }}
preset-tonal-success preset-outlined-success hover:preset-filled-success-200-800 class="btn btn-sm preset-tonal-error opacity-80 hover:opacity-100 transition-all"
opacity-80 hover:opacity-100 active:opactiy-100 class:hidden={$events_loc.launcher.controller != 'local_push' ||
transition-all $events_sess.launcher.ws_connect_status != 'connected'}
" title="Close poster on this device and on the remote display (screensaver resumes)"
class:hidden={!$ae_loc.trusted_access && >
($events_loc.launcher.controller != 'local_push' || <span class="fas fa-tv mr-1"></span>
$events_sess.launcher.ws_connect_status != 'connected')} <span class="fas fa-times"></span>
title="Close this controller's local modal display of this poster" <span class="hidden sm:inline ml-1">Close Both</span>
> </button>
<span class="fas fa-times m-1"></span>
<span class="fas fa-list"></span> <!-- Back to List: dismisses this controller's view only.
Close Poster on This Device Remote display keeps showing the current poster until next action or screensaver. -->
</button> <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"
>
<span class="fas fa-list mr-1"></span>
<span class="hidden sm:inline">Back to List</span>
</button>
</div>
</Modal> </Modal>
{#if $events_loc.launcher.controller_group_code && $events_loc.launcher.ws_connect} {#if $events_loc.launcher.controller_group_code && $events_loc.launcher.ws_connect}