teardown scene window when unused
This commit is contained in:
@@ -94,6 +94,10 @@ impl Network {
|
||||
self.keypair.public_key()
|
||||
}
|
||||
|
||||
pub(crate) fn online_friend_ids(&self) -> Result<Vec<String>, String> {
|
||||
self.friend_presence.snapshot()
|
||||
}
|
||||
|
||||
pub fn send_live_data(&self, data: LiveData) {
|
||||
let active_remotes = match self.friend_presence.active_remotes() {
|
||||
Ok(remotes) if remotes.is_empty() => return,
|
||||
@@ -502,7 +506,7 @@ pub fn list_statuses(
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn list_friend_statuses(network: State<'_, Network>) -> Result<Vec<String>, String> {
|
||||
network.friend_presence.snapshot()
|
||||
network.online_friend_ids()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+53
-16
@@ -1,5 +1,7 @@
|
||||
mod visibility;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
sync::RwLock,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -91,6 +93,7 @@ pub fn init(handle: &AppHandle) -> Result<(), String> {
|
||||
let mut ticker = tokio::time::interval(TICK_INTERVAL);
|
||||
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
let mut previous_tick = Instant::now();
|
||||
let mut visibility = visibility::Visibility::default();
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
@@ -98,40 +101,74 @@ pub fn init(handle: &AppHandle) -> Result<(), String> {
|
||||
let elapsed = now.duration_since(previous_tick);
|
||||
previous_tick = now;
|
||||
|
||||
let cursor_positions = match handle.state::<CursorState>().snapshot() {
|
||||
// UI state is registered only after the splash/onboarding boundary.
|
||||
if !crate::ui::scene::is_initialized(&handle) {
|
||||
continue;
|
||||
}
|
||||
let online = match handle
|
||||
.state::<crate::network::Network>()
|
||||
.online_friend_ids()
|
||||
{
|
||||
Ok(online) => online.into_iter().collect::<HashSet<_>>(),
|
||||
Err(error) => {
|
||||
eprintln!("failed to read scene friend presence: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let configuration = match handle.state::<SceneConfigurationState>().snapshot() {
|
||||
Ok(configuration) => configuration,
|
||||
Err(error) => {
|
||||
eprintln!("failed to read scene configuration: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let has_online_friends = !online.is_empty();
|
||||
visibility.update(
|
||||
configuration.hide_local_puppet_when_alone,
|
||||
has_online_friends,
|
||||
now,
|
||||
);
|
||||
let local_id = handle
|
||||
.state::<crate::keypair::AppKeypair>()
|
||||
.public_key()
|
||||
.to_owned();
|
||||
|
||||
let mut cursor_positions = match handle.state::<CursorState>().snapshot() {
|
||||
Ok(positions) => positions,
|
||||
Err(error) => {
|
||||
eprintln!("failed to read cursor positions for puppet motion: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let movement_mode = match handle
|
||||
.state::<SceneConfigurationState>()
|
||||
.puppet_movement_mode()
|
||||
{
|
||||
Ok(mode) => mode,
|
||||
Err(error) => {
|
||||
eprintln!("failed to read Puppet movement mode: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
cursor_positions
|
||||
.retain(|id, _| online.contains(id) || (id == &local_id && visibility.show_local));
|
||||
let puppets = match handle.state::<PuppetStateStore>().update(
|
||||
&cursor_positions,
|
||||
viewport,
|
||||
elapsed,
|
||||
movement_mode,
|
||||
configuration.puppet_movement_mode,
|
||||
) {
|
||||
Ok(Some(puppets)) => puppets,
|
||||
Ok(None) => continue,
|
||||
Ok(puppets) => puppets,
|
||||
Err(error) => {
|
||||
eprintln!("failed to update puppet motion: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = emit_changed(&handle, puppets) {
|
||||
if let Some(puppets) = puppets
|
||||
&& let Err(error) = emit_changed(&handle, puppets)
|
||||
{
|
||||
eprintln!("failed to emit puppet states: {error}");
|
||||
}
|
||||
// Reconcile even when motion is unchanged (including the idle deadline).
|
||||
if let Err(error) = crate::ui::scene::reconcile_window(
|
||||
&handle,
|
||||
visibility.window_open(has_online_friends),
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("failed to reconcile scene window: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const HIDE_DELAY: Duration = Duration::from_secs(5);
|
||||
const SHOW_DELAY: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Backend-owned visibility survives destruction of the scene webview.
|
||||
pub(super) struct Visibility {
|
||||
pub show_local: bool,
|
||||
pending: Option<(bool, Instant)>,
|
||||
}
|
||||
|
||||
impl Default for Visibility {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_local: true,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
pub fn window_open(&self, has_friends: bool) -> bool {
|
||||
self.show_local || has_friends
|
||||
}
|
||||
|
||||
pub fn update(&mut self, hide_when_alone: bool, has_friends: bool, now: Instant) {
|
||||
if !hide_when_alone {
|
||||
self.show_local = true;
|
||||
self.pending = None;
|
||||
return;
|
||||
}
|
||||
if self.show_local == has_friends {
|
||||
self.pending = None;
|
||||
return;
|
||||
}
|
||||
let (target, deadline) = *self.pending.get_or_insert_with(|| {
|
||||
(
|
||||
has_friends,
|
||||
now + if has_friends { SHOW_DELAY } else { HIDE_DELAY },
|
||||
)
|
||||
});
|
||||
if now >= deadline {
|
||||
self.show_local = target;
|
||||
self.pending = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_puppet_hides_after_five_seconds_and_return_delays_only_local_puppet() {
|
||||
let now = Instant::now();
|
||||
let mut visibility = Visibility::default();
|
||||
visibility.update(true, false, now);
|
||||
visibility.update(true, false, now + HIDE_DELAY - Duration::from_millis(1));
|
||||
assert!(visibility.show_local);
|
||||
visibility.update(true, false, now + HIDE_DELAY);
|
||||
assert!(!visibility.show_local);
|
||||
assert!(!visibility.window_open(false));
|
||||
let returned = now + HIDE_DELAY;
|
||||
visibility.update(true, true, returned);
|
||||
assert!(!visibility.show_local);
|
||||
assert!(visibility.window_open(true));
|
||||
visibility.update(true, true, returned + SHOW_DELAY);
|
||||
assert!(visibility.show_local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_cancels_hide_and_disconnect_cancels_show() {
|
||||
let now = Instant::now();
|
||||
let mut visibility = Visibility::default();
|
||||
visibility.update(true, false, now);
|
||||
visibility.update(true, true, now + Duration::from_secs(4));
|
||||
visibility.update(true, false, now + Duration::from_secs(5));
|
||||
assert!(visibility.show_local);
|
||||
visibility.update(true, false, now + Duration::from_secs(10));
|
||||
assert!(!visibility.show_local);
|
||||
visibility.update(true, true, now + Duration::from_secs(11));
|
||||
visibility.update(true, false, now + Duration::from_secs(12));
|
||||
visibility.update(true, true, now + Duration::from_secs(14));
|
||||
assert!(!visibility.show_local);
|
||||
visibility.update(true, true, now + Duration::from_secs(17));
|
||||
assert!(visibility.show_local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_setting_restores_local_and_cancels_pending_timer() {
|
||||
let now = Instant::now();
|
||||
let mut visibility = Visibility::default();
|
||||
visibility.update(true, false, now);
|
||||
visibility.update(true, false, now + HIDE_DELAY);
|
||||
visibility.update(false, false, now + HIDE_DELAY);
|
||||
assert!(visibility.show_local);
|
||||
visibility.update(true, false, now + HIDE_DELAY);
|
||||
visibility.update(false, false, now + HIDE_DELAY + Duration::from_secs(1));
|
||||
let restarted = now + HIDE_DELAY + Duration::from_secs(2);
|
||||
visibility.update(true, false, restarted);
|
||||
visibility.update(
|
||||
true,
|
||||
false,
|
||||
restarted + HIDE_DELAY - Duration::from_millis(1),
|
||||
);
|
||||
assert!(visibility.show_local);
|
||||
visibility.update(true, false, restarted + HIDE_DELAY);
|
||||
assert!(!visibility.show_local);
|
||||
}
|
||||
}
|
||||
@@ -41,10 +41,10 @@ impl SceneConfigurationState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn puppet_movement_mode(&self) -> Result<PuppetMovementMode, String> {
|
||||
pub(crate) fn snapshot(&self) -> Result<SceneConfiguration, String> {
|
||||
self.0
|
||||
.read()
|
||||
.map(|configuration| configuration.puppet_movement_mode)
|
||||
.map(|configuration| configuration.clone())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,8 @@ pub fn apply_macos_decoration_window_policy(app_handle: &AppHandle, window_label
|
||||
pub const WINDOW_LABEL: &str = "scene";
|
||||
|
||||
fn track_scene_hitboxes(app_handle: AppHandle, window: tauri::WebviewWindow) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let tracked_window = window.clone();
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
let mut ignores_cursor = true;
|
||||
let mut interval = tokio::time::interval(crate::cursor::SYSTEM_CURSOR_POLL_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
@@ -101,14 +102,14 @@ fn track_scene_hitboxes(app_handle: AppHandle, window: tauri::WebviewWindow) {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let window_position = match window.outer_position() {
|
||||
let window_position = match tracked_window.outer_position() {
|
||||
Ok(position) => position,
|
||||
Err(error) => {
|
||||
eprintln!("Failed to read scene position for hit-testing: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let scale_factor = match window.scale_factor() {
|
||||
let scale_factor = match tracked_window.scale_factor() {
|
||||
Ok(scale_factor) => scale_factor,
|
||||
Err(error) => {
|
||||
eprintln!("Failed to read scene scale factor for hit-testing: {error}");
|
||||
@@ -126,7 +127,7 @@ fn track_scene_hitboxes(app_handle: AppHandle, window: tauri::WebviewWindow) {
|
||||
};
|
||||
|
||||
if should_ignore != ignores_cursor {
|
||||
match window.set_ignore_cursor_events(should_ignore) {
|
||||
match tracked_window.set_ignore_cursor_events(should_ignore) {
|
||||
Ok(()) => ignores_cursor = should_ignore,
|
||||
Err(error) => {
|
||||
eprintln!("Failed to update scene cursor event policy: {error}")
|
||||
@@ -135,6 +136,11 @@ fn track_scene_hitboxes(app_handle: AppHandle, window: tauri::WebviewWindow) {
|
||||
}
|
||||
}
|
||||
});
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, tauri::WindowEvent::Destroyed) {
|
||||
task.abort();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn overlay_fullscreen(
|
||||
@@ -156,9 +162,8 @@ pub fn overlay_fullscreen(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn open_window(app_handle: &AppHandle) -> Result<tauri::WebviewWindow, String> {
|
||||
fn open_window(app_handle: &AppHandle) -> Result<tauri::WebviewWindow, String> {
|
||||
if let Some(window) = app_handle.get_webview_window(WINDOW_LABEL) {
|
||||
window.show().map_err(|e| e.to_string())?;
|
||||
return Ok(window);
|
||||
};
|
||||
|
||||
@@ -181,26 +186,77 @@ pub fn open_window(app_handle: &AppHandle) -> Result<tauri::WebviewWindow, Strin
|
||||
.skip_taskbar(true)
|
||||
.shadow(false)
|
||||
.accept_first_mouse(true)
|
||||
.visible(true)
|
||||
.visible(false)
|
||||
.focused(false);
|
||||
|
||||
let window = builder.build().map_err(|e: tauri::Error| e.to_string())?;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
window.open_devtools();
|
||||
let configure = || -> Result<(), tauri::Error> {
|
||||
overlay_fullscreen(app_handle, &window)?;
|
||||
window.set_ignore_cursor_events(true)?;
|
||||
window.show()?;
|
||||
Ok(())
|
||||
};
|
||||
if let Err(error) = configure() {
|
||||
let _ = window.destroy();
|
||||
return Err(error.to_string());
|
||||
}
|
||||
apply_macos_decoration_window_policy(app_handle, WINDOW_LABEL.to_string());
|
||||
track_scene_hitboxes(app_handle.clone(), window.clone());
|
||||
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
/// Serializes native create/destroy requests, including destruction acknowledgement.
|
||||
#[derive(Default)]
|
||||
struct SceneWindow(tokio::sync::Mutex<()>);
|
||||
|
||||
pub(crate) fn is_initialized(handle: &AppHandle) -> bool {
|
||||
handle.try_state::<SceneWindow>().is_some()
|
||||
}
|
||||
|
||||
/// Creates a fully configured scene, or destroys its webview and stops hit-testing.
|
||||
pub(crate) async fn reconcile_window(handle: &AppHandle, open: bool) -> Result<(), String> {
|
||||
let state = handle
|
||||
.try_state::<SceneWindow>()
|
||||
.ok_or("scene UI has not started")?;
|
||||
let _guard = state.0.lock().await;
|
||||
if open {
|
||||
if handle.get_webview_window(WINDOW_LABEL).is_none() {
|
||||
handle
|
||||
.state::<SceneHitboxes>()
|
||||
.0
|
||||
.write()
|
||||
.map_err(|error| error.to_string())?
|
||||
.clear();
|
||||
open_window(&handle)?;
|
||||
}
|
||||
} else if let Some(window) = handle.get_webview_window(WINDOW_LABEL) {
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
let sender = std::sync::Mutex::new(Some(sender));
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, tauri::WindowEvent::Destroyed)
|
||||
&& let Some(sender) = sender.lock().unwrap().take()
|
||||
{
|
||||
let _ = sender.send(());
|
||||
}
|
||||
});
|
||||
window.destroy().map_err(|error| error.to_string())?;
|
||||
receiver.await.map_err(|error| error.to_string())?;
|
||||
handle
|
||||
.state::<SceneHitboxes>()
|
||||
.0
|
||||
.write()
|
||||
.map_err(|error| error.to_string())?
|
||||
.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init(app_handle: &AppHandle) {
|
||||
app_handle.manage(SceneHitboxes::default());
|
||||
let window = open_window(app_handle).expect("Scene window should be opened successfully");
|
||||
overlay_fullscreen(app_handle, &window).expect("Scene window should be fullscreened");
|
||||
apply_macos_decoration_window_policy(app_handle, WINDOW_LABEL.to_string());
|
||||
window
|
||||
.set_ignore_cursor_events(true)
|
||||
.expect("Scene window needs to ignore cursor events");
|
||||
track_scene_hitboxes(app_handle.clone(), window);
|
||||
// The existing puppet tick opens the scene only after publishing its snapshot.
|
||||
app_handle.manage(SceneWindow::default());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,12 +5,16 @@ export const puppetStates = writable<PuppetState[]>([]);
|
||||
export const puppetStatesListenerError = writable("");
|
||||
|
||||
export async function initPuppetStatesListener() {
|
||||
let receivedEvent = false;
|
||||
const unlisten = await events.puppetStatesChanged.listen((event) => {
|
||||
receivedEvent = true;
|
||||
puppetStates.set(event.payload.puppets);
|
||||
});
|
||||
|
||||
try {
|
||||
puppetStates.set(await commands.listPuppetStates());
|
||||
const snapshot = await commands.listPuppetStates();
|
||||
// A newly created scene must not overwrite a newer event with its startup snapshot.
|
||||
if (!receivedEvent) puppetStates.set(snapshot);
|
||||
} catch (error) {
|
||||
puppetStatesListenerError.set(String(error));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import Popover from "$lib/components/popover.svelte";
|
||||
import { friendName, friends } from "$lib/listeners/friends";
|
||||
import { onlineFriendIds } from "$lib/listeners/friend-statuses";
|
||||
import { incomingInteraction } from "$lib/listeners/interactions";
|
||||
import { liveMetadata } from "$lib/listeners/live-metadata";
|
||||
import { profile } from "$lib/listeners/profile";
|
||||
@@ -17,28 +16,16 @@
|
||||
import SceneUserPopoverContent from "./popovers/user-interaction.svelte";
|
||||
import { messages } from "$lib/i18n";
|
||||
|
||||
const LOCAL_PUPPET_HIDE_DELAY_MS = 5_000;
|
||||
const LOCAL_PUPPET_SHOW_DELAY_MS = 3_000;
|
||||
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
let lockedPopoverUserId = $state<string | null>(null);
|
||||
let viewedImage = $state<{ source: string; senderName: string } | null>(null);
|
||||
let puppetBounds = $state<PuppetScreenBounds[]>([]);
|
||||
let showLocalPuppet = $state(true);
|
||||
let puppetBoundsById = $derived(
|
||||
new Map(puppetBounds.map((bounds) => [bounds.id, bounds])),
|
||||
);
|
||||
let visiblePuppets = $derived(
|
||||
$puppetStates.filter(
|
||||
({ id }) =>
|
||||
$onlineFriendIds.has(id) ||
|
||||
(id === $liveMetadata.localId &&
|
||||
(!$sceneConfiguration.hideLocalPuppetWhenAlone || showLocalPuppet)),
|
||||
),
|
||||
);
|
||||
let skinHashes = $derived(
|
||||
new Map(
|
||||
visiblePuppets.map(({ id }) => [
|
||||
$puppetStates.map(({ id }) => [
|
||||
id,
|
||||
id === $profile?.id
|
||||
? $profile.skinHash
|
||||
@@ -51,23 +38,6 @@
|
||||
void getCurrentWindow().setTitle($messages.scene_window_title());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!$sceneConfiguration.hideLocalPuppetWhenAlone) {
|
||||
showLocalPuppet = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextShowLocalPuppet = $onlineFriendIds.size > 0;
|
||||
if (showLocalPuppet === nextShowLocalPuppet) return;
|
||||
const timer = window.setTimeout(
|
||||
() => (showLocalPuppet = nextShowLocalPuppet),
|
||||
nextShowLocalPuppet
|
||||
? LOCAL_PUPPET_SHOW_DELAY_MS
|
||||
: LOCAL_PUPPET_HIDE_DELAY_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
});
|
||||
|
||||
onMount(startHitboxSync);
|
||||
|
||||
$effect(() => {
|
||||
@@ -84,7 +54,7 @@
|
||||
$effect(() => {
|
||||
if (
|
||||
selectedUserId &&
|
||||
!visiblePuppets.some((puppet) => puppet.id === selectedUserId)
|
||||
!$puppetStates.some((puppet) => puppet.id === selectedUserId)
|
||||
) {
|
||||
dismissPopover();
|
||||
}
|
||||
@@ -133,14 +103,14 @@
|
||||
|
||||
<div class="relative size-full">
|
||||
<Renderer
|
||||
puppets={visiblePuppets}
|
||||
puppets={$puppetStates}
|
||||
selectedPuppetId={selectedUserId}
|
||||
onBoundsChange={(bounds) => (puppetBounds = bounds)}
|
||||
{skinHashes}
|
||||
/>
|
||||
|
||||
<div role="banner" class="pointer-events-none fixed inset-0 z-10">
|
||||
{#each visiblePuppets as puppet, index (puppet.id)}
|
||||
{#each $puppetStates as puppet, index (puppet.id)}
|
||||
{@const userId = puppet.id}
|
||||
{@const bounds = puppetBoundsById.get(userId)}
|
||||
{@const foregroundApp = $liveMetadata.foregroundApps.get(userId)}
|
||||
|
||||
Reference in New Issue
Block a user