fix(android): playtime tracking and music stopping on unfocus

This commit is contained in:
neoapps-dev 2026-08-16 13:31:12 +03:00
parent 7a2650bd48
commit 17c2000b97
5 changed files with 247 additions and 63 deletions

File diff suppressed because one or more lines are too long

View file

@ -54,11 +54,15 @@ pub async fn launch_game(
}
ensure_server_list(&working_dir, servers);
crate::android_runtime::launch_bridge(
let result = crate::android_runtime::launch_bridge(
working_dir.to_string_lossy().to_string(),
crate::android_runtime::BridgeAction::Play,
extra_args,
)
);
if result.is_ok() {
playtime::start_session(&app, &instance_id);
}
result
}
#[cfg(not(target_os = "android"))]
launch_game_desktop(app, state, instance_id, servers, extra_args).await

View file

@ -157,6 +157,18 @@ pub fn run() {
}
}
#[cfg(target_os = "android")]
{
let handle = app_handle.clone();
if let Some(window) = app_handle.get_webview_window("main") {
window.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(true) = event {
playtime::finish_active_session(&handle);
}
});
}
}
#[cfg(desktop)]
{
let args: Vec<String> = std::env::args().collect();

View file

@ -15,6 +15,12 @@ pub struct PlaytimeData {
pub sessions: HashMap<String, Vec<PlaytimeSession>>,
}
#[derive(Serialize, Deserialize)]
struct ActiveSession {
instance_id: String,
start: u64,
}
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PlaytimeResponse {
@ -54,6 +60,34 @@ pub fn record_session(app: &AppHandle, instance_id: &str, start: u64, end: u64)
save(app, &data);
}
const MAX_SESSION_SECONDS: u64 = 12 * 60 * 60;
fn active_session_path(app: &AppHandle) -> PathBuf {
util::get_app_dir(app).join("active_session.json")
}
pub fn start_session(app: &AppHandle, instance_id: &str) {
let start = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let active = ActiveSession { instance_id: instance_id.to_string(), start };
let path = active_session_path(app);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(content) = serde_json::to_string(&active) {
let _ = std::fs::write(&path, content);
}
}
pub fn finish_active_session(app: &AppHandle) {
let path = active_session_path(app);
let Ok(content) = std::fs::read_to_string(&path) else { return };
let _ = std::fs::remove_file(&path);
let Ok(active) = serde_json::from_str::<ActiveSession>(&content) else { return };
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
if now > active.start && now - active.start <= MAX_SESSION_SECONDS {
record_session(app, &active.instance_id, active.start, now);
}
}
pub fn get_playtime(app: &AppHandle, instance_id: &str) -> PlaytimeResponse {
let data = load(app);
let sessions = data.sessions.get(instance_id);

View file

@ -1,4 +1,11 @@
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from "react";
import React, {
createContext,
useContext,
useState,
useEffect,
useMemo,
useCallback,
} from "react";
import { useAppConfig } from "../hooks/useAppConfig";
import { TauriService } from "../services/TauriService";
import { useAudioController } from "../hooks/useAudioController";
@ -29,10 +36,18 @@ interface UIContextType {
clearUpdateMessage: () => void;
}
const UIContext = createContext<UIContextType | undefined>(undefined);
export const ConfigContext = createContext<ReturnType<typeof useAppConfig> | undefined>(undefined);
export const AudioContext = createContext<ReturnType<typeof useAudioController> | undefined>(undefined);
export const GameContext = createContext<ReturnType<typeof useGameManager> | undefined>(undefined);
export const SkinContext = createContext<ReturnType<typeof useSkinSync> | undefined>(undefined);
export const ConfigContext = createContext<
ReturnType<typeof useAppConfig> | undefined
>(undefined);
export const AudioContext = createContext<
ReturnType<typeof useAudioController> | undefined
>(undefined);
export const GameContext = createContext<
ReturnType<typeof useGameManager> | undefined
>(undefined);
export const SkinContext = createContext<
ReturnType<typeof useSkinSync> | undefined
>(undefined);
export function LauncherProvider({ children }: { children: React.ReactNode }) {
const [showIntro, setShowIntro] = useState(true);
const [logoAnimDone, setLogoAnimDone] = useState(false);
@ -55,7 +70,11 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
setCustomizations: configRaw.setCustomizations,
extraLaunchArgs: configRaw.extraLaunchArgs,
});
const skinSync = useSkinSync({ username: configRaw.username, profile: configRaw.profile, editions: gameRaw.editions });
const skinSync = useSkinSync({
username: configRaw.username,
profile: configRaw.profile,
editions: gameRaw.editions,
});
const audioRaw = useAudioController({
musicVol: configRaw.musicVol,
sfxVol: configRaw.sfxVol,
@ -63,33 +82,78 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
isWindowVisible,
});
const config = useMemo(() => configRaw, [
configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled,
configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime,
configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions,
configRaw.customPaths,
configRaw.customizations,
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled,
configRaw.extraLaunchArgs, configRaw.launchPrefix, configRaw.launchEnvVars, configRaw.startFullscreen,
configRaw.skipIntro, configRaw.instanceLaunchArgs,
]);
const config = useMemo(
() => configRaw,
[
configRaw.username,
configRaw.theme,
configRaw.layout,
configRaw.vfxEnabled,
configRaw.rpcEnabled,
configRaw.musicVol,
configRaw.sfxVol,
configRaw.isDayTime,
configRaw.profile,
configRaw.linuxRunner,
configRaw.perfBoost,
configRaw.customEditions,
configRaw.customPaths,
configRaw.customizations,
configRaw.legacyMode,
configRaw.animationsEnabled,
configRaw.mangohudEnabled,
configRaw.extraLaunchArgs,
configRaw.launchPrefix,
configRaw.launchEnvVars,
configRaw.startFullscreen,
configRaw.skipIntro,
configRaw.instanceLaunchArgs,
],
);
const game = useMemo(() => gameRaw, [
gameRaw.installs, gameRaw.isGameRunning, gameRaw.downloadProgress,
gameRaw.downloadingIds, gameRaw.editions, gameRaw.isRunnerDownloading,
gameRaw.runnerDownloadProgress, gameRaw.error, gameRaw.updateCustomEdition,
gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile,
gameRaw.updatesAvailable, gameRaw.addToSteam, gameRaw.steamSuccessMessage,
gameRaw.cycleBranch, gameRaw.toggleInstall, gameRaw.checkInstalls,
gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.addCustomEdition,
gameRaw.deleteCustomEdition, gameRaw.downloadRunner,
gameRaw.customizations, gameRaw.updateCustomization,
gameRaw.gameLog, gameRaw.clearGameLog,
]);
const game = useMemo(
() => gameRaw,
[
gameRaw.installs,
gameRaw.isGameRunning,
gameRaw.downloadProgress,
gameRaw.downloadingIds,
gameRaw.editions,
gameRaw.isRunnerDownloading,
gameRaw.runnerDownloadProgress,
gameRaw.error,
gameRaw.updateCustomEdition,
gameRaw.handleUninstall,
gameRaw.handleCancelDownload,
gameRaw.gameUpdateMessage,
configRaw.profile,
gameRaw.updatesAvailable,
gameRaw.addToSteam,
gameRaw.steamSuccessMessage,
gameRaw.cycleBranch,
gameRaw.toggleInstall,
gameRaw.checkInstalls,
gameRaw.handleLaunch,
gameRaw.stopGame,
gameRaw.addCustomEdition,
gameRaw.deleteCustomEdition,
gameRaw.downloadRunner,
gameRaw.customizations,
gameRaw.updateCustomization,
gameRaw.gameLog,
gameRaw.clearGameLog,
],
);
const audio = useMemo(() => audioRaw, [
audioRaw.currentTrack, audioRaw.splashIndex, audioRaw.tracks, audioRaw.splashes
]);
const audio = useMemo(
() => audioRaw,
[
audioRaw.currentTrack,
audioRaw.splashIndex,
audioRaw.tracks,
audioRaw.splashes,
],
);
useDiscordRPC({
rpcEnabled: config.rpcEnabled,
@ -143,32 +207,50 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
extraLaunchArgs: config.extraLaunchArgs,
launchPrefix: config.launchPrefix,
launchEnvVars: config.launchEnvVars,
startFullscreen: config.startFullscreen,
skipIntro: config.skipIntro,
startFullscreen: config.startFullscreen,
skipIntro: config.skipIntro,
instanceLaunchArgs: config.instanceLaunchArgs,
}).catch(console.error);
}
}, [
config.username, skinSync.skinBase64, config.theme, config.linuxRunner,
config.perfBoost, config.customEditions, config.profile,
config.username,
skinSync.skinBase64,
config.theme,
config.linuxRunner,
config.perfBoost,
config.customEditions,
config.profile,
config.customPaths,
config.customizations, config.vfxEnabled, config.animationsEnabled,
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
config.mangohudEnabled, config.extraLaunchArgs, config.launchPrefix,
config.launchEnvVars, config.isLoaded, config.startFullscreen,
config.skipIntro, config.instanceLaunchArgs,
config.customizations,
config.vfxEnabled,
config.animationsEnabled,
config.rpcEnabled,
config.musicVol,
config.sfxVol,
config.legacyMode,
config.mangohudEnabled,
config.extraLaunchArgs,
config.launchPrefix,
config.launchEnvVars,
config.isLoaded,
config.startFullscreen,
config.skipIntro,
config.instanceLaunchArgs,
]);
useEffect(() => {
const setupVisibilityDetection = async () => {
try {
const { listen } = await import("@tauri-apps/api/event");
const unlistenClose = await listen("tauri://close-requested", async () => {
setIsWindowVisible(false);
if (config.rpcEnabled) {
await RpcService.StopRPC();
}
});
const unlistenClose = await listen(
"tauri://close-requested",
async () => {
setIsWindowVisible(false);
if (config.rpcEnabled) {
await RpcService.StopRPC();
}
},
);
const unlistenShow = await listen("tauri://window-shown", () => {
setIsWindowVisible(true);
@ -179,14 +261,19 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
});
const unlistenBlur = await listen("tauri://blur", () => {
console.log("Window blurred - checking visibility");
setIsWindowVisible(false);
});
const onVisibilityChange = () => {
setIsWindowVisible(!document.hidden);
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
unlistenClose();
unlistenShow();
unlistenFocus();
unlistenBlur();
document.removeEventListener("visibilitychange", onVisibilityChange);
};
} catch (error) {
console.error("Failed to setup visibility detection:", error);
@ -197,14 +284,41 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
setupVisibilityDetection();
}, [config.rpcEnabled]);
const uiValue = useMemo(() => ({
activeView, setActiveView, showIntro, setShowIntro,
logoAnimDone, setLogoAnimDone, isUiHidden, setIsUiHidden,
isWindowVisible,
focusSection, setFocusSection,
onNavigateToSkin, onNavigateToMenu, connected,
updateMessage, updateUrl, clearUpdateMessage
}), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, updateUrl, clearUpdateMessage]);
const uiValue = useMemo(
() => ({
activeView,
setActiveView,
showIntro,
setShowIntro,
logoAnimDone,
setLogoAnimDone,
isUiHidden,
setIsUiHidden,
isWindowVisible,
focusSection,
setFocusSection,
onNavigateToSkin,
onNavigateToMenu,
connected,
updateMessage,
updateUrl,
clearUpdateMessage,
}),
[
activeView,
showIntro,
logoAnimDone,
isUiHidden,
isWindowVisible,
focusSection,
onNavigateToSkin,
onNavigateToMenu,
connected,
updateMessage,
updateUrl,
clearUpdateMessage,
],
);
return (
<UIContext.Provider value={uiValue}>
@ -221,8 +335,28 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
);
}
export const useUI = () => { const c = useContext(UIContext); if (!c) throw new Error("useUI must be used within LauncherProvider"); return c; };
export const useConfig = () => { const c = useContext(ConfigContext); if (!c) throw new Error("useConfig must be used within LauncherProvider"); return c; };
export const useAudio = () => { const c = useContext(AudioContext); if (!c) throw new Error("useAudio must be used within LauncherProvider"); return c; };
export const useGame = () => { const c = useContext(GameContext); if (!c) throw new Error("useGame must be used within LauncherProvider"); return c; };
export const useSkin = () => { const c = useContext(SkinContext); if (!c) throw new Error("useSkin must be used within LauncherProvider"); return c; };
export const useUI = () => {
const c = useContext(UIContext);
if (!c) throw new Error("useUI must be used within LauncherProvider");
return c;
};
export const useConfig = () => {
const c = useContext(ConfigContext);
if (!c) throw new Error("useConfig must be used within LauncherProvider");
return c;
};
export const useAudio = () => {
const c = useContext(AudioContext);
if (!c) throw new Error("useAudio must be used within LauncherProvider");
return c;
};
export const useGame = () => {
const c = useContext(GameContext);
if (!c) throw new Error("useGame must be used within LauncherProvider");
return c;
};
export const useSkin = () => {
const c = useContext(SkinContext);
if (!c) throw new Error("useSkin must be used within LauncherProvider");
return c;
};