mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-08-20 04:27:29 +00:00
fix(android): playtime tracking and music stopping on unfocus
This commit is contained in:
parent
7a2650bd48
commit
17c2000b97
File diff suppressed because one or more lines are too long
|
|
@ -54,11 +54,15 @@ pub async fn launch_game(
|
||||||
}
|
}
|
||||||
ensure_server_list(&working_dir, servers);
|
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(),
|
working_dir.to_string_lossy().to_string(),
|
||||||
crate::android_runtime::BridgeAction::Play,
|
crate::android_runtime::BridgeAction::Play,
|
||||||
extra_args,
|
extra_args,
|
||||||
)
|
);
|
||||||
|
if result.is_ok() {
|
||||||
|
playtime::start_session(&app, &instance_id);
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
launch_game_desktop(app, state, instance_id, servers, extra_args).await
|
launch_game_desktop(app, state, instance_id, servers, extra_args).await
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
#[cfg(desktop)]
|
||||||
{
|
{
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ pub struct PlaytimeData {
|
||||||
pub sessions: HashMap<String, Vec<PlaytimeSession>>,
|
pub sessions: HashMap<String, Vec<PlaytimeSession>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct ActiveSession {
|
||||||
|
instance_id: String,
|
||||||
|
start: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone, Debug)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlaytimeResponse {
|
pub struct PlaytimeResponse {
|
||||||
|
|
@ -54,6 +60,34 @@ pub fn record_session(app: &AppHandle, instance_id: &str, start: u64, end: u64)
|
||||||
save(app, &data);
|
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 {
|
pub fn get_playtime(app: &AppHandle, instance_id: &str) -> PlaytimeResponse {
|
||||||
let data = load(app);
|
let data = load(app);
|
||||||
let sessions = data.sessions.get(instance_id);
|
let sessions = data.sessions.get(instance_id);
|
||||||
|
|
|
||||||
|
|
@ -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 { useAppConfig } from "../hooks/useAppConfig";
|
||||||
import { TauriService } from "../services/TauriService";
|
import { TauriService } from "../services/TauriService";
|
||||||
import { useAudioController } from "../hooks/useAudioController";
|
import { useAudioController } from "../hooks/useAudioController";
|
||||||
|
|
@ -29,10 +36,18 @@ interface UIContextType {
|
||||||
clearUpdateMessage: () => void;
|
clearUpdateMessage: () => void;
|
||||||
}
|
}
|
||||||
const UIContext = createContext<UIContextType | undefined>(undefined);
|
const UIContext = createContext<UIContextType | undefined>(undefined);
|
||||||
export const ConfigContext = createContext<ReturnType<typeof useAppConfig> | undefined>(undefined);
|
export const ConfigContext = createContext<
|
||||||
export const AudioContext = createContext<ReturnType<typeof useAudioController> | undefined>(undefined);
|
ReturnType<typeof useAppConfig> | undefined
|
||||||
export const GameContext = createContext<ReturnType<typeof useGameManager> | undefined>(undefined);
|
>(undefined);
|
||||||
export const SkinContext = createContext<ReturnType<typeof useSkinSync> | 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 }) {
|
export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [showIntro, setShowIntro] = useState(true);
|
const [showIntro, setShowIntro] = useState(true);
|
||||||
const [logoAnimDone, setLogoAnimDone] = useState(false);
|
const [logoAnimDone, setLogoAnimDone] = useState(false);
|
||||||
|
|
@ -55,7 +70,11 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||||
setCustomizations: configRaw.setCustomizations,
|
setCustomizations: configRaw.setCustomizations,
|
||||||
extraLaunchArgs: configRaw.extraLaunchArgs,
|
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({
|
const audioRaw = useAudioController({
|
||||||
musicVol: configRaw.musicVol,
|
musicVol: configRaw.musicVol,
|
||||||
sfxVol: configRaw.sfxVol,
|
sfxVol: configRaw.sfxVol,
|
||||||
|
|
@ -63,33 +82,78 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||||
isWindowVisible,
|
isWindowVisible,
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = useMemo(() => configRaw, [
|
const config = useMemo(
|
||||||
configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled,
|
() => configRaw,
|
||||||
configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime,
|
[
|
||||||
configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions,
|
configRaw.username,
|
||||||
configRaw.customPaths,
|
configRaw.theme,
|
||||||
configRaw.customizations,
|
configRaw.layout,
|
||||||
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled,
|
configRaw.vfxEnabled,
|
||||||
configRaw.extraLaunchArgs, configRaw.launchPrefix, configRaw.launchEnvVars, configRaw.startFullscreen,
|
configRaw.rpcEnabled,
|
||||||
configRaw.skipIntro, configRaw.instanceLaunchArgs,
|
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, [
|
const game = useMemo(
|
||||||
gameRaw.installs, gameRaw.isGameRunning, gameRaw.downloadProgress,
|
() => gameRaw,
|
||||||
gameRaw.downloadingIds, gameRaw.editions, gameRaw.isRunnerDownloading,
|
[
|
||||||
gameRaw.runnerDownloadProgress, gameRaw.error, gameRaw.updateCustomEdition,
|
gameRaw.installs,
|
||||||
gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile,
|
gameRaw.isGameRunning,
|
||||||
gameRaw.updatesAvailable, gameRaw.addToSteam, gameRaw.steamSuccessMessage,
|
gameRaw.downloadProgress,
|
||||||
gameRaw.cycleBranch, gameRaw.toggleInstall, gameRaw.checkInstalls,
|
gameRaw.downloadingIds,
|
||||||
gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.addCustomEdition,
|
gameRaw.editions,
|
||||||
gameRaw.deleteCustomEdition, gameRaw.downloadRunner,
|
gameRaw.isRunnerDownloading,
|
||||||
gameRaw.customizations, gameRaw.updateCustomization,
|
gameRaw.runnerDownloadProgress,
|
||||||
gameRaw.gameLog, gameRaw.clearGameLog,
|
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, [
|
const audio = useMemo(
|
||||||
audioRaw.currentTrack, audioRaw.splashIndex, audioRaw.tracks, audioRaw.splashes
|
() => audioRaw,
|
||||||
]);
|
[
|
||||||
|
audioRaw.currentTrack,
|
||||||
|
audioRaw.splashIndex,
|
||||||
|
audioRaw.tracks,
|
||||||
|
audioRaw.splashes,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
useDiscordRPC({
|
useDiscordRPC({
|
||||||
rpcEnabled: config.rpcEnabled,
|
rpcEnabled: config.rpcEnabled,
|
||||||
|
|
@ -143,32 +207,50 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||||
extraLaunchArgs: config.extraLaunchArgs,
|
extraLaunchArgs: config.extraLaunchArgs,
|
||||||
launchPrefix: config.launchPrefix,
|
launchPrefix: config.launchPrefix,
|
||||||
launchEnvVars: config.launchEnvVars,
|
launchEnvVars: config.launchEnvVars,
|
||||||
startFullscreen: config.startFullscreen,
|
startFullscreen: config.startFullscreen,
|
||||||
skipIntro: config.skipIntro,
|
skipIntro: config.skipIntro,
|
||||||
instanceLaunchArgs: config.instanceLaunchArgs,
|
instanceLaunchArgs: config.instanceLaunchArgs,
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
config.username, skinSync.skinBase64, config.theme, config.linuxRunner,
|
config.username,
|
||||||
config.perfBoost, config.customEditions, config.profile,
|
skinSync.skinBase64,
|
||||||
|
config.theme,
|
||||||
|
config.linuxRunner,
|
||||||
|
config.perfBoost,
|
||||||
|
config.customEditions,
|
||||||
|
config.profile,
|
||||||
config.customPaths,
|
config.customPaths,
|
||||||
config.customizations, config.vfxEnabled, config.animationsEnabled,
|
config.customizations,
|
||||||
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
|
config.vfxEnabled,
|
||||||
config.mangohudEnabled, config.extraLaunchArgs, config.launchPrefix,
|
config.animationsEnabled,
|
||||||
config.launchEnvVars, config.isLoaded, config.startFullscreen,
|
config.rpcEnabled,
|
||||||
config.skipIntro, config.instanceLaunchArgs,
|
config.musicVol,
|
||||||
|
config.sfxVol,
|
||||||
|
config.legacyMode,
|
||||||
|
config.mangohudEnabled,
|
||||||
|
config.extraLaunchArgs,
|
||||||
|
config.launchPrefix,
|
||||||
|
config.launchEnvVars,
|
||||||
|
config.isLoaded,
|
||||||
|
config.startFullscreen,
|
||||||
|
config.skipIntro,
|
||||||
|
config.instanceLaunchArgs,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const setupVisibilityDetection = async () => {
|
const setupVisibilityDetection = async () => {
|
||||||
try {
|
try {
|
||||||
const { listen } = await import("@tauri-apps/api/event");
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
const unlistenClose = await listen("tauri://close-requested", async () => {
|
const unlistenClose = await listen(
|
||||||
setIsWindowVisible(false);
|
"tauri://close-requested",
|
||||||
if (config.rpcEnabled) {
|
async () => {
|
||||||
await RpcService.StopRPC();
|
setIsWindowVisible(false);
|
||||||
}
|
if (config.rpcEnabled) {
|
||||||
});
|
await RpcService.StopRPC();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const unlistenShow = await listen("tauri://window-shown", () => {
|
const unlistenShow = await listen("tauri://window-shown", () => {
|
||||||
setIsWindowVisible(true);
|
setIsWindowVisible(true);
|
||||||
|
|
@ -179,14 +261,19 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const unlistenBlur = await listen("tauri://blur", () => {
|
const unlistenBlur = await listen("tauri://blur", () => {
|
||||||
console.log("Window blurred - checking visibility");
|
setIsWindowVisible(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
setIsWindowVisible(!document.hidden);
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
return () => {
|
return () => {
|
||||||
unlistenClose();
|
unlistenClose();
|
||||||
unlistenShow();
|
unlistenShow();
|
||||||
unlistenFocus();
|
unlistenFocus();
|
||||||
unlistenBlur();
|
unlistenBlur();
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to setup visibility detection:", error);
|
console.error("Failed to setup visibility detection:", error);
|
||||||
|
|
@ -197,14 +284,41 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
||||||
setupVisibilityDetection();
|
setupVisibilityDetection();
|
||||||
}, [config.rpcEnabled]);
|
}, [config.rpcEnabled]);
|
||||||
|
|
||||||
const uiValue = useMemo(() => ({
|
const uiValue = useMemo(
|
||||||
activeView, setActiveView, showIntro, setShowIntro,
|
() => ({
|
||||||
logoAnimDone, setLogoAnimDone, isUiHidden, setIsUiHidden,
|
activeView,
|
||||||
isWindowVisible,
|
setActiveView,
|
||||||
focusSection, setFocusSection,
|
showIntro,
|
||||||
onNavigateToSkin, onNavigateToMenu, connected,
|
setShowIntro,
|
||||||
updateMessage, updateUrl, clearUpdateMessage
|
logoAnimDone,
|
||||||
}), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, updateUrl, clearUpdateMessage]);
|
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 (
|
return (
|
||||||
<UIContext.Provider value={uiValue}>
|
<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 useUI = () => {
|
||||||
export const useConfig = () => { const c = useContext(ConfigContext); if (!c) throw new Error("useConfig must be used within LauncherProvider"); return c; };
|
const c = useContext(UIContext);
|
||||||
export const useAudio = () => { const c = useContext(AudioContext); if (!c) throw new Error("useAudio must be used within LauncherProvider"); return c; };
|
if (!c) throw new Error("useUI must be used within LauncherProvider");
|
||||||
export const useGame = () => { const c = useContext(GameContext); if (!c) throw new Error("useGame must be used within LauncherProvider"); return c; };
|
return c;
|
||||||
export const useSkin = () => { const c = useContext(SkinContext); if (!c) throw new Error("useSkin 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;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue