From 262c8505b7b68fc94bf11bc8b26e1194bb973cdc Mon Sep 17 00:00:00 2001 From: Aden Lindsay Date: Fri, 5 Sep 2025 14:19:44 +0930 Subject: [PATCH] feat(library): implement playtime tracking on the frontend --- main/app.vue | 4 + main/components/PlaytimeDisplay.vue | 52 ++++++++ main/components/PlaytimeStats.vue | 114 ++++++++++++++++ main/composables/playtime.ts | 193 ++++++++++++++++++++++++++++ main/pages/library/[id]/index.vue | 30 ++++- main/types.ts | 34 +++++ src-tauri/src/playtime/manager.rs | 3 +- 7 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 main/components/PlaytimeDisplay.vue create mode 100644 main/components/PlaytimeStats.vue create mode 100644 main/composables/playtime.ts diff --git a/main/app.vue b/main/app.vue index 1594dd6..c7e1bb5 100644 --- a/main/app.vue +++ b/main/app.vue @@ -44,6 +44,10 @@ router.beforeEach(async () => { setupHooks(); initialNavigation(state); +// Setup playtime event listeners +const { setupEventListeners } = usePlaytime(); +setupEventListeners(); + useHead({ title: "Drop", }); diff --git a/main/components/PlaytimeDisplay.vue b/main/components/PlaytimeDisplay.vue new file mode 100644 index 0000000..f222eb7 --- /dev/null +++ b/main/components/PlaytimeDisplay.vue @@ -0,0 +1,52 @@ + + + diff --git a/main/components/PlaytimeStats.vue b/main/components/PlaytimeStats.vue new file mode 100644 index 0000000..9595a0f --- /dev/null +++ b/main/components/PlaytimeStats.vue @@ -0,0 +1,114 @@ + + + diff --git a/main/composables/playtime.ts b/main/composables/playtime.ts new file mode 100644 index 0000000..b19d54c --- /dev/null +++ b/main/composables/playtime.ts @@ -0,0 +1,193 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import type { + GamePlaytimeStats, + PlaytimeUpdateEvent, + PlaytimeSessionStartEvent, + PlaytimeSessionEndEvent +} from "~/types"; + +export const usePlaytime = () => { + const playtimeStats = useState>('playtime-stats', () => ({})); + const activeSessions = useState>('active-sessions', () => new Set()); + + // Fetch playtime stats for a specific game + const fetchGamePlaytime = async (gameId: string): Promise => { + try { + const stats = await invoke("fetch_game_playtime", { gameId }); + if (stats) { + playtimeStats.value[gameId] = stats; + } + return stats; + } catch (error) { + console.error(`Failed to fetch playtime for game ${gameId}:`, error); + return null; + } + }; + + // Fetch all playtime stats + const fetchAllPlaytimeStats = async (): Promise> => { + try { + const stats = await invoke>("fetch_all_playtime_stats"); + playtimeStats.value = stats; + return stats; + } catch (error) { + console.error("Failed to fetch all playtime stats:", error); + return {}; + } + }; + + // Check if a session is active + const isSessionActive = async (gameId: string): Promise => { + try { + return await invoke("is_playtime_session_active", { gameId }); + } catch (error) { + console.error(`Failed to check session status for game ${gameId}:`, error); + return false; + } + }; + + // Get all active sessions + const getActiveSessions = async (): Promise => { + try { + const sessions = await invoke("get_active_playtime_sessions"); + activeSessions.value = new Set(sessions); + return sessions; + } catch (error) { + console.error("Failed to get active sessions:", error); + return []; + } + }; + + // Format playtime duration + const formatPlaytime = (seconds: number): string => { + if (seconds < 60) { + return `${seconds}s`; + } else if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + return `${minutes}m`; + } else { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (minutes === 0) { + return `${hours}h`; + } + return `${hours}h ${minutes}m`; + } + }; + + // Format detailed playtime + const formatDetailedPlaytime = (seconds: number): string => { + if (seconds < 60) { + return `${seconds} seconds`; + } else if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (remainingSeconds === 0) { + return `${minutes} minutes`; + } + return `${minutes} minutes, ${remainingSeconds} seconds`; + } else { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (minutes === 0) { + return `${hours} hours`; + } + return `${hours} hours, ${minutes} minutes`; + } + }; + + // Format relative time (e.g., "2 hours ago") + const formatRelativeTime = (timestamp: string): string => { + const date = new Date(timestamp); + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) { + return "Just now"; + } else if (diffInSeconds < 3600) { + const minutes = Math.floor(diffInSeconds / 60); + return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; + } else if (diffInSeconds < 86400) { + const hours = Math.floor(diffInSeconds / 3600); + return `${hours} hour${hours !== 1 ? 's' : ''} ago`; + } else if (diffInSeconds < 604800) { + const days = Math.floor(diffInSeconds / 86400); + return `${days} day${days !== 1 ? 's' : ''} ago`; + } else { + return date.toLocaleDateString(); + } + }; + + // Get playtime stats for a game (from cache or fetch) + const getGamePlaytime = async (gameId: string): Promise => { + if (playtimeStats.value[gameId]) { + return playtimeStats.value[gameId]; + } + return await fetchGamePlaytime(gameId); + }; + + // Setup event listeners + const setupEventListeners = () => { + // Listen for general playtime updates + listen("playtime_update", (event) => { + const { gameId, stats, isActive } = event.payload; + playtimeStats.value[gameId] = stats; + + if (isActive) { + activeSessions.value.add(gameId); + } else { + activeSessions.value.delete(gameId); + } + }); + + // Listen for session start events + listen("playtime_session_start", (event) => { + const { gameId } = event.payload; + activeSessions.value.add(gameId); + }); + + // Listen for session end events + listen("playtime_session_end", (event) => { + const { gameId } = event.payload; + activeSessions.value.delete(gameId); + }); + }; + + // Setup game-specific event listeners + const setupGameEventListeners = (gameId: string) => { + listen(`playtime_update/${gameId}`, (event) => { + const { stats, isActive } = event.payload; + playtimeStats.value[gameId] = stats; + + if (isActive) { + activeSessions.value.add(gameId); + } else { + activeSessions.value.delete(gameId); + } + }); + + listen(`playtime_session_start/${gameId}`, () => { + activeSessions.value.add(gameId); + }); + + listen(`playtime_session_end/${gameId}`, () => { + activeSessions.value.delete(gameId); + }); + }; + + return { + playtimeStats: readonly(playtimeStats), + activeSessions: readonly(activeSessions), + fetchGamePlaytime, + fetchAllPlaytimeStats, + isSessionActive, + getActiveSessions, + formatPlaytime, + formatDetailedPlaytime, + formatRelativeTime, + getGamePlaytime, + setupEventListeners, + setupGameEventListeners, + }; +}; diff --git a/main/pages/library/[id]/index.vue b/main/pages/library/[id]/index.vue index 5fc30fc..f094c2b 100644 --- a/main/pages/library/[id]/index.vue +++ b/main/pages/library/[id]/index.vue @@ -18,10 +18,19 @@

{{ game.mName }}

+ + +
+ +
@@ -60,6 +69,12 @@
+ + +

Game Images @@ -528,6 +543,19 @@ const currentImageIndex = ref(0); const configureModalOpen = ref(false); +// Playtime tracking +const { + getGamePlaytime, + setupGameEventListeners, + activeSessions +} = usePlaytime(); + +const gamePlaytime = ref(await getGamePlaytime(id)); +const isPlaytimeActive = computed(() => activeSessions.value.has(id)); + +// Setup playtime event listeners for this game +setupGameEventListeners(id); + async function installFlow() { installFlowOpen.value = true; versionOptions.value = undefined; diff --git a/main/types.ts b/main/types.ts index f63a9c5..171e006 100644 --- a/main/types.ts +++ b/main/types.ts @@ -94,3 +94,37 @@ export type Settings = { maxDownloadThreads: number; forceOffline: boolean; }; + +export type GamePlaytimeStats = { + gameId: string; + totalPlaytimeSeconds: number; + sessionCount: number; + firstPlayed: string; + lastPlayed: string; + averageSessionLength: number; + currentSessionDuration?: number; +}; + +export type PlaytimeSession = { + gameId: string; + startTime: string; + sessionId: string; +}; + +export type PlaytimeUpdateEvent = { + gameId: string; + stats: GamePlaytimeStats; + isActive: boolean; +}; + +export type PlaytimeSessionStartEvent = { + gameId: string; + startTime: string; +}; + +export type PlaytimeSessionEndEvent = { + gameId: string; + sessionDurationSeconds: number; + totalPlaytimeSeconds: number; + sessionCount: number; +}; \ No newline at end of file diff --git a/src-tauri/src/playtime/manager.rs b/src-tauri/src/playtime/manager.rs index 2faf08a..b04baeb 100644 --- a/src-tauri/src/playtime/manager.rs +++ b/src-tauri/src/playtime/manager.rs @@ -51,13 +51,14 @@ pub struct PlaytimeStats { impl From for PlaytimeStats { fn from(stats: GamePlaytimeStats) -> Self { + let average_length = stats.average_session_length(); Self { game_id: stats.game_id, total_playtime_seconds: stats.total_playtime_seconds, session_count: stats.session_count, first_played: stats.first_played, last_played: stats.last_played, - average_session_length: stats.average_session_length(), + average_session_length: average_length, current_session_duration: None, } }