diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index 17838e4..0076acb 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -1,5 +1,6 @@ use std::fs; use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Manager, State}; use tauri_plugin_opener::OpenerExt; use crate::commands::runners; @@ -8,6 +9,7 @@ use crate::config; use crate::platform::macos; #[cfg(unix)] use crate::platform::linux; +use crate::playtime::{self, PlaytimeResponse}; use crate::state::GameState; use crate::types::McServer; use crate::util; @@ -113,6 +115,7 @@ pub async fn launch_game( cmd.arg(a); } cmd.current_dir(&working_dir); + let playtime_start = std::time::Instant::now(); let child = cmd.spawn().map_err(|e| e.to_string())?; { let mut lock = state.child.lock().await; @@ -138,6 +141,11 @@ pub async fn launch_game( *lock = None; } + let duration = playtime_start.elapsed(); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let start = now - duration.as_secs(); + playtime::record_session(&app, &instance_id, start, now); + return if status.success() || status.code() == Some(253) || status.code() == Some(96) { Ok(()) } else { @@ -222,6 +230,7 @@ pub async fn launch_game( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); + let playtime_start = std::time::Instant::now(); let child = cmd.spawn().map_err(|e| e.to_string())?; { let mut lock = state.child.lock().await; @@ -247,6 +256,11 @@ pub async fn launch_game( *lock = None; } + let duration = playtime_start.elapsed(); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let start = now - duration.as_secs(); + playtime::record_session(&app, &instance_id, start, now); + return if status.success() || status.code() == Some(253) || status.code() == Some(96) { Ok(()) } else { @@ -263,6 +277,7 @@ pub async fn launch_game( #[cfg(unix)] cmd.process_group(0); cmd.current_dir(&working_dir); + let playtime_start = std::time::Instant::now(); let child = cmd.spawn().map_err(|e| e.to_string())?; { let mut lock = state.child.lock().await; @@ -285,6 +300,10 @@ pub async fn launch_game( let mut lock = state.child.lock().await; *lock = None; } + let duration = playtime_start.elapsed(); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let start = now - duration.as_secs(); + playtime::record_session(&app, &instance_id, start, now); return if status.success() || status.code() == Some(253) || status.code() == Some(96) { Ok(()) } else { @@ -362,6 +381,11 @@ pub fn get_instance_path(app: AppHandle, instance_id: String) -> String { .to_string() } +#[tauri::command] +pub fn get_playtime(app: AppHandle, instance_id: String) -> PlaytimeResponse { + playtime::get_playtime(&app, &instance_id) +} + pub fn ensure_server_list(instance_dir: &PathBuf, servers: Vec) { let servers_db = instance_dir.join("servers.db"); let mut all_servers = Vec::new(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d9581a0..f0cfceb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod types; mod state; mod config; mod util; +mod playtime; mod platform; mod networking; mod workshop_server; @@ -80,6 +81,7 @@ pub fn run() { steam::add_to_steam, proxy_cmd::http_proxy_request, game::get_instance_path, + game::get_playtime, commands::console2lce::import_world, stun::stun_discover, direct::start_direct_proxy, diff --git a/src-tauri/src/playtime.rs b/src-tauri/src/playtime.rs new file mode 100644 index 0000000..33465bf --- /dev/null +++ b/src-tauri/src/playtime.rs @@ -0,0 +1,74 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use serde::{Serialize, Deserialize}; +use crate::util; +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct PlaytimeSession { + pub start: u64, + pub end: u64, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct PlaytimeData { + pub sessions: HashMap>, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PlaytimeResponse { + pub total_seconds: u64, + pub week_seconds: u64, + pub day_seconds: u64, +} + +fn playtime_path(app: &AppHandle) -> PathBuf { + util::get_app_dir(app).join("playtime.json") +} + +pub fn load(app: &AppHandle) -> PlaytimeData { + let path = playtime_path(app); + if path.exists() { + let content = std::fs::read_to_string(&path).unwrap_or_default(); + serde_json::from_str(&content).unwrap_or(PlaytimeData { sessions: HashMap::new() }) + } else { + PlaytimeData { sessions: HashMap::new() } + } +} + +pub fn save(app: &AppHandle, data: &PlaytimeData) { + let path = playtime_path(app); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(content) = serde_json::to_string_pretty(data) { + let _ = std::fs::write(&path, content); + } +} + +pub fn record_session(app: &AppHandle, instance_id: &str, start: u64, end: u64) { + let mut data = load(app); + let sessions = data.sessions.entry(instance_id.to_string()).or_default(); + sessions.push(PlaytimeSession { start, end }); + save(app, &data); +} + +pub fn get_playtime(app: &AppHandle, instance_id: &str) -> PlaytimeResponse { + let data = load(app); + let sessions = data.sessions.get(instance_id); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let week_ago = now - 7 * 24 * 60 * 60; + let day_ago = now - 24 * 60 * 60; + let total_seconds: u64 = sessions.map_or(0, |s| s.iter().map(|s| s.end - s.start).sum()); + let week_seconds: u64 = sessions.map_or(0, |s| s.iter() + .filter(|s| s.start >= week_ago) + .map(|s| s.end - s.start) + .sum()); + let day_seconds: u64 = sessions.map_or(0, |s| s.iter() + .filter(|s| s.start >= day_ago) + .map(|s| s.end - s.start) + .sum()); + + PlaytimeResponse { total_seconds, week_seconds, day_seconds } +} diff --git a/src/components/modals/PlaytimeModal.tsx b/src/components/modals/PlaytimeModal.tsx new file mode 100644 index 0000000..ecb999a --- /dev/null +++ b/src/components/modals/PlaytimeModal.tsx @@ -0,0 +1,129 @@ +import { useState, useEffect } from "react"; +import { motion } from "framer-motion"; +import { TauriService, PlaytimeResponse } from "../../services/TauriService"; + +function formatTime(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return `${h}h ${m}m ${s}s`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +export default function PlaytimeModal({ + isOpen, + onClose, + instanceId, + instanceName, + playBackSound, +}: { + isOpen: boolean; + onClose: () => void; + instanceId: string; + instanceName: string; + playBackSound: (s?: string) => void; +}) { + const [playtime, setPlaytime] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!isOpen) { + setPlaytime(null); + return; + } + setLoading(true); + TauriService.getPlaytime(instanceId) + .then(setPlaytime) + .catch(console.error) + .finally(() => setLoading(false)); + }, [isOpen, instanceId]); + + useEffect(() => { + if (!isOpen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + playBackSound("close_click.wav"); + onClose(); + } + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [isOpen, onClose, playBackSound]); + + if (!isOpen) return null; + + return ( + +
+

+ Playtime +

+ +

+ {instanceName} +

+ + {loading ? ( +
Loading...
+ ) : playtime ? ( +
+
+ + Total + + + {formatTime(playtime.totalSeconds)} + +
+
+ + This Week + + + {formatTime(playtime.weekSeconds)} + +
+
+ + Today + + + {formatTime(playtime.daySeconds)} + +
+
+ ) : ( +
Failed to load playtime data.
+ )} + + +
+
+ ); +} diff --git a/src/components/views/VersionsView.tsx b/src/components/views/VersionsView.tsx index a66c397..a348886 100644 --- a/src/components/views/VersionsView.tsx +++ b/src/components/views/VersionsView.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useRef, memo } from "react"; import { motion } from "framer-motion"; -import { TauriService } from "../../services/TauriService"; +import { TauriService, PlaytimeResponse } from "../../services/TauriService"; import CustomTUModal from "../modals/CustomTUModal"; import SetUidModal from "../modals/SetUidModal"; import ImportWorldModal from "../modals/ImportWorldModal"; +import PlaytimeModal from "../modals/PlaytimeModal"; import { useUI, useConfig, @@ -46,6 +47,14 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({ ); }); +function formatPlaytime(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m`; + return seconds > 0 ? `${seconds}s` : ""; +} + const VersionsView = memo(function VersionsView() { const { setActiveView } = useUI(); const { @@ -78,6 +87,9 @@ const VersionsView = memo(function VersionsView() { const [editingEdition, setEditingEdition] = useState(null); const [isImportWorldModalOpen, setIsImportWorldModalOpen] = useState(false); const [importWorldTarget, setImportWorldTarget] = useState<{ id: string; name: string } | null>(null); + const [isPlaytimeModalOpen, setIsPlaytimeModalOpen] = useState(false); + const [playtimeTarget, setPlaytimeTarget] = useState<{ id: string; name: string } | null>(null); + const [playtimeMap, setPlaytimeMap] = useState>({}); const [initialPath, setInitialPath] = useState(""); const [hoveredBtn, setHoveredBtn] = useState<{ row: number; @@ -189,6 +201,21 @@ const VersionsView = memo(function VersionsView() { } }, [focusIndex]); + useEffect(() => { + const fetchPlaytimes = async () => { + const map: Record = {}; + await Promise.all(installedVersions.map(async (id) => { + try { + map[id] = await TauriService.getPlaytime(id); + } catch (e) { + console.error(e); + } + })); + setPlaytimeMap(map); + }; + fetchPlaytimes(); + }, [installedVersions]); + const handleEditionClick = (edition: Edition, index: number) => { const isInstalled = installedVersions.includes(edition.instanceId); if (isInstalled) { @@ -331,6 +358,36 @@ const VersionsView = memo(function VersionsView() { > {edition.name} + {isInstalled && ( + + )} {edition.category && edition.category.map((cat: string) => ( + { setIsPlaytimeModalOpen(false); setPlaytimeTarget(null); }} + playBackSound={playBackSound} + instanceId={playtimeTarget?.id ?? ""} + instanceName={playtimeTarget?.name ?? ""} + /> + {deleteConfirmEdition && (
{ return invoke("save_config", { config }); @@ -283,6 +289,10 @@ export class TauriService { return invoke("http_proxy_request", { method, url, body, headers }); } + static async getPlaytime(instanceId: string): Promise { + return invoke("get_playtime", { instanceId }); + } + static async getInstancePath(instanceId: string): Promise { return invoke("get_instance_path", { instanceId }); }