feat: add platform game counts and enhance platform selection in Setup.vue

This commit is contained in:
zurdi
2025-12-18 16:23:34 +00:00
parent 11387b57d3
commit d51dbdb1cc
5 changed files with 305 additions and 41 deletions

View File

@@ -172,6 +172,7 @@ async def get_setup_library_info():
Returns:
- detected_structure: "A" (roms/{platform}), "B" ({platform}/roms), or None
- existing_platforms: list of platform fs_slugs already in filesystem
- platform_game_counts: dict mapping platform fs_slug to game count
- supported_platforms: list of all supported platforms with metadata
"""
@@ -190,12 +191,47 @@ async def get_setup_library_info():
except Exception:
existing_platforms = []
# Count games for each existing platform
platform_game_counts = {}
if detected_structure and existing_platforms:
cnfg = cm.get_config()
for fs_slug in existing_platforms:
try:
# Determine the roms directory based on structure
if detected_structure == "A":
roms_path = os.path.join(
LIBRARY_BASE_PATH, cnfg.ROMS_FOLDER_NAME, fs_slug
)
else: # Structure B
roms_path = os.path.join(
LIBRARY_BASE_PATH, fs_slug, cnfg.ROMS_FOLDER_NAME
)
# Count files and folders in the roms directory
if os.path.exists(roms_path):
items = os.listdir(roms_path)
# Filter out hidden files and system files
game_count = len(
[
item
for item in items
if not item.startswith(".")
and item not in ["_resources", "_cache"]
]
)
platform_game_counts[fs_slug] = game_count
else:
platform_game_counts[fs_slug] = 0
except Exception:
platform_game_counts[fs_slug] = 0
# Get all supported platforms with metadata
supported_platforms = get_supported_platforms()
return {
"detected_structure": detected_structure,
"existing_platforms": existing_platforms,
"platform_game_counts": platform_game_counts,
"supported_platforms": supported_platforms,
}

View File

@@ -86,9 +86,21 @@ class FSPlatformsHandler(FSHandler):
Returns:
List of platform slugs.
"""
cnfg = cm.get_config()
try:
platforms = await self.list_directories(path=self.get_platforms_directory())
except FileNotFoundError as e:
raise FolderStructureNotMatchException() from e
# For Structure B, only include directories that have a roms subfolder
if not os.path.exists(cnfg.HIGH_PRIO_STRUCTURE_PATH):
platforms = [
platform
for platform in platforms
if os.path.exists(
os.path.join(LIBRARY_BASE_PATH, platform, cnfg.ROMS_FOLDER_NAME)
)
]
return self._exclude_platforms(platforms)

View File

@@ -9,6 +9,9 @@ const props = defineProps<{
showCheckboxes?: boolean;
keyPrefix?: string;
baseIndex?: number;
onToggleGroup?: (platforms: Platform[], checked: boolean) => void;
isGroupFullySelected?: (platforms: Platform[]) => boolean;
platformGameCounts?: Record<string, number>;
}>();
const emit = defineEmits<{
@@ -42,18 +45,33 @@ const countSelectedInGroup = (platforms: Platform[]) => {
class="bg-transparent"
>
<v-expansion-panel-title class="text-white text-shadow">
<strong>{{ groupName }}</strong>
<span class="ml-2 text-caption text-grey"
>({{ platforms.length }})</span
>
<v-chip
v-if="showCheckboxes && countSelectedInGroup(platforms) > 0"
size="x-small"
color="primary"
class="ml-2"
>
{{ countSelectedInGroup(platforms) }} selected
</v-chip>
<template #default>
<div class="d-flex align-center w-100">
<v-checkbox
v-if="showCheckboxes && onToggleGroup && isGroupFullySelected"
:model-value="isGroupFullySelected(platforms)"
hide-details
density="compact"
class="mr-2 flex-grow-0"
@click.stop
@update:model-value="onToggleGroup(platforms, $event as boolean)"
/>
<div class="flex-grow-1">
<strong>{{ groupName }}</strong>
<span class="ml-2 text-caption text-grey"
>({{ platforms.length }})</span
>
<v-chip
v-if="showCheckboxes && countSelectedInGroup(platforms) > 0"
size="x-small"
color="primary"
class="ml-2"
>
{{ countSelectedInGroup(platforms) }} selected
</v-chip>
</div>
</div>
</template>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list lines="two" class="py-1 px-0 bg-transparent">
@@ -61,7 +79,6 @@ const countSelectedInGroup = (platforms: Platform[]) => {
v-for="platform in platforms"
:key="platform.fs_slug"
:platform="platform"
:show-rom-count="false"
>
<template v-if="showCheckboxes" #prepend>
<v-checkbox

View File

@@ -6,6 +6,7 @@ export type LibraryStructure = "A" | "B" | null;
export interface SetupLibraryInfo {
detected_structure: LibraryStructure;
existing_platforms: string[];
platform_game_counts: Record<string, number>;
supported_platforms: Platform[];
}

View File

@@ -4,6 +4,7 @@ import { computed, inject, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useDisplay } from "vuetify";
import PlatformGroupList from "@/components/Setup/PlatformGroupList.vue";
import RDialog from "@/components/common/RDialog.vue";
import router from "@/plugins/router";
import { ROUTES } from "@/plugins/router";
import { refetchCSRFToken } from "@/services/api";
@@ -31,6 +32,9 @@ const selectedPlatforms = ref<string[]>([]);
const creatingPlatforms = ref(false);
const openPanels = ref<number[]>([]);
const mobileTab = ref(0); // 0: Detected, 1: Available
const showConfirmDialog = ref(false);
const confirmDialogMessage = ref("");
const confirmDialogAction = ref<(() => void) | null>(null);
// Use a computed property to reactively update metadataOptions based on heartbeat
const metadataOptions = computed(() => [
@@ -146,15 +150,21 @@ const groupPlatformsByManufacturer = (platforms: Platform[]) => {
const groupedExistingPlatforms = computed(() => {
if (!libraryInfo.value) return [];
// Only show existing platforms if a structure was actually detected
if (!libraryInfo.value.detected_structure) return [];
// Get supported platform slugs for quick lookup
const supportedSlugs = new Set(
libraryInfo.value.supported_platforms.map((p) => p.fs_slug),
);
// Get identified platforms (existing and in supported list)
const identified = libraryInfo.value.supported_platforms.filter((p) =>
libraryInfo.value?.existing_platforms.includes(p.fs_slug),
);
const identified = libraryInfo.value.supported_platforms
.filter((p) => libraryInfo.value?.existing_platforms.includes(p.fs_slug))
.map((p) => ({
...p,
rom_count: libraryInfo.value?.platform_game_counts?.[p.fs_slug] || 0,
}));
// Get unidentified platforms (existing but not in supported list)
// Create Platform objects for them with family_name="Other"
@@ -168,6 +178,7 @@ const groupedExistingPlatforms = computed(() => {
name: slug,
family_name: "Other",
generation: 999,
rom_count: libraryInfo.value?.platform_game_counts?.[slug] || 0,
}) as Platform,
);
@@ -178,14 +189,19 @@ const groupedExistingPlatforms = computed(() => {
// Group available platforms (not existing)
const groupedAvailablePlatforms = computed(() => {
if (!libraryInfo.value) return [];
const available = libraryInfo.value.supported_platforms.filter(
(p) => !libraryInfo.value?.existing_platforms.includes(p.fs_slug),
);
const available = libraryInfo.value.supported_platforms
.filter((p) => !libraryInfo.value?.existing_platforms.includes(p.fs_slug))
.map((p) => ({
...p,
rom_count: libraryInfo.value?.platform_game_counts?.[p.fs_slug] || 0,
}));
return groupPlatformsByManufacturer(available);
});
// Check if there are existing platforms
const hasExistingPlatforms = computed(() => {
// Only consider platforms as existing if a structure was detected
if (!libraryInfo.value?.detected_structure) return false;
return (libraryInfo.value?.existing_platforms.length ?? 0) > 0;
});
@@ -225,6 +241,98 @@ watch(selectAll, (newValue) => {
}
});
// Function to toggle all platforms in a group
function toggleGroupSelection(platforms: Platform[], checked: boolean) {
const slugs = platforms.map((p) => p.fs_slug);
if (checked) {
// Add all platforms from this group
const newSlugs = slugs.filter(
(slug) => !selectedPlatforms.value.includes(slug),
);
selectedPlatforms.value = [...selectedPlatforms.value, ...newSlugs];
} else {
// Remove all platforms from this group
selectedPlatforms.value = selectedPlatforms.value.filter(
(slug) => !slugs.includes(slug),
);
}
}
// Check if all platforms in a group are selected
function isGroupFullySelected(platforms: Platform[]) {
return platforms.every((p) => selectedPlatforms.value.includes(p.fs_slug));
}
// Compute the count of selected available platforms (excluding existing ones)
const selectedAvailableCount = computed(() => {
const existingPlatforms = libraryInfo.value?.existing_platforms || [];
return selectedPlatforms.value.filter(
(slug) => !existingPlatforms.includes(slug),
).length;
});
// Compute the total game count for detected platforms
const totalDetectedGames = computed(() => {
if (!libraryInfo.value?.platform_game_counts) return 0;
return Object.values(libraryInfo.value.platform_game_counts).reduce(
(total, count) => total + count,
0,
);
});
// Function to handle next button with confirmation
function handleNext(nextCallback: () => void) {
if (step.value !== 1) {
nextCallback();
return;
}
const hasStructure = libraryInfo.value?.detected_structure;
const platformsToCreate = selectedPlatforms.value.filter(
(slug) => !isPlatformExisting(slug),
);
// Case 1: No structure detected and user is creating platforms
if (!hasStructure && platformsToCreate.length > 0) {
confirmDialogMessage.value = `No folder structure detected. RomM will create Structure A (roms/{platform}) with ${
platformsToCreate.length
} platform${platformsToCreate.length > 1 ? "s" : ""}. Continue?`;
confirmDialogAction.value = nextCallback;
showConfirmDialog.value = true;
return;
}
// Case 2: No structure detected and user is not selecting anything
if (!hasStructure && platformsToCreate.length === 0) {
confirmDialogMessage.value =
"No folder structure detected and no platforms selected. You will need to create the folder structure manually. Continue?";
confirmDialogAction.value = nextCallback;
showConfirmDialog.value = true;
return;
}
// Case 3: Structure is detected and user selected at least one platform to create
if (hasStructure && platformsToCreate.length > 0) {
confirmDialogMessage.value = `RomM will create Structure A (roms/{platform}) with ${
platformsToCreate.length
} platform${platformsToCreate.length > 1 ? "s" : ""}. Continue?`;
confirmDialogAction.value = nextCallback;
showConfirmDialog.value = true;
return;
}
// Otherwise, proceed normally
nextCallback();
}
function handleConfirmDialog() {
showConfirmDialog.value = false;
if (confirmDialogAction.value) {
confirmDialogAction.value();
confirmDialogAction.value = null;
}
}
async function loadLibraryInfo() {
loadingLibraryInfo.value = true;
try {
@@ -358,7 +466,7 @@ onMounted(() => {
<v-stepper-window
class="flex-grow-1 mb-4"
:class="{ 'align-content-center': step != 1 }"
:class="{ 'align-content-center': step != 1 || loadingLibraryInfo }"
>
<v-stepper-window-item :key="1" :value="1" class="h-100">
<v-row no-gutters class="h-100">
@@ -411,23 +519,41 @@ onMounted(() => {
<!-- Desktop: Two columns side by side -->
<template v-if="!xs">
<!-- Existing platforms column -->
<v-col v-if="hasExistingPlatforms" cols="12" md="6">
<v-col
v-if="hasExistingPlatforms"
cols="12"
md="6"
class="pr-2"
>
<div class="text-white text-center text-shadow mb-2">
<strong>Detected Platforms</strong>
</div>
<div
class="overflow-y-auto pr-4"
style="max-height: 500px"
>
<div class="mb-2 ml-4">
<v-chip label>
{{ libraryInfo?.existing_platforms.length }} platforms
</v-chip>
<v-chip class="ml-2" variant="tonal" label>
{{ totalDetectedGames }} game{{
totalDetectedGames !== 1 ? "s" : ""
}}
</v-chip>
</div>
<div class="overflow-y-auto" style="max-height: 500px">
<PlatformGroupList
:grouped-platforms="groupedExistingPlatforms"
key-prefix="existing"
:platform-game-counts="
libraryInfo?.platform_game_counts
"
/>
</div>
</v-col>
<!-- Available platforms to create column -->
<v-col cols="12" :md="hasExistingPlatforms ? 6 : 12">
<v-col
cols="12"
:md="hasExistingPlatforms ? 6 : 12"
class="pl-2"
>
<div class="text-white text-center text-shadow mb-2">
<strong>{{
hasExistingPlatforms
@@ -435,16 +561,28 @@ onMounted(() => {
: "Select Platforms to Create"
}}</strong>
</div>
<div
class="overflow-y-auto pr-4"
style="max-height: 500px"
>
<div class="mb-2 ml-4">
<v-chip
variant="tonal"
color="primary"
@click="selectAll = !selectAll"
label
>
{{ selectAll ? "Deselect All" : "Select All" }}
</v-chip>
<v-chip class="ml-2" label
>{{ selectedAvailableCount }} selected</v-chip
>
</div>
<div class="overflow-y-auto" style="max-height: 500px">
<PlatformGroupList
:grouped-platforms="groupedAvailablePlatforms"
v-model:selected-platforms="selectedPlatforms"
:show-checkboxes="true"
key-prefix="available"
:base-index="groupedExistingPlatforms.length"
:on-toggle-group="toggleGroupSelection"
:is-group-fully-selected="isGroupFullySelected"
/>
</div>
</v-col>
@@ -471,29 +609,52 @@ onMounted(() => {
<v-window v-model="mobileTab">
<!-- Detected platforms tab -->
<v-window-item v-if="hasExistingPlatforms" :value="0">
<div
class="overflow-y-auto pr-4"
style="max-height: 400px"
>
<div class="mb-2 ml-4">
<v-chip label>
{{ libraryInfo?.existing_platforms.length }}
platforms
</v-chip>
<v-chip class="ml-2" variant="tonal" label>
{{ totalDetectedGames }} game{{
totalDetectedGames !== 1 ? "s" : ""
}}
</v-chip>
</div>
<div class="overflow-y-auto" style="max-height: 400px">
<PlatformGroupList
:grouped-platforms="groupedExistingPlatforms"
key-prefix="existing-mobile"
:platform-game-counts="
libraryInfo?.platform_game_counts
"
/>
</div>
</v-window-item>
<!-- Available platforms tab -->
<v-window-item :value="hasExistingPlatforms ? 1 : 0">
<div
class="overflow-y-auto pr-4"
style="max-height: 400px"
>
<div class="mb-2 ml-4">
<v-chip
variant="tonal"
color="primary"
@click="selectAll = !selectAll"
label
>
{{ selectAll ? "Deselect All" : "Select All" }}
</v-chip>
<v-chip class="ml-2" label
>{{ selectedAvailableCount }} selected</v-chip
>
</div>
<div class="overflow-y-auto" style="max-height: 500px">
<PlatformGroupList
:grouped-platforms="groupedAvailablePlatforms"
v-model:selected-platforms="selectedPlatforms"
:show-checkboxes="true"
key-prefix="available-mobile"
:base-index="groupedExistingPlatforms.length"
:on-toggle-group="toggleGroupSelection"
:is-group-fully-selected="isGroupFullySelected"
/>
</div>
</v-window-item>
@@ -626,8 +787,8 @@ onMounted(() => {
<v-btn
class="text-white text-shadow"
:loading="isLastStep && creatingPlatforms"
@click="!isLastStep ? next() : finishWizard()"
@keydown.enter="!isLastStep ? next() : finishWizard()"
@click="!isLastStep ? handleNext(next) : finishWizard()"
@keydown.enter="!isLastStep ? handleNext(next) : finishWizard()"
>
{{ !isLastStep ? "Next" : "Finish" }}
</v-btn>
@@ -636,5 +797,42 @@ onMounted(() => {
</div>
</template>
</v-stepper>
<!-- Confirmation Dialog -->
<RDialog
v-model="showConfirmDialog"
icon="mdi-alert"
width="500"
@close="showConfirmDialog = false"
>
<template #header>
<v-row class="ml-2">Confirm Action</v-row>
</template>
<template #content>
<div class="text-body-1 pa-4">
{{ confirmDialogMessage }}
</div>
</template>
<template #footer>
<v-row class="justify-center my-2" no-gutters>
<v-btn-group divided density="compact">
<v-btn class="bg-toplayer" @click="showConfirmDialog = false">
Cancel
</v-btn>
<v-btn
class="bg-toplayer text-primary"
@click="handleConfirmDialog"
>
Continue
</v-btn>
</v-btn-group>
</v-row>
</template>
</RDialog>
</v-card>
</template>
<style lang="css">
.v-expansion-panel-text__wrapper {
padding: 0px !important;
}
</style>