mirror of
https://github.com/Drop-OSS/drop-app.git
synced 2025-11-10 04:22:13 +10:00
feat(library): implement playtime tracking on the frontend
This commit is contained in:
@ -44,6 +44,10 @@ router.beforeEach(async () => {
|
||||
setupHooks();
|
||||
initialNavigation(state);
|
||||
|
||||
// Setup playtime event listeners
|
||||
const { setupEventListeners } = usePlaytime();
|
||||
setupEventListeners();
|
||||
|
||||
useHead({
|
||||
title: "Drop",
|
||||
});
|
||||
|
||||
52
main/components/PlaytimeDisplay.vue
Normal file
52
main/components/PlaytimeDisplay.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div v-if="stats" class="flex flex-col gap-1">
|
||||
<!-- Main playtime display -->
|
||||
<div class="flex items-center gap-2">
|
||||
<ClockIcon class="w-4 h-4 text-zinc-400" />
|
||||
<span class="text-sm text-zinc-300">
|
||||
{{ formatPlaytime(stats.totalPlaytimeSeconds) }}
|
||||
</span>
|
||||
<span v-if="isActive" class="text-xs text-green-400 font-medium">
|
||||
• Playing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Additional details when expanded -->
|
||||
<div v-if="showDetails" class="text-xs text-zinc-400 space-y-1 ml-6">
|
||||
<div>{{ stats.sessionCount }} session{{ stats.sessionCount !== 1 ? 's' : '' }}</div>
|
||||
<div v-if="stats.sessionCount > 0">
|
||||
Avg: {{ formatPlaytime(stats.averageSessionLength) }} per session
|
||||
</div>
|
||||
<div>Last played {{ formatRelativeTime(stats.lastPlayed) }}</div>
|
||||
<div v-if="stats.currentSessionDuration">
|
||||
Current session: {{ formatPlaytime(stats.currentSessionDuration) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No playtime data -->
|
||||
<div v-else-if="showWhenEmpty" class="flex items-center gap-2 text-zinc-500">
|
||||
<ClockIcon class="w-4 h-4" />
|
||||
<span class="text-sm">Never played</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClockIcon } from "@heroicons/vue/20/solid";
|
||||
import type { GamePlaytimeStats } from "~/types";
|
||||
|
||||
interface Props {
|
||||
stats: GamePlaytimeStats | null;
|
||||
isActive?: boolean;
|
||||
showDetails?: boolean;
|
||||
showWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false,
|
||||
showDetails: false,
|
||||
showWhenEmpty: true,
|
||||
});
|
||||
|
||||
const { formatPlaytime, formatRelativeTime } = usePlaytime();
|
||||
</script>
|
||||
114
main/components/PlaytimeStats.vue
Normal file
114
main/components/PlaytimeStats.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="bg-zinc-800/50 rounded-lg p-4 space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<ChartBarIcon class="w-5 h-5 text-zinc-400" />
|
||||
<h3 class="text-lg font-semibold text-zinc-100">Playtime Statistics</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="stats" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Total Playtime -->
|
||||
<div class="bg-zinc-700/50 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<ClockIcon class="w-4 h-4 text-blue-400" />
|
||||
<span class="text-sm font-medium text-zinc-300">Total Playtime</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-zinc-100">
|
||||
{{ formatDetailedPlaytime(stats.totalPlaytimeSeconds) }}
|
||||
</div>
|
||||
<div v-if="stats.currentSessionDuration" class="text-xs text-green-400 mt-1">
|
||||
+{{ formatPlaytime(stats.currentSessionDuration) }} this session
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessions -->
|
||||
<div class="bg-zinc-700/50 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<PlayIcon class="w-4 h-4 text-green-400" />
|
||||
<span class="text-sm font-medium text-zinc-300">Sessions</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-zinc-100">
|
||||
{{ stats.sessionCount }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-400 mt-1">
|
||||
Avg: {{ formatPlaytime(stats.averageSessionLength) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- First Played -->
|
||||
<div class="bg-zinc-700/50 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<CalendarIcon class="w-4 h-4 text-purple-400" />
|
||||
<span class="text-sm font-medium text-zinc-300">First Played</span>
|
||||
</div>
|
||||
<div class="text-lg font-semibold text-zinc-100">
|
||||
{{ formatDate(stats.firstPlayed) }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-400 mt-1">
|
||||
{{ formatRelativeTime(stats.firstPlayed) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Played -->
|
||||
<div class="bg-zinc-700/50 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<ClockIcon class="w-4 h-4 text-orange-400" />
|
||||
<span class="text-sm font-medium text-zinc-300">Last Played</span>
|
||||
</div>
|
||||
<div class="text-lg font-semibold text-zinc-100">
|
||||
{{ formatDate(stats.lastPlayed) }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-400 mt-1">
|
||||
{{ formatRelativeTime(stats.lastPlayed) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No stats available -->
|
||||
<div v-else class="text-center py-8">
|
||||
<ClockIcon class="w-12 h-12 text-zinc-600 mx-auto mb-3" />
|
||||
<p class="text-zinc-400">No playtime data available</p>
|
||||
<p class="text-sm text-zinc-500 mt-1">Statistics will appear after you start playing</p>
|
||||
</div>
|
||||
|
||||
<!-- Current session indicator -->
|
||||
<div v-if="isActive && stats" class="border-t border-zinc-700 pt-3">
|
||||
<div class="flex items-center gap-2 text-green-400">
|
||||
<div class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span class="text-sm font-medium">Currently playing</span>
|
||||
<span v-if="stats.currentSessionDuration" class="text-xs text-zinc-400">
|
||||
{{ formatPlaytime(stats.currentSessionDuration) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
PlayIcon,
|
||||
CalendarIcon
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import type { GamePlaytimeStats } from "~/types";
|
||||
|
||||
interface Props {
|
||||
stats: GamePlaytimeStats | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
const { formatPlaytime, formatDetailedPlaytime, formatRelativeTime } = usePlaytime();
|
||||
|
||||
const formatDate = (timestamp: string): string => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
</script>
|
||||
193
main/composables/playtime.ts
Normal file
193
main/composables/playtime.ts
Normal file
@ -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<Record<string, GamePlaytimeStats>>('playtime-stats', () => ({}));
|
||||
const activeSessions = useState<Set<string>>('active-sessions', () => new Set());
|
||||
|
||||
// Fetch playtime stats for a specific game
|
||||
const fetchGamePlaytime = async (gameId: string): Promise<GamePlaytimeStats | null> => {
|
||||
try {
|
||||
const stats = await invoke<GamePlaytimeStats | null>("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<Record<string, GamePlaytimeStats>> => {
|
||||
try {
|
||||
const stats = await invoke<Record<string, GamePlaytimeStats>>("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<boolean> => {
|
||||
try {
|
||||
return await invoke<boolean>("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<string[]> => {
|
||||
try {
|
||||
const sessions = await invoke<string[]>("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<GamePlaytimeStats | null> => {
|
||||
if (playtimeStats.value[gameId]) {
|
||||
return playtimeStats.value[gameId];
|
||||
}
|
||||
return await fetchGamePlaytime(gameId);
|
||||
};
|
||||
|
||||
// Setup event listeners
|
||||
const setupEventListeners = () => {
|
||||
// Listen for general playtime updates
|
||||
listen<PlaytimeUpdateEvent>("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<PlaytimeSessionStartEvent>("playtime_session_start", (event) => {
|
||||
const { gameId } = event.payload;
|
||||
activeSessions.value.add(gameId);
|
||||
});
|
||||
|
||||
// Listen for session end events
|
||||
listen<PlaytimeSessionEndEvent>("playtime_session_end", (event) => {
|
||||
const { gameId } = event.payload;
|
||||
activeSessions.value.delete(gameId);
|
||||
});
|
||||
};
|
||||
|
||||
// Setup game-specific event listeners
|
||||
const setupGameEventListeners = (gameId: string) => {
|
||||
listen<PlaytimeUpdateEvent>(`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<PlaytimeSessionStartEvent>(`playtime_session_start/${gameId}`, () => {
|
||||
activeSessions.value.add(gameId);
|
||||
});
|
||||
|
||||
listen<PlaytimeSessionEndEvent>(`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,
|
||||
};
|
||||
};
|
||||
@ -18,10 +18,19 @@
|
||||
<div class="relative z-10">
|
||||
<div class="px-8 pb-4">
|
||||
<h1
|
||||
class="text-5xl text-zinc-100 font-bold font-display drop-shadow-lg mb-8"
|
||||
class="text-5xl text-zinc-100 font-bold font-display drop-shadow-lg mb-4"
|
||||
>
|
||||
{{ game.mName }}
|
||||
</h1>
|
||||
|
||||
<!-- Playtime Display -->
|
||||
<div class="mb-8">
|
||||
<PlaytimeDisplay
|
||||
:stats="gamePlaytime"
|
||||
:is-active="isPlaytimeActive"
|
||||
:show-details="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-x-4 items-stretch mb-8">
|
||||
<!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 -->
|
||||
@ -60,6 +69,12 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Playtime Statistics -->
|
||||
<PlaytimeStats
|
||||
:stats="gamePlaytime"
|
||||
:is-active="isPlaytimeActive"
|
||||
/>
|
||||
|
||||
<div class="bg-zinc-800/50 rounded-xl p-6 backdrop-blur-sm">
|
||||
<h2 class="text-xl font-display font-semibold text-zinc-100 mb-4">
|
||||
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;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -51,13 +51,14 @@ pub struct PlaytimeStats {
|
||||
|
||||
impl From<GamePlaytimeStats> 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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user