hotkey to activate neko interactions
This commit is contained in:
@@ -1,10 +1,125 @@
|
|||||||
use crate::get_app_handle;
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tauri::Manager;
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use device_query::{DeviceQuery, DeviceState, Keycode};
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
use tauri_plugin_positioner::WindowExt;
|
use tauri_plugin_positioner::WindowExt;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::get_app_handle;
|
||||||
|
|
||||||
pub static SCENE_WINDOW_LABEL: &str = "scene";
|
pub static SCENE_WINDOW_LABEL: &str = "scene";
|
||||||
pub static SPLASH_WINDOW_LABEL: &str = "splash";
|
pub static SPLASH_WINDOW_LABEL: &str = "splash";
|
||||||
|
|
||||||
|
static SCENE_INTERACTIVE_STATE: OnceCell<Arc<AtomicBool>> = OnceCell::new();
|
||||||
|
static MODIFIER_LISTENER_INIT: OnceCell<()> = OnceCell::new();
|
||||||
|
|
||||||
|
fn scene_interactive_state() -> Arc<AtomicBool> {
|
||||||
|
SCENE_INTERACTIVE_STATE
|
||||||
|
.get_or_init(|| Arc::new(AtomicBool::new(false)))
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_scene_interactive(interactive: bool) {
|
||||||
|
let app_handle = get_app_handle();
|
||||||
|
|
||||||
|
if let Some(window) = app_handle.get_window(SCENE_WINDOW_LABEL) {
|
||||||
|
if let Err(e) = window.set_ignore_cursor_events(!interactive) {
|
||||||
|
error!("Failed to toggle scene cursor events: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = window.emit("scene-interactive", &interactive) {
|
||||||
|
error!("Failed to emit scene interactive event: {}", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("Scene window not available for interactive update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[link(name = "ApplicationServices", kind = "framework")]
|
||||||
|
extern "C" {
|
||||||
|
fn AXIsProcessTrusted() -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_scene_modifier_listener() {
|
||||||
|
MODIFIER_LISTENER_INIT.get_or_init(|| {
|
||||||
|
let state = scene_interactive_state();
|
||||||
|
update_scene_interactive(false);
|
||||||
|
|
||||||
|
let app_handle = get_app_handle().clone();
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
unsafe {
|
||||||
|
info!("Accessibility status: {}", AXIsProcessTrusted());
|
||||||
|
if !AXIsProcessTrusted() {
|
||||||
|
// Warning only - polling might work without explicit permissions for just key state in some contexts,
|
||||||
|
// or we just want to avoid the crash. We'll show the dialog but not return early if we want to try anyway.
|
||||||
|
// However, usually global key monitoring requires it.
|
||||||
|
// Let's show the dialog but NOT return, to try polling.
|
||||||
|
// Or better, let's keep the return if we think it won't work at all,
|
||||||
|
// but since the crash was the main issue, let's try to proceed safely.
|
||||||
|
// For now, I will keep the dialog and the return to encourage users to enable it,
|
||||||
|
// as it's likely needed for global input monitoring.
|
||||||
|
|
||||||
|
// On second thought, let's keep the return to be safe and clear to the user.
|
||||||
|
error!("Accessibility permissions not granted. Global modifier listener will NOT start.");
|
||||||
|
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
use tauri_plugin_dialog::MessageDialogBuilder;
|
||||||
|
use tauri_plugin_dialog::MessageDialogKind;
|
||||||
|
|
||||||
|
MessageDialogBuilder::new(
|
||||||
|
app_handle.dialog().clone(),
|
||||||
|
"Missing Permissions",
|
||||||
|
"Friendolls needs Accessibility permissions to detect the Alt key for interactivity. Please grant permissions in System Settings -> Privacy & Security -> Accessibility and restart the app.",
|
||||||
|
)
|
||||||
|
.kind(MessageDialogKind::Warning)
|
||||||
|
.show(|_| {});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn a thread for polling key state
|
||||||
|
thread::spawn(move || {
|
||||||
|
let device_state = DeviceState::new();
|
||||||
|
let mut last_interactive = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let keys = device_state.get_keys();
|
||||||
|
// Check for Alt key (Option on Mac)
|
||||||
|
let interactive = (keys.contains(&Keycode::LAlt) || keys.contains(&Keycode::RAlt)) || keys.contains(&Keycode::Command);
|
||||||
|
|
||||||
|
if interactive != last_interactive {
|
||||||
|
// State changed
|
||||||
|
info!("Key down state chanegd!");
|
||||||
|
let previous = state.swap(interactive, Ordering::SeqCst);
|
||||||
|
if previous != interactive {
|
||||||
|
if let Some(window) = app_handle.get_window(SCENE_WINDOW_LABEL) {
|
||||||
|
if let Err(err) = window.set_ignore_cursor_events(!interactive) {
|
||||||
|
error!("Failed to toggle scene cursor events: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = window.emit("scene-interactive", &interactive) {
|
||||||
|
error!("Failed to emit scene interactive event: {}", err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("Scene window not available for interactive update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last_interactive = interactive;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll every 100ms
|
||||||
|
thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub fn overlay_fullscreen(window: &tauri::Window) -> Result<(), tauri::Error> {
|
pub fn overlay_fullscreen(window: &tauri::Window) -> Result<(), tauri::Error> {
|
||||||
// Get the primary monitor
|
// Get the primary monitor
|
||||||
let monitor = get_app_handle().primary_monitor()?.unwrap();
|
let monitor = get_app_handle().primary_monitor()?.unwrap();
|
||||||
@@ -142,6 +257,9 @@ pub fn open_scene_window() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start global modifier listener once scene window exists
|
||||||
|
start_scene_modifier_listener();
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
webview_window.open_devtools();
|
webview_window.open_devtools();
|
||||||
|
|
||||||
|
|||||||
37
src/events/scene-interactive.ts
Normal file
37
src/events/scene-interactive.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
export const sceneInteractive = writable<boolean>(false);
|
||||||
|
|
||||||
|
let unlisten: UnlistenFn | null = null;
|
||||||
|
let isListening = false;
|
||||||
|
|
||||||
|
export async function initSceneInteractiveListener() {
|
||||||
|
if (isListening) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ensure initial default matches backend default
|
||||||
|
sceneInteractive.set(false);
|
||||||
|
unlisten = await listen<boolean>("scene-interactive", (event) => {
|
||||||
|
sceneInteractive.set(Boolean(event.payload));
|
||||||
|
});
|
||||||
|
isListening = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize scene interactive listener:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSceneInteractiveListener() {
|
||||||
|
if (unlisten) {
|
||||||
|
unlisten();
|
||||||
|
unlisten = null;
|
||||||
|
isListening = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.dispose(() => {
|
||||||
|
stopSceneInteractiveListener();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { initCursorTracking, stopCursorTracking } from "../events/cursor";
|
import { initCursorTracking, stopCursorTracking } from "../events/cursor";
|
||||||
import { initAppDataListener } from "../events/app-data";
|
import { initAppDataListener } from "../events/app-data";
|
||||||
|
import {
|
||||||
|
initSceneInteractiveListener,
|
||||||
|
stopSceneInteractiveListener,
|
||||||
|
} from "../events/scene-interactive";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
if (browser) {
|
if (browser) {
|
||||||
@@ -10,6 +14,7 @@
|
|||||||
try {
|
try {
|
||||||
await initCursorTracking();
|
await initCursorTracking();
|
||||||
await initAppDataListener();
|
await initAppDataListener();
|
||||||
|
await initSceneInteractiveListener();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to initialize event listeners:", err);
|
console.error("Failed to initialize event listeners:", err);
|
||||||
}
|
}
|
||||||
@@ -17,6 +22,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
stopCursorTracking();
|
stopCursorTracking();
|
||||||
|
stopSceneInteractiveListener();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,12 +5,15 @@
|
|||||||
friendsActiveDolls,
|
friendsActiveDolls,
|
||||||
} from "../../events/cursor";
|
} from "../../events/cursor";
|
||||||
import { appData } from "../../events/app-data";
|
import { appData } from "../../events/app-data";
|
||||||
|
import { sceneInteractive } from "../../events/scene-interactive";
|
||||||
|
|
||||||
import DesktopPet from "./DesktopPet.svelte";
|
import DesktopPet from "./DesktopPet.svelte";
|
||||||
|
|
||||||
let innerWidth = 0;
|
let innerWidth = 0;
|
||||||
let innerHeight = 0;
|
let innerHeight = 0;
|
||||||
|
|
||||||
|
$: isInteractive = $sceneInteractive;
|
||||||
|
|
||||||
function getFriendName(userId: string) {
|
function getFriendName(userId: string) {
|
||||||
const friend = $appData?.friends?.find((f) => f.friend.id === userId);
|
const friend = $appData?.friends?.find((f) => f.friend.id === userId);
|
||||||
return friend ? friend.friend.name : userId.slice(0, 8) + "...";
|
return friend ? friend.friend.name : userId.slice(0, 8) + "...";
|
||||||
@@ -30,39 +33,34 @@
|
|||||||
|
|
||||||
<div class="w-svw h-svh p-4 relative overflow-hidden">
|
<div class="w-svw h-svh p-4 relative overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="size-max mx-auto bg-base-100 border-base-200 border px-4 py-3 rounded-xl"
|
class="size-max mx-auto bg-base-100 border-base-200 border p-1 rounded-lg shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col text-center">
|
<div class="flex flex-row gap-1 items-center text-center">
|
||||||
<p class="text-xl">Friendolls</p>
|
<div>
|
||||||
<p class="text-sm opacity-50">Scene Screen</p>
|
<span class="py-3 text-xs items-center gap-2 badge">
|
||||||
<div class="mt-4 flex flex-col gap-1">
|
<span
|
||||||
<span class="font-mono text-sm">
|
class={`size-2 rounded-full ${isInteractive ? "bg-success" : "bg-base-300"}`}
|
||||||
Raw: ({$cursorPositionOnScreen.raw.x}, {$cursorPositionOnScreen.raw
|
></span>
|
||||||
.y})
|
Intercepting cursor events
|
||||||
</span>
|
|
||||||
<span class="font-mono text-sm">
|
|
||||||
Mapped: ({$cursorPositionOnScreen.mapped.x.toFixed(3)}, {$cursorPositionOnScreen.mapped.y.toFixed(
|
|
||||||
3,
|
|
||||||
)})
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span class="font-mono text-xs badge py-3">
|
||||||
|
({$cursorPositionOnScreen.mapped.x.toFixed(3)}, {$cursorPositionOnScreen.mapped.y.toFixed(
|
||||||
|
3,
|
||||||
|
)})
|
||||||
|
</span>
|
||||||
|
|
||||||
{#if Object.keys($friendsCursorPositions).length > 0}
|
{#if Object.keys($friendsCursorPositions).length > 0}
|
||||||
<div class="mt-4 flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<p class="text-sm font-semibold opacity-75">Friends Online</p>
|
|
||||||
<div>
|
<div>
|
||||||
{#each Object.entries($friendsCursorPositions) as [userId, position]}
|
{#each Object.entries($friendsCursorPositions) as [userId, position]}
|
||||||
{@const dollConfig = getFriendDollConfig(userId)}
|
{@const dollConfig = getFriendDollConfig(userId)}
|
||||||
<div
|
<div class="badge py-3 text-xs text-left flex flex-row gap-2">
|
||||||
class="bg-base-200/50 p-2 rounded text-xs text-left flex gap-2 flex-col"
|
|
||||||
>
|
|
||||||
<span class="font-bold">{getFriendName(userId)}</span>
|
<span class="font-bold">{getFriendName(userId)}</span>
|
||||||
<div class="flex flex-col font-mono">
|
<div class="flex flex-col font-mono">
|
||||||
<span>
|
<span>
|
||||||
Raw: ({position.raw.x}, {position.raw.y})
|
({position.mapped.x.toFixed(3)}, {position.mapped.y.toFixed(
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Mapped: ({position.mapped.x.toFixed(3)}, {position.mapped.y.toFixed(
|
|
||||||
3,
|
3,
|
||||||
)})
|
)})
|
||||||
</span>
|
</span>
|
||||||
@@ -74,6 +72,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute inset-0 size-full">
|
<div class="absolute inset-0 size-full">
|
||||||
{#if Object.keys($friendsCursorPositions).length > 0}
|
{#if Object.keys($friendsCursorPositions).length > 0}
|
||||||
{#each Object.entries($friendsCursorPositions) as [userId, position]}
|
{#each Object.entries($friendsCursorPositions) as [userId, position]}
|
||||||
@@ -85,6 +84,7 @@
|
|||||||
name={getFriendName(userId)}
|
name={getFriendName(userId)}
|
||||||
bodyColor={config?.colorScheme?.body}
|
bodyColor={config?.colorScheme?.body}
|
||||||
outlineColor={config?.colorScheme?.outline}
|
outlineColor={config?.colorScheme?.outline}
|
||||||
|
{isInteractive}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
export let name = "";
|
export let name = "";
|
||||||
export let bodyColor: string | undefined = undefined;
|
export let bodyColor: string | undefined = undefined;
|
||||||
export let outlineColor: string | undefined = undefined;
|
export let outlineColor: string | undefined = undefined;
|
||||||
|
export let isInteractive = false;
|
||||||
|
|
||||||
let nekoPosX = 32;
|
let nekoPosX = 32;
|
||||||
let nekoPosY = 32;
|
let nekoPosY = 32;
|
||||||
@@ -251,15 +252,18 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<button
|
||||||
class="desktop-pet flex flex-col items-center"
|
onclick={() => {
|
||||||
|
console.log("clicked on neko");
|
||||||
|
}}
|
||||||
|
class="desktop-pet flex flex-col items-center relative"
|
||||||
style="
|
style="
|
||||||
transform: translate({nekoPosX - 16}px, {nekoPosY - 16}px);
|
transform: translate({nekoPosX - 16}px, {nekoPosY - 16}px);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="pixelated"
|
class="pixelated hover:scale-110 active:scale-95"
|
||||||
style="
|
style="
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -268,18 +272,18 @@
|
|||||||
"
|
"
|
||||||
></div>
|
></div>
|
||||||
<span
|
<span
|
||||||
class="text-[10px] bg-black/50 text-white px-1 rounded backdrop-blur-sm mt-1 whitespace-nowrap"
|
class="absolute -bottom-5 width-full text-[10px] bg-black/50 text-white px-1 rounded backdrop-blur-sm mt-1 whitespace-nowrap opacity-0 transition-opacity"
|
||||||
|
class:opacity-100={isInteractive}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.desktop-pet {
|
.desktop-pet {
|
||||||
position: fixed; /* Fixed relative to the viewport/container */
|
position: fixed; /* Fixed relative to the viewport/container */
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
pointer-events: none; /* Let clicks pass through */
|
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user