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:
2026-05-16 20:45:54 +12:00
parent 9de7a338a4
commit a04ae7803b
14 changed files with 1944 additions and 581 deletions
+4 -1
View File
@@ -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
]) ])
+52 -1
View File
@@ -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}"))?
}
+1 -1
View File
@@ -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
View File
@@ -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());
}; };
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
} }
+250 -295
View File
@@ -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 launchFocusedItem = async () => {
const folders = ["C:/Games", "D:/Games"]; const item = findItem(runtime);
return folders; if (!item) return;
const result = await launchLibraryItem(item);
runtime.status = result?.launched ? "Launch requested" : "Details ready";
runtime.message = result?.message ?? `${item.title} is ready.`;
if (!item.installed) {
runtime.detailsOpen = true;
}
renderLibrary();
}; };
const sourceLabel = (source) => { const openDetails = () => {
const labels = { if (!runtime.focusedId) return;
steam: "Steam", runtime.detailsOpen = true;
epic: "Epic Games", runtime.sortOpen = false;
gog: "GOG", renderLibrary("library-detail-0");
local: "Local",
unknown: "Unknown",
};
return labels[source] ?? source;
}; };
const statusLabel = (status) => { const closePanels = () => {
const labels = { runtime.detailsOpen = false;
pending: "Pending metadata", runtime.sortOpen = false;
matched: "Matched", renderLibrary();
needs_review: "Needs review",
manual: "Manual",
};
return labels[status] ?? "Pending metadata";
}; };
const kindLabel = (kind) => { const toggleSortPanel = () => {
const labels = { runtime.sortOpen = !runtime.sortOpen;
game: "Game", runtime.detailsOpen = false;
tool: "Tool", renderLibrary(runtime.sortOpen ? `library-sort-${runtime.query.sortBy}` : focusKeyForRuntime(runtime));
software: "Software",
unknown: "Unknown",
};
return labels[kind] ?? "Game";
}; };
const gameTitle = (game) => game.userTitle || game.title || "Unknown App"; const applyElementAction = async (element) => {
if (!element) return;
const { action } = element.dataset;
const escapeHtml = (value) => if (action === "scan") return scanLibrary();
String(value ?? "") if (action === "refresh") return refreshLibrary();
.replaceAll("&", "&amp;") if (action === "category") runtime.query.category = element.dataset.category;
.replaceAll("<", "&lt;") if (action === "genre") runtime.query.genre = element.dataset.genre;
.replaceAll(">", "&gt;") if (action === "platform") runtime.query.platform = element.dataset.platform;
.replaceAll('"', "&quot;") if (action === "toggle-installed") runtime.query.installedOnly = !runtime.query.installedOnly;
.replaceAll("'", "&#039;"); if (action === "cycle-play-state") runtime.query.playState = cycleOption(PLAY_STATES, runtime.query.playState, 1);
if (action === "toggle-coop") runtime.query.coOpOnly = !runtime.query.coOpOnly;
if (action === "toggle-achievements") runtime.query.achievementsOnly = !runtime.query.achievementsOnly;
if (action === "sort") runtime.query.sortBy = element.dataset.sort;
const imageUrlForGame = (game, convertFileSrc) => { if (action === "card") {
const imagePath = game.coverImage || game.heroImage || game.iconImage; runtime.focusedId = element.dataset.itemId;
return imagePath && convertFileSrc ? convertFileSrc(imagePath) : ""; return launchFocusedItem();
}
if (action === "launch") return launchFocusedItem();
if (action === "install") {
runtime.status = "Install queued";
runtime.message = "Store/provider install flows will connect here when integrations are added.";
}
if (action === "uninstall") {
runtime.status = "Uninstall placeholder";
runtime.message = "Uninstall will be routed through source-specific providers later.";
}
if (action === "hide") {
const item = findItem(runtime);
if (item) {
item.hidden = true;
await hideLibraryItem(item);
runtime.detailsOpen = false;
runtime.status = "Hidden";
runtime.message = `${item.title} is hidden from the visible library.`;
}
}
if (action === "open-folder") {
runtime.status = "Open folder placeholder";
runtime.message = "Folder opening will use the desktop bridge for installed apps.";
}
if (action === "close-details" || action === "close-sort") {
return closePanels();
}
renderLibrary(element.dataset.focusKey);
}; };
const TOOL_CATEGORY_LABELS = new Set([ const cycleCategory = (direction) => {
"animation & modeling", runtime.query.category = cycleOption(
"audio production", LIBRARY_CATEGORIES.map((category) => category.id),
"design & illustration", runtime.query.category,
"education", direction,
"game development", );
"photo editing", renderLibrary(`library-category-${runtime.query.category}`);
"software training",
"utilities",
"video production",
"web publishing",
"includes level editor",
]);
const isTool = (game) => game.appKind === "tool" || game.appKind === "software";
const toolCategoryForGame = (game) => {
const labels = [
...(Array.isArray(game.genres) ? game.genres : []),
...(Array.isArray(game.steamCategories) ? game.steamCategories : []),
].filter(Boolean);
const toolLabel = labels.find((label) => TOOL_CATEGORY_LABELS.has(label.toLowerCase()));
return toolLabel || labels[0] || "Other Tools";
}; };
const renderGameCard = (game, index, convertFileSrc) => { const cycleGenre = (direction) => {
const title = gameTitle(game); runtime.query.genre = cycleOption(GENRE_FILTERS, runtime.query.genre, direction);
const imageUrl = imageUrlForGame(game, convertFileSrc); const genreIndex = GENRE_FILTERS.findIndex((genre) => genre === runtime.query.genre);
const hasImage = Boolean(imageUrl); renderLibrary(`library-genre-${genreIndex}`);
const metaParts = [
sourceLabel(game.platformSource),
kindLabel(game.appKind),
game.developer,
Array.isArray(game.genres) ? game.genres.slice(0, 2).join(", ") : "",
].filter(Boolean);
return `
<button
class="library-card focusable"
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) => ` return {
<li class="library-provider-row">
<span>${sourceLabel(provider.source)}</span>
<strong>${provider.error ? "Error" : `${provider.discovered} found`}</strong>
</li>
`;
const renderLibrarySection = (title, eyebrow, games, startIndex, convertFileSrc) => `
<section class="library-section">
<div class="library-section-heading">
<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 = `
<section class="view library-view" data-view="library">
<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 }) => ({
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));
}
}; };
+124
View File
@@ -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,
},
});
}
};
+324
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
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>
`;
+14
View File
@@ -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];
};
+83
View File
@@ -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));
};
+276
View File
@@ -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,
};
};