From a04ae7803bd3ffd928b1c3c8caf72528ee00e66f Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Sat, 16 May 2026 20:45:54 +1200 Subject: [PATCH] Add library UI, spatial nav & game launcher Introduce a full library feature: add new frontend modules (libraryBridge, libraryComponents, libraryController, libraryFilters, libraryModel) and refactor library view JS to use them; implement rich library CSS and UI templates in index.html (new hints, sidebar entries). Rework spatial navigation (src/core/nav.js) with a geometry-based picker, anchor behavior, sidebar/content regions and integration hook for external nebula navigation; add navigation refresh handling in main.js. Update controller/state glyphs to include a 'clear' button. On the Tauri backend, add a launch_library_game command and its result type, wire it into lib.rs commands, and adjust DB upsert to handle app_kind correctly and export list_games usage. Misc: various wiring to load/scan/launch library items and improved focus/keyboard behaviors. --- src-tauri/src/lib.rs | 5 +- src-tauri/src/library/commands.rs | 53 ++- src-tauri/src/library/db.rs | 2 +- src/core/nav.js | 427 ++++++++++++------ src/core/state.js | 2 + src/index.html | 27 +- src/main.js | 18 + src/views/library/library.css | 569 ++++++++++++++++++----- src/views/library/library.js | 601 ++++++++++++------------- src/views/library/libraryBridge.js | 124 +++++ src/views/library/libraryComponents.js | 324 +++++++++++++ src/views/library/libraryController.js | 14 + src/views/library/libraryFilters.js | 83 ++++ src/views/library/libraryModel.js | 276 ++++++++++++ 14 files changed, 1944 insertions(+), 581 deletions(-) create mode 100644 src/views/library/libraryBridge.js create mode 100644 src/views/library/libraryComponents.js create mode 100644 src/views/library/libraryController.js create mode 100644 src/views/library/libraryFilters.js create mode 100644 src/views/library/libraryModel.js diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0976803..9b662c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,9 @@ mod library; mod storage; -use library::commands::{list_library_games, scan_library_command, update_library_game}; +use library::commands::{ + launch_library_game, list_library_games, scan_library_command, update_library_game, +}; use rusqlite::{params, Connection, OptionalExtension}; use serde::Serialize; use std::time::{SystemTime, UNIX_EPOCH}; @@ -199,6 +201,7 @@ pub fn run() { get_first_user, create_user, list_library_games, + launch_library_game, scan_library_command, update_library_game ]) diff --git a/src-tauri/src/library/commands.rs b/src-tauri/src/library/commands.rs index b93dde0..a02409d 100644 --- a/src-tauri/src/library/commands.rs +++ b/src-tauri/src/library/commands.rs @@ -1,9 +1,18 @@ -use super::db::{ensure_library_schema, update_customization}; +use super::db::{ensure_library_schema, list_games, update_customization}; use super::models::{GameCustomizationRequest, LibraryGame, LibraryScanRequest, ScanSummary}; use super::{list_visible_games, scan_library}; use crate::storage::AppStorage; +use serde::Serialize; use std::path::PathBuf; +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchLibraryGameResult { + pub launched: bool, + pub action: String, + pub message: String, +} + #[tauri::command] pub async fn scan_library_command( request: LibraryScanRequest, @@ -45,3 +54,45 @@ pub async fn update_library_game( .await .map_err(|err| format!("Library update task failed: {err}"))? } + +#[tauri::command] +pub async fn launch_library_game( + game_id: i64, + storage: tauri::State<'_, AppStorage>, +) -> Result { + let storage = storage.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + let conn = storage.connect_library()?; + ensure_library_schema(&conn)?; + let game = list_games(&conn, true)? + .into_iter() + .find(|game| game.id == game_id) + .ok_or_else(|| "Library app was not found.".to_string())?; + + let launch_target = game + .launch_command + .as_deref() + .or(game.executable_path.as_deref()) + .unwrap_or(game.install_path.as_str()); + + // Future provider launchers plug in here: + // Steam: steam://run/, GOG/Epic URI handlers, emulator profiles, + // and native executable spawning with per-app environment overrides. + println!( + "[NebulaOS] launch placeholder: {} via {}", + game.user_title.as_deref().unwrap_or(&game.title), + launch_target + ); + + Ok(LaunchLibraryGameResult { + launched: true, + action: "placeholder".to_string(), + message: format!( + "Launch requested for {}.", + game.user_title.unwrap_or(game.title) + ), + }) + }) + .await + .map_err(|err| format!("Library launch task failed: {err}"))? +} diff --git a/src-tauri/src/library/db.rs b/src-tauri/src/library/db.rs index 28182eb..f461f2a 100644 --- a/src-tauri/src/library/db.rs +++ b/src-tauri/src/library/db.rs @@ -133,7 +133,7 @@ pub fn upsert_candidate( executable_path, launch_command, description, - app_kind.as_str(), + app_kind, steam_app_type, genres_json, steam_categories_json, diff --git a/src/core/nav.js b/src/core/nav.js index 429bade..77997cf 100644 --- a/src/core/nav.js +++ b/src/core/nav.js @@ -1,38 +1,131 @@ +// Spatial controller navigation. +// +// Given a focused element ("source") and a direction (up/down/left/right) we pick +// the next focusable by physical screen position rather than by an authoring-time +// grid (row/col data attributes). The screen geometry is the source of truth that +// the player sees, so it is also what the controller should follow. +// +// The algorithm: +// 1. Filter candidates to those whose *center* is strictly past the source's +// edge in the requested direction. Center-vs-edge avoids picking elements +// that merely brush the source from the side. +// 2. Prefer candidates that overlap the source on the perpendicular axis. A +// candidate sharing your row when you press right (or your column when you +// press up/down) is almost always the right answer. +// 3. If no overlapping candidate exists, fall back to candidates inside a 60° +// cone from the source center pointing in the requested direction. Bias +// heavily against off-axis drift. +// 4. Remember the "preferred" perpendicular center while the player presses the +// same axis repeatedly so vertical traversal doesn't slide sideways across +// rows of unequal width. +// 5. Sidebar is treated as a separate region. Left-from-content escapes to the +// sidebar only when there is no content target to the left; right-from- +// sidebar always re-enters content; up/down inside the sidebar walks the +// sidebar items. + +const EPSILON = 1; +const MAX_FALLBACK_CONE_RADIANS = Math.PI / 3; // 60° + const getRect = (element) => { const rect = element.getBoundingClientRect(); return { - x: rect.left, - y: rect.top, + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, width: rect.width, height: rect.height, + centerX: rect.left + rect.width / 2, + centerY: rect.top + rect.height / 2, }; }; -const scoreCandidate = (source, target, direction) => { - const sourceRect = getRect(source.element); - const targetRect = getRect(target.element); - const sourceCenterX = sourceRect.x + sourceRect.width / 2; - const sourceCenterY = sourceRect.y + sourceRect.height / 2; - const targetCenterX = targetRect.x + targetRect.width / 2; - const targetCenterY = targetRect.y + targetRect.height / 2; - const horizontal = targetCenterX - sourceCenterX; - const vertical = targetCenterY - sourceCenterY; +const isInDirection = (sourceRect, targetRect, direction) => { + if (direction === "right") return targetRect.centerX > sourceRect.right - EPSILON; + if (direction === "left") return targetRect.centerX < sourceRect.left + EPSILON; + if (direction === "down") return targetRect.centerY > sourceRect.bottom - EPSILON; + if (direction === "up") return targetRect.centerY < sourceRect.top + EPSILON; + return false; +}; - if (direction === "up" && vertical >= -1) return Number.POSITIVE_INFINITY; - if (direction === "down" && vertical <= 1) return Number.POSITIVE_INFINITY; - if (direction === "left" && horizontal >= -1) return Number.POSITIVE_INFINITY; - if (direction === "right" && horizontal <= 1) return Number.POSITIVE_INFINITY; +const perpendicularOverlap = (sourceRect, targetRect, direction) => { + if (direction === "left" || direction === "right") { + const start = Math.max(sourceRect.top, targetRect.top); + const end = Math.min(sourceRect.bottom, targetRect.bottom); + return Math.max(0, end - start); + } + const start = Math.max(sourceRect.left, targetRect.left); + const end = Math.min(sourceRect.right, targetRect.right); + return Math.max(0, end - start); +}; - const primary = direction === "up" || direction === "down" ? Math.abs(vertical) : Math.abs(horizontal); - const secondary = direction === "up" || direction === "down" ? Math.abs(horizontal) : Math.abs(vertical); - const sourceStart = direction === "up" || direction === "down" ? sourceRect.x : sourceRect.y; - const sourceEnd = sourceStart + (direction === "up" || direction === "down" ? sourceRect.width : sourceRect.height); - const targetStart = direction === "up" || direction === "down" ? targetRect.x : targetRect.y; - const targetEnd = targetStart + (direction === "up" || direction === "down" ? targetRect.width : targetRect.height); - const overlap = Math.max(0, Math.min(sourceEnd, targetEnd) - Math.max(sourceStart, targetStart)); - const overlapPenalty = overlap > 0 ? -40 : 0; +const primaryDistance = (sourceRect, targetRect, direction) => { + if (direction === "right") return Math.max(0, targetRect.left - sourceRect.right); + if (direction === "left") return Math.max(0, sourceRect.left - targetRect.right); + if (direction === "down") return Math.max(0, targetRect.top - sourceRect.bottom); + if (direction === "up") return Math.max(0, sourceRect.top - targetRect.bottom); + return Number.POSITIVE_INFINITY; +}; - return primary * 10 + secondary + overlapPenalty; +const perpendicularDisplacement = (sourceRect, targetRect, direction) => { + if (direction === "left" || direction === "right") { + return Math.abs(targetRect.centerY - sourceRect.centerY); + } + return Math.abs(targetRect.centerX - sourceRect.centerX); +}; + +const offAxisAngle = (sourceRect, targetRect, direction) => { + const dx = targetRect.centerX - sourceRect.centerX; + const dy = targetRect.centerY - sourceRect.centerY; + if (direction === "right") return Math.atan2(Math.abs(dy), Math.max(1, dx)); + if (direction === "left") return Math.atan2(Math.abs(dy), Math.max(1, -dx)); + if (direction === "down") return Math.atan2(Math.abs(dx), Math.max(1, dy)); + if (direction === "up") return Math.atan2(Math.abs(dx), Math.max(1, -dy)); + return Math.PI; +}; + +const scoreCandidate = (sourceRect, targetRect, direction, anchorPerpCenter) => { + if (!isInDirection(sourceRect, targetRect, direction)) { + return null; + } + + const primary = primaryDistance(sourceRect, targetRect, direction); + const overlap = perpendicularOverlap(sourceRect, targetRect, direction); + const perpDisp = perpendicularDisplacement(sourceRect, targetRect, direction); + + let score; + if (overlap > 0) { + // Strong winner: candidate shares the perpendicular line with the source. + // Primary distance dominates; tiny perpendicular drift breaks ties so the + // most centered candidate wins. + score = primary + perpDisp * 0.15; + } else { + // No overlap — only allow within a 60° cone in the requested direction. + if (offAxisAngle(sourceRect, targetRect, direction) > MAX_FALLBACK_CONE_RADIANS) { + return null; + } + // Heavy off-axis penalty plus a flat bias so an overlapping candidate that + // is further away in the primary axis still beats this one. + score = primary + perpDisp * 2.5 + 400; + } + + if (anchorPerpCenter !== null && Number.isFinite(anchorPerpCenter)) { + const anchorDrift = direction === "left" || direction === "right" + ? Math.abs(targetRect.centerY - anchorPerpCenter) + : Math.abs(targetRect.centerX - anchorPerpCenter); + score += anchorDrift * 0.6; + } + + return score; +}; + +const isElementInteractable = (element) => { + if (element.dataset.disabled === "true") return false; + if (element.getAttribute("aria-disabled") === "true") return false; + if (element.hasAttribute("hidden")) return false; + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + return true; }; export const createNavigationManager = () => { @@ -40,11 +133,34 @@ export const createNavigationManager = () => { let focusables = []; let focusedIndex = -1; + // Tracks the perpendicular center the player is "anchored" to while they + // repeat moves on the same axis. axis="x" while pressing up/down, axis="y" + // while pressing left/right. Reset whenever the axis flips or focus crosses + // regions (sidebar <-> content). + let anchor = { axis: null, value: null }; + const decorateFocusable = (element) => { element.classList.remove("is-focused"); element.tabIndex = -1; }; + const dispatchFocusChange = (element) => { + const col = Number(element.dataset.col ?? 0); + const row = Number(element.dataset.row ?? 0); + document.documentElement.style.setProperty("--nebula-accent-line-x", `${20 + col * 110}px`); + document.documentElement.style.setProperty("--nebula-focus-strength", "1"); + + window.dispatchEvent( + new CustomEvent("nebula-focus-change", { + detail: { + key: element.dataset.focusKey ?? null, + row, + col, + }, + }), + ); + }; + const applyFocus = (index) => { if (!focusables.length) { focusedIndex = -1; @@ -64,21 +180,7 @@ export const createNavigationManager = () => { focused.classList.add("is-focused"); focused.setAttribute("aria-selected", "true"); focused.focus({ preventScroll: true }); - - const col = Number(focused.dataset.col ?? 0); - const row = Number(focused.dataset.row ?? 0); - document.documentElement.style.setProperty("--nebula-accent-line-x", `${20 + col * 110}px`); - document.documentElement.style.setProperty("--nebula-focus-strength", "1"); - - window.dispatchEvent( - new CustomEvent("nebula-focus-change", { - detail: { - key: focused.dataset.focusKey ?? null, - row, - col, - }, - }), - ); + dispatchFocusChange(focused); } }; @@ -94,181 +196,216 @@ export const createNavigationManager = () => { ); focusables = nodes - .map((element, index) => { + .filter(isElementInteractable) + .map((element) => { decorateFocusable(element); return { - index, element, row: Number(element.dataset.row ?? 0), col: Number(element.dataset.col ?? 0), - key: element.dataset.focusKey ?? String(index), + key: element.dataset.focusKey ?? "", region: element.dataset.navRegion ?? "content", }; - }) - .sort((left, right) => { - if (left.row === right.row) { - return left.col - right.col; - } - return left.row - right.row; }); + + focusables.forEach((focusable, index) => { + focusable.index = index; + }); }; const resolveDefaultFocus = () => { - if (!focusables.length) { - return -1; - } + if (!focusables.length) return -1; if (contract?.defaultFocus) { - const defaultIndex = focusables.findIndex((focusable) => focusable.element === contract.defaultFocus); - if (defaultIndex >= 0) { - return defaultIndex; - } + const idx = focusables.findIndex((f) => f.element === contract.defaultFocus); + if (idx >= 0) return idx; } - return 0; + const contentIdx = focusables.findIndex((f) => f.region !== "sidebar"); + return contentIdx >= 0 ? contentIdx : 0; }; - const resolveDefaultContentFocus = () => { - if (!focusables.length) { - return -1; - } - + const findContentDefaultIndex = () => { if (contract?.defaultFocus) { - const defaultIndex = focusables.findIndex((focusable) => focusable.element === contract.defaultFocus); - if (defaultIndex >= 0 && focusables[defaultIndex]?.region !== "sidebar") { - return defaultIndex; + const idx = focusables.findIndex((f) => f.element === contract.defaultFocus); + if (idx >= 0 && focusables[idx].region !== "sidebar") { + return idx; } } - - return focusables.findIndex((focusable) => focusable.region !== "sidebar"); + return focusables.findIndex((f) => f.region !== "sidebar"); }; - const findSidebarIndex = () => { - const activeIndex = focusables.findIndex( - (focusable) => focusable.region === "sidebar" && focusable.element.classList.contains("is-active"), + const findSidebarTargetIndex = () => { + const activeIdx = focusables.findIndex( + (f) => f.region === "sidebar" && f.element.classList.contains("is-active"), ); - if (activeIndex >= 0) { - return activeIndex; - } - - return focusables.findIndex((focusable) => focusable.region === "sidebar"); + if (activeIdx >= 0) return activeIdx; + return focusables.findIndex((f) => f.region === "sidebar"); }; const findNextSidebarIndex = (direction) => { - const source = focusables[focusedIndex]; - if (!source || source.region !== "sidebar") { - return null; - } - const sidebarItems = focusables - .map((item, index) => ({ item, index })) - .filter(({ item }) => item.region === "sidebar") - .sort((left, right) => left.item.row - right.item.row); - const currentSidebarIndex = sidebarItems.findIndex(({ index }) => index === focusedIndex); - if (currentSidebarIndex < 0) { - return null; - } + .map((focusable, index) => ({ focusable, index })) + .filter(({ focusable }) => focusable.region === "sidebar"); - const nextSidebarIndex = direction === "up" ? currentSidebarIndex - 1 : currentSidebarIndex + 1; - return sidebarItems[nextSidebarIndex]?.index ?? null; + if (!sidebarItems.length) return null; + + // Sort sidebar items by physical Y position so the order matches what the + // user sees, regardless of how the markup was authored. + sidebarItems.sort((a, b) => { + const ra = a.focusable.element.getBoundingClientRect(); + const rb = b.focusable.element.getBoundingClientRect(); + return ra.top - rb.top; + }); + + const currentSlot = sidebarItems.findIndex(({ index }) => index === focusedIndex); + if (currentSlot < 0) return null; + + const nextSlot = direction === "up" ? currentSlot - 1 : currentSlot + 1; + return sidebarItems[nextSlot]?.index ?? null; + }; + + const findBestContentSpatialIndex = (direction) => { + const source = focusables[focusedIndex]; + if (!source) return -1; + + const sourceRect = getRect(source.element); + + const axisOfMove = direction === "up" || direction === "down" ? "x" : "y"; + const anchorPerpCenter = anchor.axis === axisOfMove ? anchor.value : null; + + let bestIndex = -1; + let bestScore = Number.POSITIVE_INFINITY; + + focusables.forEach((candidate, idx) => { + if (idx === focusedIndex) return; + if (candidate.region === "sidebar") return; + + const targetRect = getRect(candidate.element); + const score = scoreCandidate(sourceRect, targetRect, direction, anchorPerpCenter); + if (score === null) return; + if (score < bestScore) { + bestScore = score; + bestIndex = idx; + } + }); + + return bestIndex; + }; + + const updateAnchorForMove = (direction, sourceRect) => { + const axisOfMove = direction === "up" || direction === "down" ? "x" : "y"; + if (anchor.axis !== axisOfMove) { + anchor = { + axis: axisOfMove, + value: axisOfMove === "x" ? sourceRect.centerX : sourceRect.centerY, + }; + } }; const moveWithNebula = (direction) => { - if (contract?.useNebulaNavigation === false) { - return null; - } + // The bundled @nebulaproject/core picker is opt-in. Our local spatial + // navigation is the default everywhere so every view gets the same + // predictable controller behavior unless a view explicitly asks for the + // remote algorithm. + if (contract?.useNebulaNavigation !== true) return null; const picker = contract?.nebulaNavigation?.pickBestCandidate; - if (typeof picker !== "function") { - return null; - } + if (typeof picker !== "function") return null; const source = focusables[focusedIndex]; - if (!source) { - return null; - } + if (!source) return null; const sourceRect = getRect(source.element); const candidates = focusables .filter((item) => item.index !== source.index) - .map((item) => ({ - id: item.key, - ...getRect(item.element), - })); + .map((item) => { + const rect = getRect(item.element); + return { + id: item.key, + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; + }); const picked = picker( - { id: source.key, ...sourceRect }, + { + id: source.key, + x: sourceRect.left, + y: sourceRect.top, + width: sourceRect.width, + height: sourceRect.height, + }, candidates, direction, ); - if (!picked?.id) { - return null; - } + if (!picked?.id) return null; const nextIndex = focusables.findIndex((item) => item.key === picked.id); return nextIndex >= 0 ? nextIndex : null; }; const move = (direction) => { - if (!focusables.length || focusedIndex < 0) { - return; - } + if (!focusables.length || focusedIndex < 0) return; const source = focusables[focusedIndex]; + const sourceRect = getRect(source.element); - if (direction === "left" && source?.region !== "sidebar") { - const sidebarIndex = findSidebarIndex(); - if (sidebarIndex >= 0) { - applyFocus(sidebarIndex); - } - return; - } - - if (direction === "right" && source?.region === "sidebar") { - const contentIndex = resolveDefaultContentFocus(); - if (contentIndex >= 0) { - applyFocus(contentIndex); - } - return; - } - - if ((direction === "up" || direction === "down") && source?.region === "sidebar") { - const nextSidebarIndex = findNextSidebarIndex(direction); - if (nextSidebarIndex !== null) { - applyFocus(nextSidebarIndex); + // -- Sidebar region ---------------------------------------------------- + if (source.region === "sidebar") { + if (direction === "up" || direction === "down") { + const next = findNextSidebarIndex(direction); + if (next !== null) applyFocus(next); + return; } + if (direction === "right") { + const contentIdx = findContentDefaultIndex(); + if (contentIdx >= 0) { + anchor = { axis: null, value: null }; + applyFocus(contentIdx); + } + return; + } + // direction === "left" from sidebar: nothing further left. return; } + // -- Optional external navigation hook (Nebula core) ------------------- const nebulaIndex = moveWithNebula(direction); if (nebulaIndex !== null) { + updateAnchorForMove(direction, sourceRect); applyFocus(nebulaIndex); return; } - let bestIndex = focusedIndex; - let bestScore = Number.POSITIVE_INFINITY; - - focusables.forEach((candidate, index) => { - if (index === focusedIndex) { - return; - } - - const score = scoreCandidate(source, candidate, direction); - if (score < bestScore) { - bestScore = score; - bestIndex = index; - } - }); - - if (bestIndex !== focusedIndex) { - applyFocus(bestIndex); + // -- Spatial search within content ------------------------------------- + const bestContentIdx = findBestContentSpatialIndex(direction); + if (bestContentIdx >= 0) { + updateAnchorForMove(direction, sourceRect); + applyFocus(bestContentIdx); + return; } + + // -- Edge of content: escape to sidebar when going left ---------------- + if (direction === "left") { + const sidebarIdx = findSidebarTargetIndex(); + if (sidebarIdx >= 0) { + anchor = { axis: null, value: null }; + applyFocus(sidebarIdx); + } + return; + } + + // No candidate in this direction. Stay put — pressing again won't bounce + // the player around to unrelated regions. }; const mount = (nextContract) => { contract = nextContract; + anchor = { axis: null, value: null }; buildFocusables(); applyFocus(resolveDefaultFocus()); }; diff --git a/src/core/state.js b/src/core/state.js index 532af26..51ec883 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -45,6 +45,7 @@ const FALLBACK_GLYPHS = { r1: "RB", l2: "LT", r2: "RT", + clear: "X", y: "Y", }; @@ -124,6 +125,7 @@ export const createAppState = () => { r1: glyphs.getGlyph("xbox", "rb") ?? FALLBACK_GLYPHS.r1, l2: glyphs.getGlyph("xbox", "lt") ?? FALLBACK_GLYPHS.l2, r2: glyphs.getGlyph("xbox", "rt") ?? FALLBACK_GLYPHS.r2, + clear: glyphs.getGlyph("xbox", "x") ?? FALLBACK_GLYPHS.clear, y: glyphs.getGlyph("xbox", "y") ?? FALLBACK_GLYPHS.y, }; } diff --git a/src/index.html b/src/index.html index c6a094f..df32694 100644 --- a/src/index.html +++ b/src/index.html @@ -43,17 +43,17 @@ Library - - - + @@ -96,5 +96,16 @@ Done + + diff --git a/src/main.js b/src/main.js index e16f4a6..acb0a5f 100644 --- a/src/main.js +++ b/src/main.js @@ -116,6 +116,23 @@ const renderView = (viewId) => { updateClockLabels(); }; +const refreshNavigation = (event) => { + if (!currentViewContract?.focusRoot) { + return; + } + + const focusKey = event?.detail?.focusKey; + const requestedFocus = focusKey + ? currentViewContract.focusRoot.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`) + : null; + + currentViewContract = { + ...currentViewContract, + defaultFocus: requestedFocus ?? currentViewContract.defaultFocus, + }; + nav.mount(currentViewContract); +}; + const registerViews = () => { const context = { state, renderView, powerMenu, keyboard, openPowerMenu }; router.register(createUserSetupView(context)); @@ -212,6 +229,7 @@ const initialize = async () => { actions: ["up", "down", "left", "right", "accept", "back", "menu", "clear", "y", "l1", "r1", "l2", "r2"], }); + window.addEventListener("nebula-navigation-refresh", refreshNavigation); input.start(); }; diff --git a/src/views/library/library.css b/src/views/library/library.css index a5a2ff3..84d7319 100644 --- a/src/views/library/library.css +++ b/src/views/library/library.css @@ -1,177 +1,542 @@ .library-view { - gap: var(--nebula-spacing-lg); + --library-glow: rgba(79, 216, 255, 0.42); + height: 100%; + overflow: hidden; + background: + radial-gradient(circle at 18% 12%, rgba(79, 216, 255, 0.12), transparent 34%), + radial-gradient(circle at 78% 0%, rgba(157, 79, 224, 0.14), transparent 36%); } -.library-actions { +.library-topbar { display: flex; - gap: var(--nebula-spacing-sm); align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding: 18px 26px 12px; + border-bottom: 1px solid rgba(79, 216, 255, 0.14); } -.library-layout { - display: grid; - grid-template-columns: minmax(260px, 320px) 1fr; - gap: var(--nebula-spacing-lg); +.library-eyebrow, +.library-section-kicker { + margin: 0; + color: var(--nebula-color-accent); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.library-title { + margin: 4px 0 0; + font-size: clamp(38px, 4.5vw, 62px); + line-height: 1; + letter-spacing: -0.04em; +} + +.library-topbar-right, +.library-actions, +.library-system-status { + display: flex; + align-items: center; + gap: 12px; +} + +.library-system-status { + padding: 10px 14px; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: var(--nebula-radius-pill); + background: rgba(10, 16, 34, 0.74); + color: var(--nebula-color-muted); + font-size: 13px; +} + +.library-system-status strong { + color: var(--nebula-color-text); + font-size: 16px; +} + +.library-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--nebula-color-success); + box-shadow: 0 0 14px rgba(79, 255, 136, 0.55); +} + +.library-action, +.library-category-tab, +.library-genre-tab, +.library-filter-chip, +.library-sort-option, +.library-detail-button { + color: var(--nebula-color-text); + border: 2px solid rgba(255, 255, 255, 0.09); + background: rgba(15, 23, 46, 0.82); + box-shadow: none; +} + +.library-action { + min-width: 132px; + padding: 12px 18px; + border-radius: var(--nebula-radius-pill); + font-weight: 800; +} + +.library-console { + flex: 1; + min-height: 0; + padding: 16px 20px 0; +} + +.library-main { + display: flex; + flex-direction: column; + gap: 14px; + height: 100%; min-height: 0; } -.library-status-panel { - align-self: start; +.library-category-tabs, +.library-genre-tabs { + display: flex; + gap: 12px; + flex-shrink: 0; +} + +.library-category-tab { + min-width: 210px; + padding: 18px 24px; + border-radius: var(--nebula-radius-lg); + font-size: clamp(20px, 2vw, 28px); + font-weight: 900; + text-align: left; +} + +.library-category-tab.is-active, +.library-genre-tab.is-active, +.library-filter-chip.is-active, +.library-sort-option.is-active { + color: #04101d; + background: linear-gradient(135deg, var(--nebula-color-accent), #78f0ff); + border-color: rgba(255, 255, 255, 0.7); +} + +.library-genre-tab { + padding: 12px 18px; + border-radius: var(--nebula-radius-pill); + font-size: 15px; + font-weight: 800; +} + +.library-content-row { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(250px, 300px) 1fr; + gap: 16px; +} + +.library-sidebar { display: flex; flex-direction: column; - gap: var(--nebula-spacing-md); + gap: 12px; + min-height: 0; } -.library-status-title { - margin: 0; - font-size: clamp(22px, 2vw, 30px); +.library-summary-card, +.library-filter-card, +.library-grid-region, +.library-details-card, +.library-sort-card, +.library-empty-card { + border: 1px solid rgba(79, 216, 255, 0.13); + background: + linear-gradient(180deg, rgba(17, 24, 48, 0.88), rgba(8, 12, 27, 0.92)), + rgba(10, 16, 34, 0.86); + border-radius: var(--nebula-radius-lg); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.36); } +.library-summary-card, +.library-filter-card { + padding: 16px; +} + +.library-summary-total { + display: flex; + align-items: end; + gap: 10px; + margin: 12px 0; +} + +.library-summary-total strong { + font-size: 48px; + line-height: 0.9; +} + +.library-summary-total span, +.library-sync-status p, .library-status-copy { margin: 0; color: var(--nebula-color-muted); - line-height: 1.5; } -.library-provider-list { +.library-stat-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.library-stat-grid span, +.library-sync-status { + padding: 10px; + border-radius: var(--nebula-radius-md); + background: rgba(255, 255, 255, 0.045); +} + +.library-stat-grid strong { + color: var(--nebula-color-text); +} + +.library-sync-status { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px; + margin-top: 12px; +} + +.library-filter-group { display: flex; flex-direction: column; - gap: var(--nebula-spacing-xs); - margin: 0; - padding: 0; - list-style: none; + gap: 8px; + margin-top: 12px; } -.library-provider-row { - display: flex; - justify-content: space-between; - gap: var(--nebula-spacing-md); - padding: var(--nebula-spacing-sm); - border-radius: var(--nebula-radius-sm); - background: rgba(255, 255, 255, 0.04); +.library-filter-label { color: var(--nebula-color-muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.12em; } -.library-provider-row strong { - color: var(--nebula-color-text); +.library-filter-chip { + width: 100%; + padding: 11px 12px; + border-radius: var(--nebula-radius-md); + text-align: left; + font-weight: 800; +} + +.library-grid-region { + min-width: 0; + min-height: 0; + padding: 16px; + display: flex; + flex-direction: column; +} + +.library-grid-header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + flex-shrink: 0; + margin-bottom: 14px; +} + +.library-grid-header h2 { + margin: 4px 0 0; + font-size: 28px; } .library-grid { display: grid; - gap: var(--nebula-spacing-md); - align-content: start; - min-width: 0; -} - -.library-section { - display: flex; - flex-direction: column; - gap: var(--nebula-spacing-sm); - min-width: 0; -} - -.library-section-heading { - display: flex; - align-items: end; - justify-content: space-between; - gap: var(--nebula-spacing-md); -} - -.library-section-heading h2 { - margin: 0; - font-size: clamp(20px, 1.6vw, 26px); -} - -.library-section-grid { - display: grid; - grid-template-columns: repeat(4, minmax(150px, 1fr)); - gap: var(--nebula-spacing-md); + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 16px; + overflow: auto; + padding: 4px 8px 24px; + scrollbar-width: thin; } .library-card { - min-height: 220px; + min-height: 330px; + padding: 0; display: flex; flex-direction: column; - overflow: hidden; - padding: 0; - background: var(--nebula-color-panel-alt); + border-radius: 22px; + border: 2px solid rgba(255, 255, 255, 0.08); + background: rgba(8, 12, 27, 0.94); color: var(--nebula-color-text); - border: 2px solid var(--nebula-color-border); - border-radius: var(--nebula-radius-md); text-align: left; + box-shadow: 0 16px 34px rgba(0, 0, 0, 0.34); + transform-origin: center; } .library-card.is-focused { border-color: var(--nebula-color-accent); - transform: scale(1.04) translateZ(0); + box-shadow: + 0 0 0 2px rgba(79, 216, 255, 0.16), + 0 0 34px var(--library-glow), + 0 22px 50px rgba(0, 0, 0, 0.54); + transform: scale(1.045) translateZ(0); + z-index: 3; } .library-card-art { - min-height: 140px; + position: relative; + min-height: 178px; display: grid; place-items: center; - background: - radial-gradient(circle at top left, rgba(79, 216, 255, 0.38), transparent 45%), - linear-gradient(135deg, rgba(80, 214, 255, 0.18), rgba(125, 89, 255, 0.18)); - position: relative; overflow: hidden; + border-radius: 20px 20px 0 0; + background: + radial-gradient(circle at 18% 16%, color-mix(in srgb, var(--library-accent, #4fd8ff), transparent 24%), transparent 42%), + linear-gradient(135deg, rgba(79, 216, 255, 0.16), rgba(157, 79, 224, 0.18)), + #10182f; } .library-card-art.has-image { - min-height: 180px; + background: + linear-gradient(180deg, rgba(7, 10, 20, 0), rgba(7, 10, 20, 0.58)), + var(--library-art) center / cover no-repeat; } -.library-card-image { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; -} - -.library-card-art.has-image::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient(180deg, rgba(7, 10, 20, 0), rgba(7, 10, 20, 0.42)); -} - -.library-card-art span { +.library-card-art span, +.library-details-art span { display: grid; place-items: center; - width: 64px; - height: 64px; - border-radius: 18px; - background: rgba(0, 0, 0, 0.28); - color: var(--nebula-color-text); - font-size: 34px; - font-weight: 800; - z-index: 1; + width: 78px; + height: 78px; + border-radius: 24px; + background: rgba(0, 0, 0, 0.24); + border: 1px solid rgba(255, 255, 255, 0.16); + font-size: 26px; + font-weight: 950; + letter-spacing: 0.08em; } -.library-card-art.has-image span { +.library-card-art.has-image span, +.library-details-art.has-image span { display: none; } +.library-verified-badge { + position: absolute; + right: 10px; + top: 10px; + padding: 5px 8px; + border-radius: var(--nebula-radius-pill); + background: rgba(79, 255, 136, 0.16); + border: 1px solid rgba(79, 255, 136, 0.42); + color: #9effbc; + font-size: 11px; + font-style: normal; + font-weight: 900; + text-transform: uppercase; +} + .library-card-body { display: flex; flex-direction: column; - gap: 4px; - padding: var(--nebula-spacing-md); + gap: 8px; + padding: 14px; } -.library-card-title { +.library-card-title-row { + display: flex; + align-items: start; + justify-content: space-between; + gap: 10px; +} + +.library-card-title-row h3 { margin: 0; - font-size: 18px; - font-weight: 800; + font-size: 20px; + line-height: 1.1; } -.library-card-meta { +.library-card-title-row span { + flex-shrink: 0; + color: var(--nebula-color-accent); + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.library-card-body p, +.library-card-meta-row, +.library-card-achievements { margin: 0; color: var(--nebula-color-muted); font-size: 13px; } -.library-empty { - grid-column: 1 / -1; +.library-card-meta-row { + display: flex; + justify-content: space-between; + gap: 10px; +} + +.library-card-focus-copy, +.library-card-achievements { + display: none; +} + +.library-card.is-focused .library-card-focus-copy, +.library-card.is-focused .library-card-achievements { + display: block; +} + +.library-empty-card { + grid-column: 1 / -1; + padding: 28px; +} + +.library-empty-card h3 { + margin: 8px 0; + font-size: 28px; +} + +.library-details-panel, +.library-sort-panel { + display: none; +} + +.library-details-panel.is-open, +.library-sort-panel.is-open { + position: fixed; + inset: 0; + z-index: 30; + display: block; +} + +.library-details-backdrop, +.library-sort-backdrop { + position: absolute; + inset: 0; + background: rgba(3, 6, 14, 0.72); +} + +.library-details-card { + position: absolute; + top: 30px; + right: 30px; + bottom: 30px; + width: min(520px, calc(100vw - 140px)); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.library-details-art { + min-height: 210px; + display: grid; + place-items: center; + background: + radial-gradient(circle at 20% 20%, color-mix(in srgb, var(--library-accent, #4fd8ff), transparent 24%), transparent 44%), + linear-gradient(135deg, rgba(79, 216, 255, 0.18), rgba(157, 79, 224, 0.2)); +} + +.library-details-art.has-image { + background: + linear-gradient(180deg, rgba(7, 10, 20, 0), rgba(7, 10, 20, 0.78)), + var(--library-detail-art) center / cover no-repeat; +} + +.library-details-body { + padding: 22px; + overflow: auto; +} + +.library-details-body h2 { + margin: 8px 0; + font-size: 34px; + line-height: 1; +} + +.library-details-body p { + color: var(--nebula-color-muted); + line-height: 1.5; +} + +.library-details-list { + display: grid; + gap: 10px; + margin: 16px 0; +} + +.library-details-list div { + padding: 10px; + border-radius: var(--nebula-radius-md); + background: rgba(255, 255, 255, 0.05); +} + +.library-details-list dt { + color: var(--nebula-color-muted); + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.library-details-list dd { + margin: 3px 0 0; +} + +.library-details-actions, +.library-sort-options { + display: grid; + gap: 10px; +} + +.library-detail-button, +.library-sort-option { + padding: 14px 16px; + border-radius: var(--nebula-radius-md); + text-align: left; + font-weight: 900; +} + +.library-sort-card { + position: absolute; + right: 34px; + bottom: 58px; + width: min(360px, calc(100vw - 130px)); + padding: 20px; +} + +.library-sort-card h2 { + margin: 8px 0; + font-size: 30px; +} + +.library-sort-card p { + color: var(--nebula-color-muted); +} + +.library-action.is-focused, +.library-category-tab.is-focused, +.library-genre-tab.is-focused, +.library-filter-chip.is-focused, +.library-sort-option.is-focused, +.library-detail-button.is-focused { + border-color: var(--nebula-color-accent); + box-shadow: + 0 0 0 2px rgba(79, 216, 255, 0.14), + 0 0 26px rgba(79, 216, 255, 0.34); + transform: scale(1.035) translateZ(0); +} + +@media (max-width: 1180px) { + .library-content-row { + grid-template-columns: 240px 1fr; + } + + .library-category-tab { + min-width: 170px; + } + + .library-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + } } diff --git a/src/views/library/library.js b/src/views/library/library.js index 6921eb5..eb8101d 100644 --- a/src/views/library/library.js +++ b/src/views/library/library.js @@ -1,337 +1,292 @@ -const getTauriCore = async () => { - const globalCore = window.__TAURI__?.core; - if (typeof globalCore?.invoke === "function") { - return { - invoke: globalCore.invoke, - convertFileSrc: typeof globalCore.convertFileSrc === "function" ? globalCore.convertFileSrc : null, - }; - } +import { filterLibraryItems } from "./libraryFilters.js"; +import { + GENRE_FILTERS, + LIBRARY_CATEGORIES, + createDefaultLibraryQuery, + createMockLibraryItems, +} from "./libraryModel.js"; +import { hideLibraryItem, launchLibraryItem, loadLibraryItems, scanLibraryItems } from "./libraryBridge.js"; +import { + renderCategoryTabs, + renderDetailsPanel, + renderFilters, + renderGenreTabs, + renderGrid, + renderLibraryShell, + renderSortPanel, + renderSummary, +} from "./libraryComponents.js"; +import { cycleOption, requestNavigationRefresh } from "./libraryController.js"; - try { - const tauriCore = await import("@tauri-apps/api/core"); - return { - invoke: typeof tauriCore.invoke === "function" ? tauriCore.invoke : null, - convertFileSrc: typeof tauriCore.convertFileSrc === "function" ? tauriCore.convertFileSrc : null, - }; - } catch (_error) { - return { invoke: null, convertFileSrc: null }; - } +const PLAY_STATES = ["all", "played", "unplayed"]; + +const findItem = (runtime, id = runtime.focusedId) => runtime.items.find((item) => item.id === id) ?? null; + +const focusKeyForRuntime = (runtime) => { + if (runtime.detailsOpen) return "library-detail-0"; + if (runtime.sortOpen) return `library-sort-${runtime.query.sortBy}`; + return runtime.focusedId ? `library-card-${runtime.focusedId}` : "library-category-games"; }; -const defaultLocalFolders = () => { - const folders = ["C:/Games", "D:/Games"]; - return folders; -}; - -const sourceLabel = (source) => { - const labels = { - steam: "Steam", - epic: "Epic Games", - gog: "GOG", - local: "Local", - unknown: "Unknown", +export const createLibraryView = ({ state, renderView }) => { + const runtime = { + items: createMockLibraryItems(), + visibleItems: [], + query: createDefaultLibraryQuery(), + status: "Mock data ready", + message: "Mock apps are shown until Start Scan imports real apps into library.db.", + providers: [], + focusedId: null, + detailsOpen: false, + sortOpen: false, + mounted: false, + focusListenerAttached: false, }; - return labels[source] ?? source; -}; -const statusLabel = (status) => { - const labels = { - pending: "Pending metadata", - matched: "Matched", - needs_review: "Needs review", - manual: "Manual", - }; - return labels[status] ?? "Pending metadata"; -}; - -const kindLabel = (kind) => { - const labels = { - game: "Game", - tool: "Tool", - software: "Software", - unknown: "Unknown", - }; - return labels[kind] ?? "Game"; -}; - -const gameTitle = (game) => game.userTitle || game.title || "Unknown App"; - -const escapeHtml = (value) => - String(value ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); - -const imageUrlForGame = (game, convertFileSrc) => { - const imagePath = game.coverImage || game.heroImage || game.iconImage; - return imagePath && convertFileSrc ? convertFileSrc(imagePath) : ""; -}; - -const TOOL_CATEGORY_LABELS = new Set([ - "animation & modeling", - "audio production", - "design & illustration", - "education", - "game development", - "photo editing", - "software training", - "utilities", - "video production", - "web publishing", - "includes level editor", -]); - -const isTool = (game) => game.appKind === "tool" || game.appKind === "software"; - -const toolCategoryForGame = (game) => { - const labels = [ - ...(Array.isArray(game.genres) ? game.genres : []), - ...(Array.isArray(game.steamCategories) ? game.steamCategories : []), - ].filter(Boolean); - const toolLabel = labels.find((label) => TOOL_CATEGORY_LABELS.has(label.toLowerCase())); - return toolLabel || labels[0] || "Other Tools"; -}; - -const renderGameCard = (game, index, convertFileSrc) => { - const title = gameTitle(game); - const imageUrl = imageUrlForGame(game, convertFileSrc); - const hasImage = Boolean(imageUrl); - const metaParts = [ - sourceLabel(game.platformSource), - kindLabel(game.appKind), - game.developer, - Array.isArray(game.genres) ? game.genres.slice(0, 2).join(", ") : "", - ].filter(Boolean); - - return ` - -`; -}; - -const renderProviderReport = (provider) => ` -
  • - ${sourceLabel(provider.source)} - ${provider.error ? "Error" : `${provider.discovered} found`} -
  • -`; - -const renderLibrarySection = (title, eyebrow, games, startIndex, convertFileSrc) => ` -
    -
    -

    ${escapeHtml(eyebrow)}

    -

    ${escapeHtml(title)}

    -
    -
    - ${games.map((game, offset) => renderGameCard(game, startIndex + offset, convertFileSrc)).join("")} -
    -
    -`; - -const LIBRARY_TEMPLATE = ` -
    -
    -
    -

    Nebula OS

    -
    - -

    --:--

    -
    -
    -
    -
    -
    -
    -

    Unified Library

    -

    Library

    -
    -
    - - -
    -
    -
    -
    -
    -

    Scanner

    -

    Ready to scan

    -
    -

    Scan Steam, Epic, GOG, and local folders into the isolated library database.

    -
      -
      -
      -
      -

      No apps discovered yet.

      -

      Press Scan Device to build your NebulaOS library.

      -
      -
      -
      -
      -`; - -export const createLibraryView = ({ state, renderView }) => ({ - id: "library", - render: () => LIBRARY_TEMPLATE, - mount: async () => { - document.querySelector("[data-action='scan']")?.addEventListener("click", scanLibrary); - document.querySelector("[data-action='refresh']")?.addEventListener("click", refreshLibrary); - await refreshLibrary(); - }, - getNavigationContract: () => { + const renderLibrary = (focusKey = null) => { const root = document.querySelector("[data-view='library']"); - return { - focusRoot: root, - defaultFocus: root?.querySelector("[data-action='scan']") ?? null, - layout: { type: "grid", cols: 4, rows: 4 }, - hintsTemplate: "#global-hints-template", - nebulaNavigation: state.nebula.navigation, - useNebulaNavigation: false, - onAccept: async (element) => { - if (element?.dataset.action === "scan") { - await scanLibrary(); - return; - } - if (element?.dataset.action === "refresh") { - await refreshLibrary(); - } - }, - onBack: () => { - state.activeView = "home"; - renderView("home"); - }, - onMenu: () => {}, - }; - }, -}); + if (!root) return; -const setStatus = (title, summary) => { - const status = document.querySelector("[data-library-status]"); - const summaryNode = document.querySelector("[data-library-summary]"); - if (status) status.textContent = title; - if (summaryNode) summaryNode.textContent = summary; -}; + runtime.visibleItems = filterLibraryItems(runtime.items, runtime.query); + if (!runtime.visibleItems.some((item) => item.id === runtime.focusedId)) { + runtime.focusedId = runtime.visibleItems[0]?.id ?? null; + } -const renderGames = (games = [], convertFileSrc = null) => { - const grid = document.querySelector("[data-library-grid]"); - if (!grid) return; - - if (!games.length) { - grid.innerHTML = ` -
      -

      No apps discovered yet.

      -

      Press Scan Device to build your NebulaOS library.

      -
      - `; - return; - } - - const gameItems = games.filter((game) => !isTool(game)); - const toolItems = games.filter(isTool); - const sections = []; - let cardIndex = 0; - - if (gameItems.length) { - sections.push(renderLibrarySection("Games", "Library", gameItems, cardIndex, convertFileSrc)); - cardIndex += gameItems.length; - } - - const toolGroups = toolItems.reduce((groups, game) => { - const category = toolCategoryForGame(game); - if (!groups.has(category)) groups.set(category, []); - groups.get(category).push(game); - return groups; - }, new Map()); - - [...toolGroups.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .forEach(([category, items]) => { - sections.push(renderLibrarySection(category, "Tools", items, cardIndex, convertFileSrc)); - cardIndex += items.length; - }); - - grid.innerHTML = sections.join(""); - bindImageFallbacks(grid); -}; - -const bindImageFallbacks = (root) => { - root.querySelectorAll(".library-card-image").forEach((image) => { - image.addEventListener("error", () => { - const art = image.closest(".library-card-art"); - image.remove(); - art?.classList.remove("has-image"); - }); - }); -}; - -const renderProviders = (providers = []) => { - const list = document.querySelector("[data-library-providers]"); - if (!list) return; - list.innerHTML = providers.map(renderProviderReport).join(""); -}; - -const refreshLibrary = async () => { - const { invoke, convertFileSrc } = await getTauriCore(); - if (!invoke) { - setStatus("Desktop bridge unavailable", "Run inside Tauri to scan installed apps."); - return; - } - - try { - const games = await invoke("list_library_games"); - renderGames(games, convertFileSrc); - const tools = games.filter(isTool).length; - const gameCount = games.length - tools; - setStatus( - `${games.length} apps in library`, - `${gameCount} games · ${tools} tools/software · hidden apps stay restorable`, + root.querySelector("[data-library-categories]").innerHTML = renderCategoryTabs(runtime.query); + root.querySelector("[data-library-genres]").innerHTML = renderGenreTabs(runtime.query); + root.querySelector("[data-library-summary]").innerHTML = renderSummary( + runtime.items, + runtime.status, + runtime.message, ); - } catch (error) { - setStatus("Library unavailable", String(error)); - } -}; + root.querySelector("[data-library-filters]").innerHTML = renderFilters(runtime.query); + root.querySelector("[data-library-grid]").innerHTML = renderGrid(runtime.visibleItems, runtime.focusedId); -const scanLibrary = async () => { - const { invoke, convertFileSrc } = await getTauriCore(); - if (!invoke) { - setStatus("Desktop bridge unavailable", "Run inside Tauri to scan installed apps."); - return; - } + const resultTitle = root.querySelector("[data-library-result-title]"); + const resultKicker = root.querySelector("[data-library-result-kicker]"); + const status = root.querySelector("[data-library-status]"); + if (resultTitle) resultTitle.textContent = `${runtime.visibleItems.length} Visible`; + if (resultKicker) resultKicker.textContent = `${runtime.query.genre} · ${runtime.query.sortBy}`; + if (status) status.textContent = runtime.message; - setStatus("Scanning device...", "Checking Steam libraries, Epic manifests, GOG installs, and local folders."); - renderProviders([]); + const details = root.querySelector("[data-library-details]"); + details.innerHTML = runtime.detailsOpen ? renderDetailsPanel(findItem(runtime)) : ""; + details.setAttribute("aria-hidden", runtime.detailsOpen ? "false" : "true"); + details.classList.toggle("is-open", runtime.detailsOpen); - try { - const summary = await invoke("scan_library_command", { - request: { - localFolders: defaultLocalFolders(), - }, - }); - renderProviders(summary.providers); - renderGames(summary.games, convertFileSrc); - setStatus( - `${summary.discovered} discovered`, - `${summary.insertedOrUpdated} saved · ${summary.metadataMatched} metadata matches · ${summary.unmatched} need review`, + const sortPanel = root.querySelector("[data-library-sort-panel]"); + sortPanel.innerHTML = runtime.sortOpen ? renderSortPanel(runtime.query) : ""; + sortPanel.setAttribute("aria-hidden", runtime.sortOpen ? "false" : "true"); + sortPanel.classList.toggle("is-open", runtime.sortOpen); + + requestNavigationRefresh(focusKey ?? focusKeyForRuntime(runtime)); + }; + + const setLoadedState = (result) => { + runtime.items = result.items; + runtime.status = result.status; + runtime.message = result.message; + runtime.providers = result.providers; + renderLibrary(); + }; + + const refreshLibrary = async () => { + runtime.status = "Refreshing"; + runtime.message = "Reloading library cards from library.db or mock fallback data."; + renderLibrary("library-refresh"); + setLoadedState(await loadLibraryItems()); + }; + + const scanLibrary = async () => { + runtime.status = "Scanning device"; + runtime.message = "Checking Steam, GOG, Epic, emulator folders, and local apps for future integrations."; + renderLibrary("library-scan"); + try { + setLoadedState(await scanLibraryItems()); + } catch (error) { + runtime.status = "Scan failed"; + runtime.message = String(error); + renderLibrary("library-scan"); + } + }; + + const launchFocusedItem = async () => { + const item = findItem(runtime); + if (!item) return; + const result = await launchLibraryItem(item); + runtime.status = result?.launched ? "Launch requested" : "Details ready"; + runtime.message = result?.message ?? `${item.title} is ready.`; + if (!item.installed) { + runtime.detailsOpen = true; + } + renderLibrary(); + }; + + const openDetails = () => { + if (!runtime.focusedId) return; + runtime.detailsOpen = true; + runtime.sortOpen = false; + renderLibrary("library-detail-0"); + }; + + const closePanels = () => { + runtime.detailsOpen = false; + runtime.sortOpen = false; + renderLibrary(); + }; + + const toggleSortPanel = () => { + runtime.sortOpen = !runtime.sortOpen; + runtime.detailsOpen = false; + renderLibrary(runtime.sortOpen ? `library-sort-${runtime.query.sortBy}` : focusKeyForRuntime(runtime)); + }; + + const applyElementAction = async (element) => { + if (!element) return; + const { action } = element.dataset; + + if (action === "scan") return scanLibrary(); + if (action === "refresh") return refreshLibrary(); + if (action === "category") runtime.query.category = element.dataset.category; + if (action === "genre") runtime.query.genre = element.dataset.genre; + if (action === "platform") runtime.query.platform = element.dataset.platform; + if (action === "toggle-installed") runtime.query.installedOnly = !runtime.query.installedOnly; + if (action === "cycle-play-state") runtime.query.playState = cycleOption(PLAY_STATES, runtime.query.playState, 1); + if (action === "toggle-coop") runtime.query.coOpOnly = !runtime.query.coOpOnly; + if (action === "toggle-achievements") runtime.query.achievementsOnly = !runtime.query.achievementsOnly; + if (action === "sort") runtime.query.sortBy = element.dataset.sort; + + if (action === "card") { + runtime.focusedId = element.dataset.itemId; + return launchFocusedItem(); + } + + if (action === "launch") return launchFocusedItem(); + + if (action === "install") { + runtime.status = "Install queued"; + runtime.message = "Store/provider install flows will connect here when integrations are added."; + } + + if (action === "uninstall") { + runtime.status = "Uninstall placeholder"; + runtime.message = "Uninstall will be routed through source-specific providers later."; + } + + if (action === "hide") { + const item = findItem(runtime); + if (item) { + item.hidden = true; + await hideLibraryItem(item); + runtime.detailsOpen = false; + runtime.status = "Hidden"; + runtime.message = `${item.title} is hidden from the visible library.`; + } + } + + if (action === "open-folder") { + runtime.status = "Open folder placeholder"; + runtime.message = "Folder opening will use the desktop bridge for installed apps."; + } + + if (action === "close-details" || action === "close-sort") { + return closePanels(); + } + + renderLibrary(element.dataset.focusKey); + }; + + const cycleCategory = (direction) => { + runtime.query.category = cycleOption( + LIBRARY_CATEGORIES.map((category) => category.id), + runtime.query.category, + direction, ); - } catch (error) { - setStatus("Scan failed", String(error)); - } + renderLibrary(`library-category-${runtime.query.category}`); + }; + + const cycleGenre = (direction) => { + runtime.query.genre = cycleOption(GENRE_FILTERS, runtime.query.genre, direction); + const genreIndex = GENRE_FILTERS.findIndex((genre) => genre === runtime.query.genre); + renderLibrary(`library-genre-${genreIndex}`); + }; + + return { + id: "library", + render: () => renderLibraryShell(), + mount: async () => { + const root = document.querySelector("[data-view='library']"); + if (!root) return; + runtime.mounted = true; + root.addEventListener("click", (event) => { + const actionElement = event.target.closest("[data-action]"); + if (actionElement) { + applyElementAction(actionElement); + } + }); + if (!runtime.focusListenerAttached) { + window.addEventListener("nebula-focus-change", (event) => { + const key = event.detail?.key ?? ""; + if (key.startsWith("library-card-")) { + runtime.focusedId = key.replace("library-card-", ""); + } + }); + runtime.focusListenerAttached = true; + } + renderLibrary("library-category-games"); + setLoadedState(await loadLibraryItems()); + }, + getNavigationContract: () => { + const root = document.querySelector("[data-view='library']"); + return { + focusRoot: root, + defaultFocus: root?.querySelector("[data-focus-key='library-category-games']") ?? null, + layout: { type: "grid", cols: 9, rows: 24 }, + hintsTemplate: "#library-hints-template", + nebulaNavigation: state.nebula.navigation, + useNebulaNavigation: false, + onAccept: applyElementAction, + onBack: () => { + if (runtime.detailsOpen || runtime.sortOpen) { + closePanels(); + return; + } + state.activeView = "home"; + renderView("home"); + }, + onMenu: () => {}, + onAction: (action, element) => { + if (action === "y") { + toggleSortPanel(); + return true; + } + if (action === "clear") { + openDetails(); + return true; + } + if (action === "l1") { + cycleCategory(-1); + return true; + } + if (action === "r1") { + cycleCategory(1); + return true; + } + if (action === "l2") { + cycleGenre(-1); + return true; + } + if (action === "r2") { + cycleGenre(1); + return true; + } + if (element?.dataset.itemId) { + runtime.focusedId = element.dataset.itemId; + renderLibrary(element.dataset.focusKey); + } + return false; + }, + }; + }, + }; }; diff --git a/src/views/library/libraryBridge.js b/src/views/library/libraryBridge.js new file mode 100644 index 0000000..fc28b64 --- /dev/null +++ b/src/views/library/libraryBridge.js @@ -0,0 +1,124 @@ +import { createMockLibraryItems, normalizeLibraryItem } from "./libraryModel.js"; + +export const getTauriCore = async () => { + const globalCore = window.__TAURI__?.core; + if (typeof globalCore?.invoke === "function") { + return { + invoke: globalCore.invoke, + convertFileSrc: typeof globalCore.convertFileSrc === "function" ? globalCore.convertFileSrc : null, + }; + } + + try { + const tauriCore = await import("@tauri-apps/api/core"); + return { + invoke: typeof tauriCore.invoke === "function" ? tauriCore.invoke : null, + convertFileSrc: typeof tauriCore.convertFileSrc === "function" ? tauriCore.convertFileSrc : null, + }; + } catch (_error) { + return { invoke: null, convertFileSrc: null }; + } +}; + +const defaultLocalFolders = () => ["C:/Games", "D:/Games"]; + +export const loadLibraryItems = async () => { + const { invoke, convertFileSrc } = await getTauriCore(); + if (!invoke) { + return { + items: createMockLibraryItems(), + status: "Mock data", + message: "Run inside Tauri to scan real installed apps into library.db.", + providers: [], + }; + } + + try { + const games = await invoke("list_library_games"); + const scannedItems = Array.isArray(games) + ? games.map((game, index) => normalizeLibraryItem(game, index, convertFileSrc)) + : []; + + return { + items: scannedItems.length ? scannedItems : createMockLibraryItems(), + status: scannedItems.length ? "Synced with library.db" : "Mock data", + message: scannedItems.length + ? "Showing installed apps scanned into the separate NebulaOS library database." + : "No scanned apps yet. Mock cards are shown until Start Scan finds installed apps.", + providers: [], + }; + } catch (error) { + return { + items: createMockLibraryItems(), + status: "Library bridge fallback", + message: String(error), + providers: [], + }; + } +}; + +export const scanLibraryItems = async () => { + const { invoke, convertFileSrc } = await getTauriCore(); + if (!invoke) { + return { + items: createMockLibraryItems(), + status: "Desktop bridge unavailable", + message: "Start Scan will call Tauri when NebulaOS is running as a desktop app.", + providers: [], + }; + } + + const summary = await invoke("scan_library_command", { + request: { localFolders: defaultLocalFolders() }, + }); + + const items = Array.isArray(summary?.games) + ? summary.games.map((game, index) => normalizeLibraryItem(game, index, convertFileSrc)) + : []; + + return { + items: items.length ? items : createMockLibraryItems(), + status: `${summary?.discovered ?? 0} discovered`, + message: `${summary?.insertedOrUpdated ?? 0} saved · ${summary?.metadataMatched ?? 0} metadata matches · ${ + summary?.unmatched ?? 0 + } need review`, + providers: summary?.providers ?? [], + }; +}; + +export const launchLibraryItem = async (item) => { + if (!item?.installed) { + console.info("[NebulaOS] Install/details state requested", item); + return { launched: false, action: "details", message: "This app is not installed yet." }; + } + + const { invoke } = await getTauriCore(); + const numericId = Number(item.backendId ?? item.id); + if (invoke && Number.isFinite(numericId)) { + return invoke("launch_library_game", { gameId: numericId }); + } + + console.info("[NebulaOS] Launch placeholder", { + id: item.id, + title: item.title, + executablePath: item.executablePath, + installPath: item.installPath, + }); + return { launched: true, action: "mock", message: `Launch queued for ${item.title}.` }; +}; + +export const hideLibraryItem = async (item) => { + const { invoke } = await getTauriCore(); + const numericId = Number(item?.backendId ?? item?.id); + if (invoke && Number.isFinite(numericId)) { + await invoke("update_library_game", { + request: { + gameId: numericId, + title: null, + executablePath: null, + hidden: true, + favourite: null, + }, + }); + } +}; diff --git a/src/views/library/libraryComponents.js b/src/views/library/libraryComponents.js new file mode 100644 index 0000000..2724544 --- /dev/null +++ b/src/views/library/libraryComponents.js @@ -0,0 +1,324 @@ +import { + GENRE_FILTERS, + LIBRARY_CATEGORIES, + PLATFORM_FILTERS, + PLAY_STATE_LABELS, + SORT_OPTIONS, + SOURCE_LABELS, + TYPE_LABELS, + formatPlaytime, + formatShortDate, + initialsForTitle, + summarizeLibrary, +} from "./libraryModel.js"; + +const escapeHtml = (value) => + String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +const providerLabel = (source) => SOURCE_LABELS[source] ?? source; +const typeLabel = (type) => TYPE_LABELS[type] ?? type; + +const navButton = ({ className, row, col, key, action, label, active = false, extra = "" }) => ` + +`; + +export const renderLibraryShell = () => ` +
      +
      +
      +

      Unified Library

      +

      Library

      +
      +
      +
      + + --- + --:-- +
      +
      + ${navButton({ className: "library-action", row: 0, col: 7, key: "library-scan", action: "scan", label: "Start Scan" })} + ${navButton({ className: "library-action", row: 0, col: 8, key: "library-refresh", action: "refresh", label: "Refresh" })} +
      +
      +
      + +
      +
      + + +
      + +
      +
      +
      +

      Ready

      +

      Controller Library

      +
      +

      Loading library...

      +
      +
      +
      +
      +
      +
      + + + +
      +`; + +export const renderCategoryTabs = (query) => + LIBRARY_CATEGORIES.map((category, index) => + navButton({ + className: "library-category-tab", + row: 1, + col: index, + key: `library-category-${category.id}`, + action: "category", + label: escapeHtml(category.label), + active: query.category === category.id, + extra: `data-category="${category.id}"`, + }), + ).join(""); + +export const renderGenreTabs = (query) => + GENRE_FILTERS.map((genre, index) => + navButton({ + className: "library-genre-tab", + row: 2, + col: index, + key: `library-genre-${index}`, + action: "genre", + label: escapeHtml(genre), + active: query.genre === genre, + extra: `data-genre="${escapeHtml(genre)}"`, + }), + ).join(""); + +export const renderSummary = (items, status, message) => { + const summary = summarizeLibrary(items); + return ` +

      Library Summary

      +
      + ${summary.total} + Total apps +
      +
      + ${summary.games} Games + ${summary.verified} Verified + ${summary.hidden} Hidden + ${summary.installed} Installed +
      +
      + +
      + ${escapeHtml(status)} +

      ${escapeHtml(message)}

      +
      +
      + `; +}; + +export const renderFilters = (query) => { + const platformButtons = PLATFORM_FILTERS.map((platform, index) => + navButton({ + className: "library-filter-chip", + row: 4 + index, + col: 0, + key: `library-platform-${platform}`, + action: "platform", + label: platform === "all" ? "All Platforms" : providerLabel(platform), + active: query.platform === platform, + extra: `data-platform="${platform}"`, + }), + ).join(""); + + return ` +

      Filter Toggles

      +
      + By Platform + ${platformButtons} +
      +
      + ${navButton({ + className: "library-filter-chip", + row: 11, + col: 0, + key: "library-installed-only", + action: "toggle-installed", + label: "Installed Only", + active: query.installedOnly, + })} + ${navButton({ + className: "library-filter-chip", + row: 12, + col: 0, + key: "library-play-state", + action: "cycle-play-state", + label: `Play State: ${PLAY_STATE_LABELS[query.playState]}`, + active: query.playState !== "all", + })} + ${navButton({ + className: "library-filter-chip", + row: 13, + col: 0, + key: "library-coop", + action: "toggle-coop", + label: "Co-op", + active: query.coOpOnly, + })} + ${navButton({ + className: "library-filter-chip", + row: 14, + col: 0, + key: "library-achievements", + action: "toggle-achievements", + label: "Achievements", + active: query.achievementsOnly, + })} +
      + `; +}; + +export const renderGrid = (items, focusedId) => { + if (!items.length) { + return ` +
      +

      No Matches

      +

      No apps match these filters.

      +

      Press Y to open sorting and filters, or clear toggles from the left panel.

      +
      + `; + } + + return items + .map((item, index) => { + const row = 4 + Math.floor(index / 4); + const col = 2 + (index % 4); + const artStyle = item.coverImage + ? `style="--library-art: url('${escapeHtml(item.coverImage)}'); --library-accent: ${escapeHtml(item.accent)};"` + : `style="--library-accent: ${escapeHtml(item.accent)};"`; + const achievementCopy = item.achievementsSupported + ? `${item.achievementsUnlocked ?? 0}/${item.achievementsTotal ?? "?"} achievements` + : "No achievements"; + + return ` + + `; + }) + .join(""); +}; + +export const renderDetailsPanel = (item) => { + if (!item) return ""; + const achievements = item.achievementsSupported + ? `${item.achievementsUnlocked ?? 0} / ${item.achievementsTotal ?? "?"}` + : "Not supported"; + + return ` +
      +
      +
      + ${escapeHtml(initialsForTitle(item.title))} +
      +
      +

      ${escapeHtml(providerLabel(item.source))} · ${escapeHtml(typeLabel(item.type))}

      +

      ${escapeHtml(item.title)}

      +

      ${escapeHtml(item.description)}

      +
      +
      Install location
      ${escapeHtml(item.installPath || "Not installed")}
      +
      Last played
      ${escapeHtml(formatShortDate(item.lastPlayed))}
      +
      Playtime
      ${escapeHtml(formatPlaytime(item.playtimeMinutes))}
      +
      Achievements
      ${escapeHtml(achievements)}
      +
      +
      + ${["Launch", "Install", "Uninstall", "Hide", "Open Folder"] + .map( + (label, index) => ` + + `, + ) + .join("")} +
      +
      +
      + `; +}; + +export const renderSortPanel = (query) => ` +
      +
      +

      Sort Library

      +

      Filter / Sort

      +

      Use Y to close. These options immediately reorder the visible grid.

      +
      + ${SORT_OPTIONS.map((option, index) => + navButton({ + className: "library-sort-option", + row: 18 + index, + col: 6, + key: `library-sort-${option.id}`, + action: "sort", + label: escapeHtml(option.label), + active: query.sortBy === option.id, + extra: `data-sort="${option.id}"`, + }), + ).join("")} +
      +
      +`; diff --git a/src/views/library/libraryController.js b/src/views/library/libraryController.js new file mode 100644 index 0000000..e624c1a --- /dev/null +++ b/src/views/library/libraryController.js @@ -0,0 +1,14 @@ +export const requestNavigationRefresh = (focusKey = null) => { + window.dispatchEvent( + new CustomEvent("nebula-navigation-refresh", { + detail: { focusKey }, + }), + ); +}; + +export const cycleOption = (options, current, direction) => { + const index = options.findIndex((option) => option === current); + const safeIndex = index >= 0 ? index : 0; + const nextIndex = (safeIndex + direction + options.length) % options.length; + return options[nextIndex]; +}; diff --git a/src/views/library/libraryFilters.js b/src/views/library/libraryFilters.js new file mode 100644 index 0000000..80614da --- /dev/null +++ b/src/views/library/libraryFilters.js @@ -0,0 +1,83 @@ +const lower = (value) => String(value ?? "").toLowerCase(); + +const byDateDesc = (field) => (left, right) => { + const leftTime = left[field] ? new Date(left[field]).getTime() : 0; + const rightTime = right[field] ? new Date(right[field]).getTime() : 0; + return rightTime - leftTime; +}; + +export const filterLibraryItems = (items, query) => { + let next = [...items]; + + if (!query.includeHidden) { + next = next.filter((item) => !item.hidden); + } + + if (query.category === "games") { + next = next.filter((item) => item.type === "game"); + } + + if (query.category === "software") { + next = next.filter((item) => item.type !== "game"); + } + + if (query.genre === "Latest Installed") { + next = next.filter((item) => item.installedAt); + } else if (query.genre === "Recently Played") { + next = next.filter((item) => item.lastPlayed); + } else if (query.genre !== "All Genres") { + next = next.filter((item) => item.genre.some((genre) => lower(genre) === lower(query.genre))); + } + + if (query.platform !== "all") { + next = next.filter((item) => item.source === query.platform); + } + + if (query.installedOnly) { + next = next.filter((item) => item.installed); + } + + if (query.playState === "played") { + next = next.filter((item) => item.playtimeMinutes > 0 || item.lastPlayed); + } + + if (query.playState === "unplayed") { + next = next.filter((item) => item.playtimeMinutes === 0 && !item.lastPlayed); + } + + if (query.coOpOnly) { + next = next.filter((item) => item.coOp); + } + + if (query.achievementsOnly) { + next = next.filter((item) => item.achievementsSupported); + } + + return sortLibraryItems(next, query.sortBy, query.genre); +}; + +export const sortLibraryItems = (items, sortBy, genre) => { + const sorted = [...items]; + const resolvedSort = + genre === "Latest Installed" ? "recentlyInstalled" : genre === "Recently Played" ? "recentlyPlayed" : sortBy; + + if (resolvedSort === "recentlyPlayed") { + return sorted.sort(byDateDesc("lastPlayed")); + } + + if (resolvedSort === "recentlyInstalled") { + return sorted.sort(byDateDesc("installedAt")); + } + + if (resolvedSort === "playtime") { + return sorted.sort((left, right) => right.playtimeMinutes - left.playtimeMinutes); + } + + if (resolvedSort === "platform") { + return sorted.sort( + (left, right) => left.source.localeCompare(right.source) || left.title.localeCompare(right.title), + ); + } + + return sorted.sort((left, right) => left.title.localeCompare(right.title)); +}; diff --git a/src/views/library/libraryModel.js b/src/views/library/libraryModel.js new file mode 100644 index 0000000..af9bee4 --- /dev/null +++ b/src/views/library/libraryModel.js @@ -0,0 +1,276 @@ +export const LIBRARY_CATEGORIES = [ + { id: "games", label: "Games" }, + { id: "software", label: "Software" }, + { id: "unified", label: "Unified Library" }, +]; + +export const GENRE_FILTERS = [ + "All Genres", + "Action", + "RPG", + "Strategy", + "Indie", + "Latest Installed", + "Recently Played", +]; + +export const PLATFORM_FILTERS = ["all", "steam", "gog", "epic", "native", "emulated", "other"]; + +export const SORT_OPTIONS = [ + { id: "recentlyPlayed", label: "Recently played" }, + { id: "recentlyInstalled", label: "Recently installed" }, + { id: "alphabetical", label: "Alphabetical" }, + { id: "playtime", label: "Playtime" }, + { id: "platform", label: "Platform" }, +]; + +export const SOURCE_LABELS = { + steam: "Steam", + gog: "GOG", + epic: "Epic", + native: "Native", + emulated: "Emulated", + other: "Other", +}; + +export const TYPE_LABELS = { + game: "Game", + software: "Software", + tool: "Tool", + launcher: "Launcher", +}; + +export const PLAY_STATE_LABELS = { + all: "All play states", + played: "Played", + unplayed: "Unplayed", +}; + +export const createDefaultLibraryQuery = () => ({ + category: "games", + genre: "All Genres", + platform: "all", + installedOnly: false, + playState: "all", + coOpOnly: false, + achievementsOnly: false, + sortBy: "recentlyPlayed", + includeHidden: false, +}); + +const sourceFromBackend = (source) => { + const normalized = String(source ?? "").toLowerCase(); + if (normalized === "local") return "native"; + if (["steam", "gog", "epic", "native", "emulated"].includes(normalized)) return normalized; + return "other"; +}; + +const typeFromBackend = (kind) => { + const normalized = String(kind ?? "").toLowerCase(); + if (["game", "software", "tool", "launcher"].includes(normalized)) return normalized; + return "game"; +}; + +export const initialsForTitle = (title) => + String(title ?? "Nebula") + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") || "OS"; + +export const formatPlaytime = (minutes = 0) => { + if (!minutes) return "Not played"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remainder = minutes % 60; + return remainder ? `${hours}h ${remainder}m` : `${hours}h`; +}; + +export const formatShortDate = (value) => { + if (!value) return "Never"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Never"; + return date.toLocaleDateString([], { month: "short", day: "numeric" }); +}; + +export const normalizeLibraryItem = (raw, index = 0, convertFileSrc = null) => { + const title = raw?.userTitle || raw?.title || "Unknown App"; + const source = sourceFromBackend(raw?.platformSource ?? raw?.source); + const type = typeFromBackend(raw?.appKind ?? raw?.type); + const convert = (path) => (path && convertFileSrc ? convertFileSrc(path) : path || null); + const installed = raw?.installed ?? Boolean(raw?.installPath || raw?.install_path); + + return { + id: String(raw?.id ?? `backend-${index}`), + backendId: raw?.id ?? null, + title, + type, + source, + genre: Array.isArray(raw?.genre) + ? raw.genre + : Array.isArray(raw?.genres) + ? raw.genres + : type === "game" + ? ["Action"] + : ["Utilities"], + installed, + installPath: raw?.installPath ?? raw?.install_path ?? null, + executablePath: raw?.executablePath ?? raw?.executable_path ?? null, + coverImage: convert(raw?.coverImage ?? raw?.cover_image), + bannerImage: convert(raw?.bannerImage ?? raw?.heroImage ?? raw?.hero_image), + iconImage: convert(raw?.iconImage ?? raw?.icon_image), + description: + raw?.description || + "Scanned from your local library. Metadata can be enriched later by Steam, GOG, Epic, emulator, and local metadata providers.", + lastPlayed: raw?.lastPlayed ?? null, + installedAt: raw?.installedAt ?? raw?.createdAt ?? null, + playtimeMinutes: Number(raw?.playtimeMinutes ?? 0), + supportsController: Boolean(raw?.supportsController ?? type === "game"), + steamDeckVerified: Boolean(raw?.steamDeckVerified ?? source === "steam"), + achievementsSupported: Boolean(raw?.achievementsSupported ?? false), + achievementsUnlocked: raw?.achievementsUnlocked ?? null, + achievementsTotal: raw?.achievementsTotal ?? null, + hidden: Boolean(raw?.hidden ?? raw?.userHidden ?? false), + multiplayer: Boolean(raw?.multiplayer ?? false), + coOp: Boolean(raw?.coOp ?? false), + accent: raw?.accent ?? ["#4fd8ff", "#9d4fe0", "#1f7aff", "#39ffd2"][index % 4], + }; +}; + +export const createMockLibraryItems = () => + [ + { + id: "mock-starfall", + title: "Starfall Protocol", + type: "game", + source: "steam", + genre: ["Action", "RPG"], + installed: true, + installPath: "C:/Games/Starfall Protocol", + executablePath: "C:/Games/Starfall Protocol/starfall.exe", + description: "Pilot a relic fighter through collapsing gates in a neon campaign built for controller play.", + lastPlayed: "2026-05-15T21:15:00Z", + installedAt: "2026-05-01T08:30:00Z", + playtimeMinutes: 1874, + supportsController: true, + steamDeckVerified: true, + achievementsSupported: true, + achievementsUnlocked: 28, + achievementsTotal: 54, + multiplayer: true, + coOp: true, + accent: "#4fd8ff", + }, + { + id: "mock-iron-vault", + title: "Iron Vault Tactics", + type: "game", + source: "gog", + genre: ["Strategy", "Indie"], + installed: true, + installPath: "D:/Games/Iron Vault Tactics", + executablePath: "D:/Games/Iron Vault Tactics/ivt.exe", + description: "A turn-based tactics sandbox with long-form campaigns, mod support, and couch co-op skirmishes.", + lastPlayed: "2026-05-10T11:00:00Z", + installedAt: "2026-04-18T18:00:00Z", + playtimeMinutes: 942, + supportsController: true, + achievementsSupported: false, + multiplayer: true, + coOp: true, + accent: "#39ffd2", + }, + { + id: "mock-nebula-paint", + title: "Nebula Paint Studio", + type: "software", + source: "native", + genre: ["Creative", "Utilities"], + installed: true, + installPath: "C:/Program Files/Nebula Paint", + executablePath: "C:/Program Files/Nebula Paint/paint.exe", + description: "A TV-friendly concept art tool for quick capture, markup, and launcher artwork editing.", + lastPlayed: "2026-05-11T06:20:00Z", + installedAt: "2026-03-12T09:00:00Z", + playtimeMinutes: 223, + supportsController: true, + achievementsSupported: false, + multiplayer: false, + coOp: false, + accent: "#9d4fe0", + }, + { + id: "mock-ember", + title: "Emberline", + type: "game", + source: "epic", + genre: ["Action", "Indie"], + installed: false, + installPath: null, + executablePath: null, + description: "Wishlist entry from a linked store. Install support will be routed through the Epic integration later.", + lastPlayed: null, + installedAt: null, + playtimeMinutes: 0, + supportsController: true, + steamDeckVerified: false, + achievementsSupported: true, + achievementsUnlocked: 0, + achievementsTotal: 32, + multiplayer: false, + coOp: false, + accent: "#1f7aff", + }, + { + id: "mock-retro-core", + title: "RetroCore Station", + type: "launcher", + source: "emulated", + genre: ["Launcher", "Retro"], + installed: true, + installPath: "D:/Emulation/RetroCore", + executablePath: "D:/Emulation/RetroCore/retrocore.exe", + description: "A controller-native emulator hub prepared for future ROM library scanning and save sync.", + lastPlayed: "2026-05-14T04:10:00Z", + installedAt: "2026-05-03T15:10:00Z", + playtimeMinutes: 517, + supportsController: true, + achievementsSupported: true, + achievementsUnlocked: 88, + achievementsTotal: 210, + multiplayer: true, + coOp: true, + accent: "#ffb84f", + }, + { + id: "mock-orbit-tools", + title: "Orbit Mod Tools", + type: "tool", + source: "other", + genre: ["Utilities", "Game Development"], + installed: false, + installPath: null, + executablePath: null, + description: "Tooling placeholder for future mod SDK detection, dependency checks, and per-game utilities.", + lastPlayed: null, + installedAt: null, + playtimeMinutes: 0, + supportsController: false, + achievementsSupported: false, + multiplayer: false, + coOp: false, + accent: "#ff6b9a", + }, + ].map((item, index) => normalizeLibraryItem(item, index)); + +export const summarizeLibrary = (items) => { + const visible = items.filter((item) => !item.hidden); + return { + total: visible.length, + games: visible.filter((item) => item.type === "game").length, + verified: visible.filter((item) => item.steamDeckVerified).length, + hidden: items.filter((item) => item.hidden).length, + installed: visible.filter((item) => item.installed).length, + }; +};