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.
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
mod library;
|
mod library;
|
||||||
mod storage;
|
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 rusqlite::{params, Connection, OptionalExtension};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -199,6 +201,7 @@ pub fn run() {
|
|||||||
get_first_user,
|
get_first_user,
|
||||||
create_user,
|
create_user,
|
||||||
list_library_games,
|
list_library_games,
|
||||||
|
launch_library_game,
|
||||||
scan_library_command,
|
scan_library_command,
|
||||||
update_library_game
|
update_library_game
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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::models::{GameCustomizationRequest, LibraryGame, LibraryScanRequest, ScanSummary};
|
||||||
use super::{list_visible_games, scan_library};
|
use super::{list_visible_games, scan_library};
|
||||||
use crate::storage::AppStorage;
|
use crate::storage::AppStorage;
|
||||||
|
use serde::Serialize;
|
||||||
use std::path::PathBuf;
|
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]
|
#[tauri::command]
|
||||||
pub async fn scan_library_command(
|
pub async fn scan_library_command(
|
||||||
request: LibraryScanRequest,
|
request: LibraryScanRequest,
|
||||||
@@ -45,3 +54,45 @@ pub async fn update_library_game(
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| format!("Library update task failed: {err}"))?
|
.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<LaunchLibraryGameResult, String> {
|
||||||
|
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/<appid>, 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}"))?
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ pub fn upsert_candidate(
|
|||||||
executable_path,
|
executable_path,
|
||||||
launch_command,
|
launch_command,
|
||||||
description,
|
description,
|
||||||
app_kind.as_str(),
|
app_kind,
|
||||||
steam_app_type,
|
steam_app_type,
|
||||||
genres_json,
|
genres_json,
|
||||||
steam_categories_json,
|
steam_categories_json,
|
||||||
|
|||||||
+275
-138
@@ -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 getRect = (element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
x: rect.left,
|
left: rect.left,
|
||||||
y: rect.top,
|
top: rect.top,
|
||||||
|
right: rect.right,
|
||||||
|
bottom: rect.bottom,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
|
centerX: rect.left + rect.width / 2,
|
||||||
|
centerY: rect.top + rect.height / 2,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const scoreCandidate = (source, target, direction) => {
|
const isInDirection = (sourceRect, targetRect, direction) => {
|
||||||
const sourceRect = getRect(source.element);
|
if (direction === "right") return targetRect.centerX > sourceRect.right - EPSILON;
|
||||||
const targetRect = getRect(target.element);
|
if (direction === "left") return targetRect.centerX < sourceRect.left + EPSILON;
|
||||||
const sourceCenterX = sourceRect.x + sourceRect.width / 2;
|
if (direction === "down") return targetRect.centerY > sourceRect.bottom - EPSILON;
|
||||||
const sourceCenterY = sourceRect.y + sourceRect.height / 2;
|
if (direction === "up") return targetRect.centerY < sourceRect.top + EPSILON;
|
||||||
const targetCenterX = targetRect.x + targetRect.width / 2;
|
return false;
|
||||||
const targetCenterY = targetRect.y + targetRect.height / 2;
|
};
|
||||||
const horizontal = targetCenterX - sourceCenterX;
|
|
||||||
const vertical = targetCenterY - sourceCenterY;
|
|
||||||
|
|
||||||
if (direction === "up" && vertical >= -1) return Number.POSITIVE_INFINITY;
|
const perpendicularOverlap = (sourceRect, targetRect, direction) => {
|
||||||
if (direction === "down" && vertical <= 1) return Number.POSITIVE_INFINITY;
|
if (direction === "left" || direction === "right") {
|
||||||
if (direction === "left" && horizontal >= -1) return Number.POSITIVE_INFINITY;
|
const start = Math.max(sourceRect.top, targetRect.top);
|
||||||
if (direction === "right" && horizontal <= 1) return Number.POSITIVE_INFINITY;
|
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 primaryDistance = (sourceRect, targetRect, direction) => {
|
||||||
const secondary = direction === "up" || direction === "down" ? Math.abs(horizontal) : Math.abs(vertical);
|
if (direction === "right") return Math.max(0, targetRect.left - sourceRect.right);
|
||||||
const sourceStart = direction === "up" || direction === "down" ? sourceRect.x : sourceRect.y;
|
if (direction === "left") return Math.max(0, sourceRect.left - targetRect.right);
|
||||||
const sourceEnd = sourceStart + (direction === "up" || direction === "down" ? sourceRect.width : sourceRect.height);
|
if (direction === "down") return Math.max(0, targetRect.top - sourceRect.bottom);
|
||||||
const targetStart = direction === "up" || direction === "down" ? targetRect.x : targetRect.y;
|
if (direction === "up") return Math.max(0, sourceRect.top - targetRect.bottom);
|
||||||
const targetEnd = targetStart + (direction === "up" || direction === "down" ? targetRect.width : targetRect.height);
|
return Number.POSITIVE_INFINITY;
|
||||||
const overlap = Math.max(0, Math.min(sourceEnd, targetEnd) - Math.max(sourceStart, targetStart));
|
};
|
||||||
const overlapPenalty = overlap > 0 ? -40 : 0;
|
|
||||||
|
|
||||||
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 = () => {
|
export const createNavigationManager = () => {
|
||||||
@@ -40,11 +133,34 @@ export const createNavigationManager = () => {
|
|||||||
let focusables = [];
|
let focusables = [];
|
||||||
let focusedIndex = -1;
|
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) => {
|
const decorateFocusable = (element) => {
|
||||||
element.classList.remove("is-focused");
|
element.classList.remove("is-focused");
|
||||||
element.tabIndex = -1;
|
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) => {
|
const applyFocus = (index) => {
|
||||||
if (!focusables.length) {
|
if (!focusables.length) {
|
||||||
focusedIndex = -1;
|
focusedIndex = -1;
|
||||||
@@ -64,21 +180,7 @@ export const createNavigationManager = () => {
|
|||||||
focused.classList.add("is-focused");
|
focused.classList.add("is-focused");
|
||||||
focused.setAttribute("aria-selected", "true");
|
focused.setAttribute("aria-selected", "true");
|
||||||
focused.focus({ preventScroll: true });
|
focused.focus({ preventScroll: true });
|
||||||
|
dispatchFocusChange(focused);
|
||||||
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,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,181 +196,216 @@ export const createNavigationManager = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
focusables = nodes
|
focusables = nodes
|
||||||
.map((element, index) => {
|
.filter(isElementInteractable)
|
||||||
|
.map((element) => {
|
||||||
decorateFocusable(element);
|
decorateFocusable(element);
|
||||||
return {
|
return {
|
||||||
index,
|
|
||||||
element,
|
element,
|
||||||
row: Number(element.dataset.row ?? 0),
|
row: Number(element.dataset.row ?? 0),
|
||||||
col: Number(element.dataset.col ?? 0),
|
col: Number(element.dataset.col ?? 0),
|
||||||
key: element.dataset.focusKey ?? String(index),
|
key: element.dataset.focusKey ?? "",
|
||||||
region: element.dataset.navRegion ?? "content",
|
region: element.dataset.navRegion ?? "content",
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
.sort((left, right) => {
|
|
||||||
if (left.row === right.row) {
|
focusables.forEach((focusable, index) => {
|
||||||
return left.col - right.col;
|
focusable.index = index;
|
||||||
}
|
|
||||||
return left.row - right.row;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveDefaultFocus = () => {
|
const resolveDefaultFocus = () => {
|
||||||
if (!focusables.length) {
|
if (!focusables.length) return -1;
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contract?.defaultFocus) {
|
if (contract?.defaultFocus) {
|
||||||
const defaultIndex = focusables.findIndex((focusable) => focusable.element === contract.defaultFocus);
|
const idx = focusables.findIndex((f) => f.element === contract.defaultFocus);
|
||||||
if (defaultIndex >= 0) {
|
if (idx >= 0) return idx;
|
||||||
return defaultIndex;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
const contentIdx = focusables.findIndex((f) => f.region !== "sidebar");
|
||||||
|
return contentIdx >= 0 ? contentIdx : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveDefaultContentFocus = () => {
|
const findContentDefaultIndex = () => {
|
||||||
if (!focusables.length) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contract?.defaultFocus) {
|
if (contract?.defaultFocus) {
|
||||||
const defaultIndex = focusables.findIndex((focusable) => focusable.element === contract.defaultFocus);
|
const idx = focusables.findIndex((f) => f.element === contract.defaultFocus);
|
||||||
if (defaultIndex >= 0 && focusables[defaultIndex]?.region !== "sidebar") {
|
if (idx >= 0 && focusables[idx].region !== "sidebar") {
|
||||||
return defaultIndex;
|
return idx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return focusables.findIndex((f) => f.region !== "sidebar");
|
||||||
return focusables.findIndex((focusable) => focusable.region !== "sidebar");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const findSidebarIndex = () => {
|
const findSidebarTargetIndex = () => {
|
||||||
const activeIndex = focusables.findIndex(
|
const activeIdx = focusables.findIndex(
|
||||||
(focusable) => focusable.region === "sidebar" && focusable.element.classList.contains("is-active"),
|
(f) => f.region === "sidebar" && f.element.classList.contains("is-active"),
|
||||||
);
|
);
|
||||||
if (activeIndex >= 0) {
|
if (activeIdx >= 0) return activeIdx;
|
||||||
return activeIndex;
|
return focusables.findIndex((f) => f.region === "sidebar");
|
||||||
}
|
|
||||||
|
|
||||||
return focusables.findIndex((focusable) => focusable.region === "sidebar");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const findNextSidebarIndex = (direction) => {
|
const findNextSidebarIndex = (direction) => {
|
||||||
const source = focusables[focusedIndex];
|
|
||||||
if (!source || source.region !== "sidebar") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sidebarItems = focusables
|
const sidebarItems = focusables
|
||||||
.map((item, index) => ({ item, index }))
|
.map((focusable, index) => ({ focusable, index }))
|
||||||
.filter(({ item }) => item.region === "sidebar")
|
.filter(({ focusable }) => focusable.region === "sidebar");
|
||||||
.sort((left, right) => left.item.row - right.item.row);
|
|
||||||
const currentSidebarIndex = sidebarItems.findIndex(({ index }) => index === focusedIndex);
|
|
||||||
if (currentSidebarIndex < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextSidebarIndex = direction === "up" ? currentSidebarIndex - 1 : currentSidebarIndex + 1;
|
if (!sidebarItems.length) return null;
|
||||||
return sidebarItems[nextSidebarIndex]?.index ?? 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) => {
|
const moveWithNebula = (direction) => {
|
||||||
if (contract?.useNebulaNavigation === false) {
|
// The bundled @nebulaproject/core picker is opt-in. Our local spatial
|
||||||
return null;
|
// 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;
|
const picker = contract?.nebulaNavigation?.pickBestCandidate;
|
||||||
if (typeof picker !== "function") {
|
if (typeof picker !== "function") return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = focusables[focusedIndex];
|
const source = focusables[focusedIndex];
|
||||||
if (!source) {
|
if (!source) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceRect = getRect(source.element);
|
const sourceRect = getRect(source.element);
|
||||||
const candidates = focusables
|
const candidates = focusables
|
||||||
.filter((item) => item.index !== source.index)
|
.filter((item) => item.index !== source.index)
|
||||||
.map((item) => ({
|
.map((item) => {
|
||||||
|
const rect = getRect(item.element);
|
||||||
|
return {
|
||||||
id: item.key,
|
id: item.key,
|
||||||
...getRect(item.element),
|
x: rect.left,
|
||||||
}));
|
y: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const picked = picker(
|
const picked = picker(
|
||||||
{ id: source.key, ...sourceRect },
|
{
|
||||||
|
id: source.key,
|
||||||
|
x: sourceRect.left,
|
||||||
|
y: sourceRect.top,
|
||||||
|
width: sourceRect.width,
|
||||||
|
height: sourceRect.height,
|
||||||
|
},
|
||||||
candidates,
|
candidates,
|
||||||
direction,
|
direction,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!picked?.id) {
|
if (!picked?.id) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextIndex = focusables.findIndex((item) => item.key === picked.id);
|
const nextIndex = focusables.findIndex((item) => item.key === picked.id);
|
||||||
return nextIndex >= 0 ? nextIndex : null;
|
return nextIndex >= 0 ? nextIndex : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const move = (direction) => {
|
const move = (direction) => {
|
||||||
if (!focusables.length || focusedIndex < 0) {
|
if (!focusables.length || focusedIndex < 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = focusables[focusedIndex];
|
const source = focusables[focusedIndex];
|
||||||
|
const sourceRect = getRect(source.element);
|
||||||
|
|
||||||
if (direction === "left" && source?.region !== "sidebar") {
|
// -- Sidebar region ----------------------------------------------------
|
||||||
const sidebarIndex = findSidebarIndex();
|
if (source.region === "sidebar") {
|
||||||
if (sidebarIndex >= 0) {
|
if (direction === "up" || direction === "down") {
|
||||||
applyFocus(sidebarIndex);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
// direction === "left" from sidebar: nothing further left.
|
||||||
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);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Optional external navigation hook (Nebula core) -------------------
|
||||||
const nebulaIndex = moveWithNebula(direction);
|
const nebulaIndex = moveWithNebula(direction);
|
||||||
if (nebulaIndex !== null) {
|
if (nebulaIndex !== null) {
|
||||||
|
updateAnchorForMove(direction, sourceRect);
|
||||||
applyFocus(nebulaIndex);
|
applyFocus(nebulaIndex);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bestIndex = focusedIndex;
|
// -- Spatial search within content -------------------------------------
|
||||||
let bestScore = Number.POSITIVE_INFINITY;
|
const bestContentIdx = findBestContentSpatialIndex(direction);
|
||||||
|
if (bestContentIdx >= 0) {
|
||||||
focusables.forEach((candidate, index) => {
|
updateAnchorForMove(direction, sourceRect);
|
||||||
if (index === focusedIndex) {
|
applyFocus(bestContentIdx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const score = scoreCandidate(source, candidate, direction);
|
// -- Edge of content: escape to sidebar when going left ----------------
|
||||||
if (score < bestScore) {
|
if (direction === "left") {
|
||||||
bestScore = score;
|
const sidebarIdx = findSidebarTargetIndex();
|
||||||
bestIndex = index;
|
if (sidebarIdx >= 0) {
|
||||||
|
anchor = { axis: null, value: null };
|
||||||
|
applyFocus(sidebarIdx);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (bestIndex !== focusedIndex) {
|
// No candidate in this direction. Stay put — pressing again won't bounce
|
||||||
applyFocus(bestIndex);
|
// the player around to unrelated regions.
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mount = (nextContract) => {
|
const mount = (nextContract) => {
|
||||||
contract = nextContract;
|
contract = nextContract;
|
||||||
|
anchor = { axis: null, value: null };
|
||||||
buildFocusables();
|
buildFocusables();
|
||||||
applyFocus(resolveDefaultFocus());
|
applyFocus(resolveDefaultFocus());
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const FALLBACK_GLYPHS = {
|
|||||||
r1: "RB",
|
r1: "RB",
|
||||||
l2: "LT",
|
l2: "LT",
|
||||||
r2: "RT",
|
r2: "RT",
|
||||||
|
clear: "X",
|
||||||
y: "Y",
|
y: "Y",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,6 +125,7 @@ export const createAppState = () => {
|
|||||||
r1: glyphs.getGlyph("xbox", "rb") ?? FALLBACK_GLYPHS.r1,
|
r1: glyphs.getGlyph("xbox", "rb") ?? FALLBACK_GLYPHS.r1,
|
||||||
l2: glyphs.getGlyph("xbox", "lt") ?? FALLBACK_GLYPHS.l2,
|
l2: glyphs.getGlyph("xbox", "lt") ?? FALLBACK_GLYPHS.l2,
|
||||||
r2: glyphs.getGlyph("xbox", "rt") ?? FALLBACK_GLYPHS.r2,
|
r2: glyphs.getGlyph("xbox", "rt") ?? FALLBACK_GLYPHS.r2,
|
||||||
|
clear: glyphs.getGlyph("xbox", "x") ?? FALLBACK_GLYPHS.clear,
|
||||||
y: glyphs.getGlyph("xbox", "y") ?? FALLBACK_GLYPHS.y,
|
y: glyphs.getGlyph("xbox", "y") ?? FALLBACK_GLYPHS.y,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-8
@@ -43,17 +43,17 @@
|
|||||||
<span class="sidebar-nav-icon" aria-hidden="true">⊟</span>
|
<span class="sidebar-nav-icon" aria-hidden="true">⊟</span>
|
||||||
<span class="sidebar-nav-label">Library</span>
|
<span class="sidebar-nav-label">Library</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="sidebar-nav-item focusable" data-sidebar-nav="media" data-nav-region="sidebar" data-focusable="true" data-row="2" data-col="-1" data-focus-key="sidebar-media" data-disabled="true" role="listitem" aria-disabled="true">
|
<li class="sidebar-nav-item focusable" data-sidebar-nav="store" data-nav-region="sidebar" data-focusable="true" data-row="2" data-col="-1" data-focus-key="sidebar-store" data-disabled="true" role="listitem" aria-disabled="true">
|
||||||
<span class="sidebar-nav-icon" aria-hidden="true">▶</span>
|
|
||||||
<span class="sidebar-nav-label">Media</span>
|
|
||||||
</li>
|
|
||||||
<li class="sidebar-nav-item focusable" data-sidebar-nav="store" data-nav-region="sidebar" data-focusable="true" data-row="3" data-col="-1" data-focus-key="sidebar-store" data-disabled="true" role="listitem" aria-disabled="true">
|
|
||||||
<span class="sidebar-nav-icon" aria-hidden="true">⊞</span>
|
<span class="sidebar-nav-icon" aria-hidden="true">⊞</span>
|
||||||
<span class="sidebar-nav-label">Store</span>
|
<span class="sidebar-nav-label">Store</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="sidebar-nav-item focusable" data-sidebar-nav="settings" data-target="settings" data-nav-region="sidebar" data-focusable="true" data-row="4" data-col="-1" data-focus-key="sidebar-settings" role="listitem">
|
<li class="sidebar-nav-item focusable" data-sidebar-nav="mods" data-nav-region="sidebar" data-focusable="true" data-row="3" data-col="-1" data-focus-key="sidebar-mods" data-disabled="true" role="listitem" aria-disabled="true">
|
||||||
<span class="sidebar-nav-icon" aria-hidden="true">⚙</span>
|
<span class="sidebar-nav-icon" aria-hidden="true">◇</span>
|
||||||
<span class="sidebar-nav-label">Settings</span>
|
<span class="sidebar-nav-label">Mods</span>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-nav-item focusable" data-sidebar-nav="appstore" data-nav-region="sidebar" data-focusable="true" data-row="4" data-col="-1" data-focus-key="sidebar-appstore" data-disabled="true" role="listitem" aria-disabled="true">
|
||||||
|
<span class="sidebar-nav-icon" aria-hidden="true">▣</span>
|
||||||
|
<span class="sidebar-nav-label">Appstore</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -96,5 +96,16 @@
|
|||||||
<span class="hint"><span data-glyph="menu"></span> Done</span>
|
<span class="hint"><span data-glyph="menu"></span> Done</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template id="library-hints-template">
|
||||||
|
<div class="hint-row">
|
||||||
|
<span class="hint"><span data-glyph="accept"></span> Launch / Select</span>
|
||||||
|
<span class="hint"><span data-glyph="clear"></span> Details</span>
|
||||||
|
<span class="hint"><span data-glyph="y"></span> Filter / Sort</span>
|
||||||
|
<span class="hint"><span data-glyph="l1"></span>/<span data-glyph="r1"></span> Tabs</span>
|
||||||
|
<span class="hint"><span data-glyph="l2"></span>/<span data-glyph="r2"></span> Genres</span>
|
||||||
|
<span class="hint"><span data-glyph="back"></span> Back</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+18
@@ -116,6 +116,23 @@ const renderView = (viewId) => {
|
|||||||
updateClockLabels();
|
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 registerViews = () => {
|
||||||
const context = { state, renderView, powerMenu, keyboard, openPowerMenu };
|
const context = { state, renderView, powerMenu, keyboard, openPowerMenu };
|
||||||
router.register(createUserSetupView(context));
|
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"],
|
actions: ["up", "down", "left", "right", "accept", "back", "menu", "clear", "y", "l1", "r1", "l2", "r2"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener("nebula-navigation-refresh", refreshNavigation);
|
||||||
input.start();
|
input.start();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+467
-102
@@ -1,177 +1,542 @@
|
|||||||
.library-view {
|
.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;
|
display: flex;
|
||||||
gap: var(--nebula-spacing-sm);
|
|
||||||
align-items: center;
|
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 {
|
.library-eyebrow,
|
||||||
display: grid;
|
.library-section-kicker {
|
||||||
grid-template-columns: minmax(260px, 320px) 1fr;
|
margin: 0;
|
||||||
gap: var(--nebula-spacing-lg);
|
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;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-status-panel {
|
.library-category-tabs,
|
||||||
align-self: start;
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--nebula-spacing-md);
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-status-title {
|
.library-summary-card,
|
||||||
margin: 0;
|
.library-filter-card,
|
||||||
font-size: clamp(22px, 2vw, 30px);
|
.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 {
|
.library-status-copy {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--nebula-color-muted);
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--nebula-spacing-xs);
|
gap: 8px;
|
||||||
margin: 0;
|
margin-top: 12px;
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-provider-row {
|
.library-filter-label {
|
||||||
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);
|
|
||||||
color: var(--nebula-color-muted);
|
color: var(--nebula-color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-provider-row strong {
|
.library-filter-chip {
|
||||||
color: var(--nebula-color-text);
|
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 {
|
.library-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--nebula-spacing-md);
|
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||||
align-content: start;
|
gap: 16px;
|
||||||
min-width: 0;
|
overflow: auto;
|
||||||
}
|
padding: 4px 8px 24px;
|
||||||
|
scrollbar-width: thin;
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-card {
|
.library-card {
|
||||||
min-height: 220px;
|
min-height: 330px;
|
||||||
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
border-radius: 22px;
|
||||||
padding: 0;
|
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||||
background: var(--nebula-color-panel-alt);
|
background: rgba(8, 12, 27, 0.94);
|
||||||
color: var(--nebula-color-text);
|
color: var(--nebula-color-text);
|
||||||
border: 2px solid var(--nebula-color-border);
|
|
||||||
border-radius: var(--nebula-radius-md);
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
box-shadow: 0 16px 34px rgba(0, 0, 0, 0.34);
|
||||||
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-card.is-focused {
|
.library-card.is-focused {
|
||||||
border-color: var(--nebula-color-accent);
|
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 {
|
.library-card-art {
|
||||||
min-height: 140px;
|
position: relative;
|
||||||
|
min-height: 178px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
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;
|
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 {
|
.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 {
|
.library-card-art span,
|
||||||
position: absolute;
|
.library-details-art span {
|
||||||
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 {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 64px;
|
width: 78px;
|
||||||
height: 64px;
|
height: 78px;
|
||||||
border-radius: 18px;
|
border-radius: 24px;
|
||||||
background: rgba(0, 0, 0, 0.28);
|
background: rgba(0, 0, 0, 0.24);
|
||||||
color: var(--nebula-color-text);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
font-size: 34px;
|
font-size: 26px;
|
||||||
font-weight: 800;
|
font-weight: 950;
|
||||||
z-index: 1;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-card-art.has-image span {
|
.library-card-art.has-image span,
|
||||||
|
.library-details-art.has-image span {
|
||||||
display: none;
|
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 {
|
.library-card-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
padding: var(--nebula-spacing-md);
|
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;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
font-weight: 800;
|
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;
|
margin: 0;
|
||||||
color: var(--nebula-color-muted);
|
color: var(--nebula-color-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-empty {
|
.library-card-meta-row {
|
||||||
grid-column: 1 / -1;
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+252
-297
@@ -1,337 +1,292 @@
|
|||||||
const getTauriCore = async () => {
|
import { filterLibraryItems } from "./libraryFilters.js";
|
||||||
const globalCore = window.__TAURI__?.core;
|
import {
|
||||||
if (typeof globalCore?.invoke === "function") {
|
GENRE_FILTERS,
|
||||||
return {
|
LIBRARY_CATEGORIES,
|
||||||
invoke: globalCore.invoke,
|
createDefaultLibraryQuery,
|
||||||
convertFileSrc: typeof globalCore.convertFileSrc === "function" ? globalCore.convertFileSrc : null,
|
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";
|
||||||
|
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderLibrary = (focusKey = null) => {
|
||||||
|
const root = document.querySelector("[data-view='library']");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
runtime.visibleItems = filterLibraryItems(runtime.items, runtime.query);
|
||||||
|
if (!runtime.visibleItems.some((item) => item.id === runtime.focusedId)) {
|
||||||
|
runtime.focusedId = runtime.visibleItems[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
root.querySelector("[data-library-filters]").innerHTML = renderFilters(runtime.query);
|
||||||
|
root.querySelector("[data-library-grid]").innerHTML = renderGrid(runtime.visibleItems, runtime.focusedId);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const tauriCore = await import("@tauri-apps/api/core");
|
setLoadedState(await scanLibraryItems());
|
||||||
return {
|
} catch (error) {
|
||||||
invoke: typeof tauriCore.invoke === "function" ? tauriCore.invoke : null,
|
runtime.status = "Scan failed";
|
||||||
convertFileSrc: typeof tauriCore.convertFileSrc === "function" ? tauriCore.convertFileSrc : null,
|
runtime.message = String(error);
|
||||||
};
|
renderLibrary("library-scan");
|
||||||
} catch (_error) {
|
|
||||||
return { invoke: null, convertFileSrc: null };
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
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",
|
|
||||||
};
|
};
|
||||||
return labels[source] ?? source;
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabel = (status) => {
|
const launchFocusedItem = async () => {
|
||||||
const labels = {
|
const item = findItem(runtime);
|
||||||
pending: "Pending metadata",
|
if (!item) return;
|
||||||
matched: "Matched",
|
const result = await launchLibraryItem(item);
|
||||||
needs_review: "Needs review",
|
runtime.status = result?.launched ? "Launch requested" : "Details ready";
|
||||||
manual: "Manual",
|
runtime.message = result?.message ?? `${item.title} is ready.`;
|
||||||
|
if (!item.installed) {
|
||||||
|
runtime.detailsOpen = true;
|
||||||
|
}
|
||||||
|
renderLibrary();
|
||||||
};
|
};
|
||||||
return labels[status] ?? "Pending metadata";
|
|
||||||
};
|
|
||||||
|
|
||||||
const kindLabel = (kind) => {
|
const openDetails = () => {
|
||||||
const labels = {
|
if (!runtime.focusedId) return;
|
||||||
game: "Game",
|
runtime.detailsOpen = true;
|
||||||
tool: "Tool",
|
runtime.sortOpen = false;
|
||||||
software: "Software",
|
renderLibrary("library-detail-0");
|
||||||
unknown: "Unknown",
|
|
||||||
};
|
};
|
||||||
return labels[kind] ?? "Game";
|
|
||||||
};
|
|
||||||
|
|
||||||
const gameTitle = (game) => game.userTitle || game.title || "Unknown App";
|
const closePanels = () => {
|
||||||
|
runtime.detailsOpen = false;
|
||||||
|
runtime.sortOpen = false;
|
||||||
|
renderLibrary();
|
||||||
|
};
|
||||||
|
|
||||||
const escapeHtml = (value) =>
|
const toggleSortPanel = () => {
|
||||||
String(value ?? "")
|
runtime.sortOpen = !runtime.sortOpen;
|
||||||
.replaceAll("&", "&")
|
runtime.detailsOpen = false;
|
||||||
.replaceAll("<", "<")
|
renderLibrary(runtime.sortOpen ? `library-sort-${runtime.query.sortBy}` : focusKeyForRuntime(runtime));
|
||||||
.replaceAll(">", ">")
|
};
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
|
|
||||||
const imageUrlForGame = (game, convertFileSrc) => {
|
const applyElementAction = async (element) => {
|
||||||
const imagePath = game.coverImage || game.heroImage || game.iconImage;
|
if (!element) return;
|
||||||
return imagePath && convertFileSrc ? convertFileSrc(imagePath) : "";
|
const { action } = element.dataset;
|
||||||
};
|
|
||||||
|
|
||||||
const TOOL_CATEGORY_LABELS = new Set([
|
if (action === "scan") return scanLibrary();
|
||||||
"animation & modeling",
|
if (action === "refresh") return refreshLibrary();
|
||||||
"audio production",
|
if (action === "category") runtime.query.category = element.dataset.category;
|
||||||
"design & illustration",
|
if (action === "genre") runtime.query.genre = element.dataset.genre;
|
||||||
"education",
|
if (action === "platform") runtime.query.platform = element.dataset.platform;
|
||||||
"game development",
|
if (action === "toggle-installed") runtime.query.installedOnly = !runtime.query.installedOnly;
|
||||||
"photo editing",
|
if (action === "cycle-play-state") runtime.query.playState = cycleOption(PLAY_STATES, runtime.query.playState, 1);
|
||||||
"software training",
|
if (action === "toggle-coop") runtime.query.coOpOnly = !runtime.query.coOpOnly;
|
||||||
"utilities",
|
if (action === "toggle-achievements") runtime.query.achievementsOnly = !runtime.query.achievementsOnly;
|
||||||
"video production",
|
if (action === "sort") runtime.query.sortBy = element.dataset.sort;
|
||||||
"web publishing",
|
|
||||||
"includes level editor",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isTool = (game) => game.appKind === "tool" || game.appKind === "software";
|
if (action === "card") {
|
||||||
|
runtime.focusedId = element.dataset.itemId;
|
||||||
|
return launchFocusedItem();
|
||||||
|
}
|
||||||
|
|
||||||
const toolCategoryForGame = (game) => {
|
if (action === "launch") return launchFocusedItem();
|
||||||
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) => {
|
if (action === "install") {
|
||||||
const title = gameTitle(game);
|
runtime.status = "Install queued";
|
||||||
const imageUrl = imageUrlForGame(game, convertFileSrc);
|
runtime.message = "Store/provider install flows will connect here when integrations are added.";
|
||||||
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 `
|
if (action === "uninstall") {
|
||||||
<button
|
runtime.status = "Uninstall placeholder";
|
||||||
class="library-card focusable"
|
runtime.message = "Uninstall will be routed through source-specific providers later.";
|
||||||
data-focusable="true"
|
}
|
||||||
data-row="${Math.floor(index / 4) + 1}"
|
|
||||||
data-col="${index % 4}"
|
|
||||||
data-focus-key="library-game-${game.id}"
|
|
||||||
data-game-id="${game.id}"
|
|
||||||
aria-label="${escapeHtml(title)}"
|
|
||||||
>
|
|
||||||
<div class="library-card-art ${hasImage ? "has-image" : ""}" aria-hidden="true">
|
|
||||||
${hasImage ? `<img class="library-card-image" src="${escapeHtml(imageUrl)}" alt="" loading="lazy">` : ""}
|
|
||||||
<span>${escapeHtml(sourceLabel(game.platformSource).slice(0, 1))}</span>
|
|
||||||
</div>
|
|
||||||
<div class="library-card-body">
|
|
||||||
<p class="library-card-title">${escapeHtml(title)}</p>
|
|
||||||
<p class="library-card-meta">${escapeHtml(metaParts.join(" · "))}</p>
|
|
||||||
<p class="library-card-meta">${escapeHtml(statusLabel(game.metadataStatus))}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderProviderReport = (provider) => `
|
if (action === "hide") {
|
||||||
<li class="library-provider-row">
|
const item = findItem(runtime);
|
||||||
<span>${sourceLabel(provider.source)}</span>
|
if (item) {
|
||||||
<strong>${provider.error ? "Error" : `${provider.discovered} found`}</strong>
|
item.hidden = true;
|
||||||
</li>
|
await hideLibraryItem(item);
|
||||||
`;
|
runtime.detailsOpen = false;
|
||||||
|
runtime.status = "Hidden";
|
||||||
|
runtime.message = `${item.title} is hidden from the visible library.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const renderLibrarySection = (title, eyebrow, games, startIndex, convertFileSrc) => `
|
if (action === "open-folder") {
|
||||||
<section class="library-section">
|
runtime.status = "Open folder placeholder";
|
||||||
<div class="library-section-heading">
|
runtime.message = "Folder opening will use the desktop bridge for installed apps.";
|
||||||
<p class="muted">${escapeHtml(eyebrow)}</p>
|
}
|
||||||
<h2>${escapeHtml(title)}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="library-section-grid">
|
|
||||||
${games.map((game, offset) => renderGameCard(game, startIndex + offset, convertFileSrc)).join("")}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const LIBRARY_TEMPLATE = `
|
if (action === "close-details" || action === "close-sort") {
|
||||||
<section class="view library-view" data-view="library">
|
return closePanels();
|
||||||
<header class="shell-topbar">
|
}
|
||||||
<div class="shell-topbar-content">
|
|
||||||
<p class="shell-brand">Nebula OS</p>
|
|
||||||
<div class="shell-status">
|
|
||||||
<span class="shell-avatar" aria-hidden="true"></span>
|
|
||||||
<p class="shell-time" data-clock>--:--</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="shell-accent-line"></div>
|
|
||||||
</header>
|
|
||||||
<section class="view-header">
|
|
||||||
<div>
|
|
||||||
<p class="muted">Unified Library</p>
|
|
||||||
<h1 class="view-title">Library</h1>
|
|
||||||
</div>
|
|
||||||
<div class="library-actions">
|
|
||||||
<button class="focusable button-like" data-focusable="true" data-row="0" data-col="0" data-action="scan" data-focus-key="library-scan">Scan Device</button>
|
|
||||||
<button class="focusable button-like" data-focusable="true" data-row="0" data-col="1" data-action="refresh" data-focus-key="library-refresh">Refresh</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="library-layout">
|
|
||||||
<section class="panel library-status-panel">
|
|
||||||
<div>
|
|
||||||
<p class="muted">Scanner</p>
|
|
||||||
<h2 class="library-status-title" data-library-status>Ready to scan</h2>
|
|
||||||
</div>
|
|
||||||
<p class="library-status-copy" data-library-summary>Scan Steam, Epic, GOG, and local folders into the isolated library database.</p>
|
|
||||||
<ul class="library-provider-list" data-library-providers></ul>
|
|
||||||
</section>
|
|
||||||
<section class="library-grid" data-library-grid>
|
|
||||||
<article class="panel library-empty">
|
|
||||||
<p class="muted">No apps discovered yet.</p>
|
|
||||||
<p>Press Scan Device to build your NebulaOS library.</p>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const createLibraryView = ({ state, renderView }) => ({
|
renderLibrary(element.dataset.focusKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleCategory = (direction) => {
|
||||||
|
runtime.query.category = cycleOption(
|
||||||
|
LIBRARY_CATEGORIES.map((category) => category.id),
|
||||||
|
runtime.query.category,
|
||||||
|
direction,
|
||||||
|
);
|
||||||
|
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",
|
id: "library",
|
||||||
render: () => LIBRARY_TEMPLATE,
|
render: () => renderLibraryShell(),
|
||||||
mount: async () => {
|
mount: async () => {
|
||||||
document.querySelector("[data-action='scan']")?.addEventListener("click", scanLibrary);
|
const root = document.querySelector("[data-view='library']");
|
||||||
document.querySelector("[data-action='refresh']")?.addEventListener("click", refreshLibrary);
|
if (!root) return;
|
||||||
await refreshLibrary();
|
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: () => {
|
getNavigationContract: () => {
|
||||||
const root = document.querySelector("[data-view='library']");
|
const root = document.querySelector("[data-view='library']");
|
||||||
return {
|
return {
|
||||||
focusRoot: root,
|
focusRoot: root,
|
||||||
defaultFocus: root?.querySelector("[data-action='scan']") ?? null,
|
defaultFocus: root?.querySelector("[data-focus-key='library-category-games']") ?? null,
|
||||||
layout: { type: "grid", cols: 4, rows: 4 },
|
layout: { type: "grid", cols: 9, rows: 24 },
|
||||||
hintsTemplate: "#global-hints-template",
|
hintsTemplate: "#library-hints-template",
|
||||||
nebulaNavigation: state.nebula.navigation,
|
nebulaNavigation: state.nebula.navigation,
|
||||||
useNebulaNavigation: false,
|
useNebulaNavigation: false,
|
||||||
onAccept: async (element) => {
|
onAccept: applyElementAction,
|
||||||
if (element?.dataset.action === "scan") {
|
onBack: () => {
|
||||||
await scanLibrary();
|
if (runtime.detailsOpen || runtime.sortOpen) {
|
||||||
|
closePanels();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (element?.dataset.action === "refresh") {
|
|
||||||
await refreshLibrary();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onBack: () => {
|
|
||||||
state.activeView = "home";
|
state.activeView = "home";
|
||||||
renderView("home");
|
renderView("home");
|
||||||
},
|
},
|
||||||
onMenu: () => {},
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderGames = (games = [], convertFileSrc = null) => {
|
|
||||||
const grid = document.querySelector("[data-library-grid]");
|
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
if (!games.length) {
|
|
||||||
grid.innerHTML = `
|
|
||||||
<article class="panel library-empty">
|
|
||||||
<p class="muted">No apps discovered yet.</p>
|
|
||||||
<p>Press Scan Device to build your NebulaOS library.</p>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
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`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
setStatus("Library unavailable", String(error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scanLibrary = async () => {
|
|
||||||
const { invoke, convertFileSrc } = await getTauriCore();
|
|
||||||
if (!invoke) {
|
|
||||||
setStatus("Desktop bridge unavailable", "Run inside Tauri to scan installed apps.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("Scanning device...", "Checking Steam libraries, Epic manifests, GOG installs, and local folders.");
|
|
||||||
renderProviders([]);
|
|
||||||
|
|
||||||
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`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
setStatus("Scan failed", String(error));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 = "" }) => `
|
||||||
|
<button
|
||||||
|
class="${className} focusable ${active ? "is-active" : ""}"
|
||||||
|
data-focusable="true"
|
||||||
|
data-row="${row}"
|
||||||
|
data-col="${col}"
|
||||||
|
data-focus-key="${escapeHtml(key)}"
|
||||||
|
data-action="${escapeHtml(action)}"
|
||||||
|
aria-selected="${active ? "true" : "false"}"
|
||||||
|
${extra}
|
||||||
|
>${label}</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const renderLibraryShell = () => `
|
||||||
|
<section class="view library-view" data-view="library">
|
||||||
|
<header class="library-topbar">
|
||||||
|
<div>
|
||||||
|
<p class="library-eyebrow">Unified Library</p>
|
||||||
|
<h1 class="library-title">Library</h1>
|
||||||
|
</div>
|
||||||
|
<div class="library-topbar-right">
|
||||||
|
<div class="library-system-status">
|
||||||
|
<span class="library-status-dot" aria-hidden="true"></span>
|
||||||
|
<span data-date>---</span>
|
||||||
|
<strong data-clock>--:--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="library-actions">
|
||||||
|
${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" })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="library-console" data-focus-root>
|
||||||
|
<section class="library-main">
|
||||||
|
<nav class="library-category-tabs" aria-label="Library categories" data-library-categories></nav>
|
||||||
|
<nav class="library-genre-tabs" aria-label="Library filters" data-library-genres></nav>
|
||||||
|
<section class="library-content-row">
|
||||||
|
<aside class="library-sidebar">
|
||||||
|
<section class="library-summary-card" data-library-summary></section>
|
||||||
|
<section class="library-filter-card" data-library-filters></section>
|
||||||
|
</aside>
|
||||||
|
<section class="library-grid-region">
|
||||||
|
<div class="library-grid-header">
|
||||||
|
<div>
|
||||||
|
<p class="library-section-kicker" data-library-result-kicker>Ready</p>
|
||||||
|
<h2 data-library-result-title>Controller Library</h2>
|
||||||
|
</div>
|
||||||
|
<p class="library-status-copy" data-library-status>Loading library...</p>
|
||||||
|
</div>
|
||||||
|
<div class="library-grid" data-library-grid></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="library-details-panel" data-library-details aria-hidden="true"></aside>
|
||||||
|
<aside class="library-sort-panel" data-library-sort-panel aria-hidden="true"></aside>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<p class="library-section-kicker">Library Summary</p>
|
||||||
|
<div class="library-summary-total">
|
||||||
|
<strong>${summary.total}</strong>
|
||||||
|
<span>Total apps</span>
|
||||||
|
</div>
|
||||||
|
<div class="library-stat-grid">
|
||||||
|
<span><strong>${summary.games}</strong> Games</span>
|
||||||
|
<span><strong>${summary.verified}</strong> Verified</span>
|
||||||
|
<span><strong>${summary.hidden}</strong> Hidden</span>
|
||||||
|
<span><strong>${summary.installed}</strong> Installed</span>
|
||||||
|
</div>
|
||||||
|
<div class="library-sync-status">
|
||||||
|
<span class="library-status-dot" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(status)}</strong>
|
||||||
|
<p>${escapeHtml(message)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<p class="library-section-kicker">Filter Toggles</p>
|
||||||
|
<div class="library-filter-group">
|
||||||
|
<span class="library-filter-label">By Platform</span>
|
||||||
|
${platformButtons}
|
||||||
|
</div>
|
||||||
|
<div class="library-filter-group">
|
||||||
|
${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,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderGrid = (items, focusedId) => {
|
||||||
|
if (!items.length) {
|
||||||
|
return `
|
||||||
|
<article class="library-empty-card">
|
||||||
|
<p class="library-section-kicker">No Matches</p>
|
||||||
|
<h3>No apps match these filters.</h3>
|
||||||
|
<p>Press Y to open sorting and filters, or clear toggles from the left panel.</p>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<button
|
||||||
|
class="library-card focusable ${focusedId === item.id ? "is-selected-card" : ""}"
|
||||||
|
data-focusable="true"
|
||||||
|
data-row="${row}"
|
||||||
|
data-col="${col}"
|
||||||
|
data-focus-key="library-card-${escapeHtml(item.id)}"
|
||||||
|
data-action="card"
|
||||||
|
data-item-id="${escapeHtml(item.id)}"
|
||||||
|
aria-label="${escapeHtml(item.title)}"
|
||||||
|
>
|
||||||
|
<div class="library-card-art ${item.coverImage ? "has-image" : ""}" ${artStyle}>
|
||||||
|
<span>${escapeHtml(initialsForTitle(item.title))}</span>
|
||||||
|
${item.steamDeckVerified ? `<em class="library-verified-badge">Verified</em>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="library-card-body">
|
||||||
|
<div class="library-card-title-row">
|
||||||
|
<h3>${escapeHtml(item.title)}</h3>
|
||||||
|
<span>${item.installed ? "Installed" : "Not installed"}</span>
|
||||||
|
</div>
|
||||||
|
<p>${escapeHtml(providerLabel(item.source))} · ${escapeHtml(typeLabel(item.type))}</p>
|
||||||
|
<div class="library-card-meta-row">
|
||||||
|
<span>${escapeHtml(formatShortDate(item.lastPlayed))}</span>
|
||||||
|
<span>${escapeHtml(formatPlaytime(item.playtimeMinutes))}</span>
|
||||||
|
</div>
|
||||||
|
<p class="library-card-focus-copy">${escapeHtml(item.description)}</p>
|
||||||
|
<p class="library-card-achievements">${escapeHtml(achievementCopy)}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderDetailsPanel = (item) => {
|
||||||
|
if (!item) return "";
|
||||||
|
const achievements = item.achievementsSupported
|
||||||
|
? `${item.achievementsUnlocked ?? 0} / ${item.achievementsTotal ?? "?"}`
|
||||||
|
: "Not supported";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="library-details-backdrop" data-action="close-details"></div>
|
||||||
|
<section class="library-details-card">
|
||||||
|
<div class="library-details-art ${item.bannerImage || item.coverImage ? "has-image" : ""}" style="${
|
||||||
|
item.bannerImage || item.coverImage
|
||||||
|
? `--library-detail-art: url('${escapeHtml(item.bannerImage || item.coverImage)}');`
|
||||||
|
: `--library-accent: ${escapeHtml(item.accent)};`
|
||||||
|
}">
|
||||||
|
<span>${escapeHtml(initialsForTitle(item.title))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="library-details-body">
|
||||||
|
<p class="library-section-kicker">${escapeHtml(providerLabel(item.source))} · ${escapeHtml(typeLabel(item.type))}</p>
|
||||||
|
<h2>${escapeHtml(item.title)}</h2>
|
||||||
|
<p>${escapeHtml(item.description)}</p>
|
||||||
|
<dl class="library-details-list">
|
||||||
|
<div><dt>Install location</dt><dd>${escapeHtml(item.installPath || "Not installed")}</dd></div>
|
||||||
|
<div><dt>Last played</dt><dd>${escapeHtml(formatShortDate(item.lastPlayed))}</dd></div>
|
||||||
|
<div><dt>Playtime</dt><dd>${escapeHtml(formatPlaytime(item.playtimeMinutes))}</dd></div>
|
||||||
|
<div><dt>Achievements</dt><dd>${escapeHtml(achievements)}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="library-details-actions">
|
||||||
|
${["Launch", "Install", "Uninstall", "Hide", "Open Folder"]
|
||||||
|
.map(
|
||||||
|
(label, index) => `
|
||||||
|
<button
|
||||||
|
class="library-detail-button focusable"
|
||||||
|
data-focusable="true"
|
||||||
|
data-row="${20 + index}"
|
||||||
|
data-col="8"
|
||||||
|
data-action="${escapeHtml(label.toLowerCase().replaceAll(" ", "-"))}"
|
||||||
|
data-focus-key="library-detail-${index}"
|
||||||
|
>${escapeHtml(label)}</button>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderSortPanel = (query) => `
|
||||||
|
<div class="library-sort-backdrop" data-action="close-sort"></div>
|
||||||
|
<section class="library-sort-card">
|
||||||
|
<p class="library-section-kicker">Sort Library</p>
|
||||||
|
<h2>Filter / Sort</h2>
|
||||||
|
<p>Use Y to close. These options immediately reorder the visible grid.</p>
|
||||||
|
<div class="library-sort-options">
|
||||||
|
${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("")}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
@@ -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];
|
||||||
|
};
|
||||||
@@ -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));
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user