mirror of
https://github.com/drakmor/ShadowMountPlus.git
synced 2026-10-21 22:22:28 -05:00
1637 lines
71 KiB
HTML
1637 lines
71 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="color-scheme" content="dark">
|
||
<title>ShadowMount Library</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: #080b12;
|
||
--surface: #111722;
|
||
--surface-2: #171f2d;
|
||
--line: #263145;
|
||
--text: #f4f7fb;
|
||
--muted: #95a2b8;
|
||
--blue: #55a7ff;
|
||
--blue-strong: #147ee8;
|
||
--green: #52d39a;
|
||
--amber: #f2bd5d;
|
||
--red: #ff6c79;
|
||
--radius: 14px;
|
||
--shadow: 0 18px 50px rgba(0, 0, 0, .34);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
body {
|
||
margin: 0;
|
||
min-width: 320px;
|
||
min-height: 100vh;
|
||
background:
|
||
radial-gradient(circle at 15% -10%, rgba(20, 126, 232, .17), transparent 34rem),
|
||
var(--bg);
|
||
color: var(--text);
|
||
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||
}
|
||
|
||
button, input, select { font: inherit; }
|
||
button, select { cursor: pointer; }
|
||
|
||
button:focus-visible, input:focus-visible, select:focus-visible {
|
||
outline: 2px solid var(--blue);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.shell { width: min(1500px, 100%); margin: 0 auto; padding: 22px; }
|
||
|
||
.topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 18px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.brand { display: flex; align-items: center; gap: 13px; }
|
||
|
||
.brand-mark {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 42px;
|
||
height: 42px;
|
||
border: 1px solid rgba(85, 167, 255, .45);
|
||
border-radius: 12px;
|
||
background: linear-gradient(145deg, #147ee8, #74baff);
|
||
box-shadow: 0 8px 24px rgba(20, 126, 232, .25);
|
||
font-size: 20px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
h1, h2, h3, p { margin: 0; }
|
||
h1 { font-size: 20px; letter-spacing: -.02em; }
|
||
h2 { font-size: 17px; }
|
||
.subtle { color: var(--muted); font-size: 13px; }
|
||
|
||
.connection {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.topbar-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.language-select {
|
||
min-width: 110px;
|
||
min-height: 36px;
|
||
}
|
||
|
||
.connection-dot {
|
||
width: 9px;
|
||
height: 9px;
|
||
border-radius: 50%;
|
||
background: var(--amber);
|
||
box-shadow: 0 0 0 4px rgba(242, 189, 93, .12);
|
||
}
|
||
|
||
.connection.online .connection-dot {
|
||
background: var(--green);
|
||
box-shadow: 0 0 0 4px rgba(82, 211, 154, .12);
|
||
}
|
||
|
||
.connection.offline .connection-dot {
|
||
background: var(--red);
|
||
box-shadow: 0 0 0 4px rgba(255, 108, 121, .12);
|
||
}
|
||
|
||
.toolbar, .panel, .stat, .game-card, .modal-card {
|
||
border: 1px solid var(--line);
|
||
background: rgba(17, 23, 34, .96);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.toolbar {
|
||
display: grid;
|
||
grid-template-columns: minmax(220px, 1fr) 150px auto auto auto;
|
||
gap: 10px;
|
||
padding: 13px;
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
input, select {
|
||
min-height: 40px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: #0c111a;
|
||
color: var(--text);
|
||
padding: 0 12px;
|
||
}
|
||
|
||
input::placeholder { color: #6f7c92; }
|
||
|
||
.check {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-height: 40px;
|
||
color: var(--muted);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.check input { min-height: 0; width: 17px; height: 17px; accent-color: var(--blue); }
|
||
|
||
.btn {
|
||
min-height: 40px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: var(--surface-2);
|
||
color: var(--text);
|
||
padding: 0 14px;
|
||
transition: border-color .15s ease, background .15s ease, transform .15s ease;
|
||
}
|
||
|
||
.btn:hover { border-color: #3b4a63; background: #1c2637; }
|
||
.btn:active { transform: translateY(1px); }
|
||
.btn:disabled { cursor: wait; opacity: .55; }
|
||
.btn.primary { border-color: var(--blue-strong); background: var(--blue-strong); }
|
||
.btn.primary:hover { background: #238cf3; }
|
||
.btn.danger { border-color: rgba(255, 108, 121, .45); color: #ff9aa3; }
|
||
.btn.small { min-height: 34px; padding: 0 11px; font-size: 13px; }
|
||
|
||
.stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin: 14px 0;
|
||
}
|
||
|
||
.stat { padding: 15px 17px; border-radius: var(--radius); box-shadow: none; }
|
||
.stat-value { display: block; margin-top: 3px; font-size: 25px; font-weight: 750; }
|
||
|
||
.layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 310px;
|
||
align-items: start;
|
||
gap: 14px;
|
||
}
|
||
|
||
.section-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin: 4px 0 11px;
|
||
}
|
||
|
||
.games {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.game-card {
|
||
display: grid;
|
||
grid-template-columns: 88px minmax(0, 1fr);
|
||
align-items: start;
|
||
gap: 14px;
|
||
min-height: 116px;
|
||
padding: 13px;
|
||
border-radius: var(--radius);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.cover {
|
||
position: relative;
|
||
width: 88px;
|
||
aspect-ratio: 1;
|
||
overflow: hidden;
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
background: linear-gradient(145deg, #1d2c43, #0c111a);
|
||
}
|
||
|
||
.cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||
.cover-fallback { display: grid; place-items: center; width: 100%; height: 100%; color: var(--blue); font-weight: 800; }
|
||
.game-main { min-width: 0; }
|
||
.game-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 10px; }
|
||
.game-identity { min-width: 0; }
|
||
.game-title { overflow: hidden; font-size: 15px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||
.game-id { color: var(--muted); font: 12px/1.5 ui-monospace, "SFMono-Regular", Consolas, monospace; }
|
||
.game-card-size { color: var(--blue); font-size: 14px; font-weight: 750; white-space: nowrap; }
|
||
|
||
.badges { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
|
||
.badge { border: 1px solid var(--line); border-radius: 999px; color: var(--muted); padding: 2px 7px; font-size: 11px; text-transform: uppercase; }
|
||
.badge.live { border-color: rgba(82, 211, 154, .35); color: var(--green); }
|
||
.badge.image { border-color: rgba(85, 167, 255, .38); color: #8cc4ff; }
|
||
|
||
.game-path { overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.game-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||
|
||
.panel { padding: 16px; border-radius: var(--radius); box-shadow: none; }
|
||
.sidebar { display: grid; gap: 14px; position: sticky; top: 14px; }
|
||
.storage-list { display: grid; gap: 9px; margin-top: 12px; }
|
||
|
||
.storage-row {
|
||
min-width: 0;
|
||
padding: 10px;
|
||
border-radius: 10px;
|
||
background: #0c111a;
|
||
}
|
||
|
||
.storage-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||
.storage-path { overflow: hidden; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||
.storage-meta { overflow: hidden; margin-top: 3px; color: var(--muted); font: 11px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||
.storage-space { display: flex; justify-content: space-between; gap: 8px; margin-top: 7px; color: var(--muted); font-size: 11px; }
|
||
|
||
.storage-meter {
|
||
height: 6px;
|
||
margin-top: 6px;
|
||
overflow: hidden;
|
||
border-radius: 999px;
|
||
background: #202a3a;
|
||
}
|
||
|
||
.storage-fill { height: 100%; border-radius: inherit; background: var(--blue); }
|
||
.storage-fill.warning { background: var(--amber); }
|
||
.storage-fill.danger { background: var(--red); }
|
||
.transfer-details { display: grid; gap: 10px; margin-top: 12px; }
|
||
.transfer-details[hidden] { display: none; }
|
||
.transfer-route { overflow-wrap: anywhere; color: var(--muted); font: 11px/1.45 ui-monospace, monospace; }
|
||
.transfer-meter { height: 9px; margin-top: 0; }
|
||
.transfer-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.transfer-stat { padding: 8px 9px; border-radius: 9px; background: #0c111a; }
|
||
.transfer-stat strong { display: block; margin-top: 2px; font-size: 13px; }
|
||
.transfer-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||
.manual-form { display: flex; gap: 7px; margin-top: 12px; }
|
||
.manual-form input { min-width: 0; width: 100%; }
|
||
.manual-list, .activity-list { display: grid; gap: 7px; margin-top: 12px; }
|
||
|
||
.manual-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 7px;
|
||
padding: 8px 9px;
|
||
border-radius: 9px;
|
||
background: #0c111a;
|
||
}
|
||
|
||
.manual-path { overflow: hidden; color: var(--muted); font: 11px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||
.settings-form { display: grid; gap: 10px; margin-top: 12px; }
|
||
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 12px; }
|
||
.setting-row select { width: 112px; }
|
||
.settings-actions { display: flex; flex-wrap: wrap; gap: 7px; }
|
||
.subheading { margin-top: 17px; font-size: 14px; }
|
||
.log-view { min-height: 460px; max-height: 76vh; margin: 0; overflow: auto; padding: 20px; background: #080c12; color: #c8d7ea; font: 14px/1.55 ui-monospace, "SFMono-Regular", Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
.activity-item { border-left: 2px solid var(--line); padding-left: 9px; color: var(--muted); font-size: 12px; }
|
||
.activity-item.ok { border-color: var(--green); }
|
||
.activity-item.error { border-color: var(--red); color: #ff9aa3; }
|
||
|
||
.empty {
|
||
grid-column: 1 / -1;
|
||
padding: 46px 20px;
|
||
border: 1px dashed var(--line);
|
||
border-radius: var(--radius);
|
||
color: var(--muted);
|
||
text-align: center;
|
||
}
|
||
|
||
.modal {
|
||
position: fixed;
|
||
z-index: 20;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 20px;
|
||
background: rgba(2, 5, 10, .78);
|
||
}
|
||
|
||
.modal[hidden] { display: none; }
|
||
.modal-card { width: min(720px, 100%); max-height: 90vh; overflow: auto; border-radius: 18px; }
|
||
.modal-card.log-card { width: min(1440px, 96vw); }
|
||
.modal-head { display: flex; align-items: start; justify-content: space-between; gap: 20px; padding: 18px; border-bottom: 1px solid var(--line); }
|
||
.close { width: 36px; min-height: 36px; padding: 0; font-size: 22px; }
|
||
.details { display: grid; grid-template-columns: 168px minmax(0, 1fr); align-items: start; gap: 18px; padding: 18px; }
|
||
.details .cover { width: 168px; }
|
||
.detail-grid { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 7px 12px; margin-top: 13px; font-size: 13px; }
|
||
.detail-key { color: var(--muted); }
|
||
.detail-value { min-width: 0; overflow-wrap: anywhere; }
|
||
.modal-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 18px 18px; }
|
||
|
||
.toast {
|
||
position: fixed;
|
||
right: 20px;
|
||
bottom: 20px;
|
||
z-index: 30;
|
||
max-width: min(420px, calc(100vw - 40px));
|
||
border: 1px solid var(--line);
|
||
border-radius: 11px;
|
||
background: #172131;
|
||
box-shadow: var(--shadow);
|
||
padding: 12px 15px;
|
||
}
|
||
|
||
.toast[hidden] { display: none; }
|
||
.toast.error { border-color: rgba(255, 108, 121, .5); color: #ffabb2; }
|
||
|
||
@media (max-width: 980px) {
|
||
.toolbar { grid-template-columns: minmax(180px, 1fr) 150px auto; }
|
||
.layout { grid-template-columns: 1fr; }
|
||
.sidebar { position: static; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
}
|
||
|
||
@media (max-width: 660px) {
|
||
.modal { padding: 10px; }
|
||
.modal-card.log-card { width: 100%; }
|
||
.log-view { min-height: 60vh; max-height: 75vh; padding: 14px; font-size: 13px; }
|
||
.shell { padding: 14px; }
|
||
.topbar { flex-direction: column; align-items: stretch; }
|
||
.topbar-actions { justify-content: space-between; }
|
||
.toolbar { grid-template-columns: 1fr 1fr; }
|
||
.toolbar #search { grid-column: 1 / -1; }
|
||
.stats { grid-template-columns: 1fr 1fr; }
|
||
.games { grid-template-columns: 1fr; }
|
||
.sidebar { grid-template-columns: 1fr; }
|
||
.details { grid-template-columns: 1fr; }
|
||
.details .cover { width: 120px; }
|
||
.detail-grid { grid-template-columns: 105px minmax(0, 1fr); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="shell">
|
||
<header class="topbar">
|
||
<div class="brand">
|
||
<div class="brand-mark" aria-hidden="true">S</div>
|
||
<div>
|
||
<h1 data-i18n="pageTitle">ShadowMount Library</h1>
|
||
<p class="subtle" id="version" data-i18n="connecting">Connecting to API…</p>
|
||
</div>
|
||
</div>
|
||
<div class="topbar-actions">
|
||
<div class="connection" id="connection" role="status">
|
||
<span class="connection-dot"></span>
|
||
<span id="connectionText" data-i18n="checking">Checking</span>
|
||
</div>
|
||
<select class="language-select" id="language" aria-label="Language" data-i18n-aria-label="language">
|
||
<option value="en">English</option>
|
||
<option value="ru">Русский</option>
|
||
</select>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="toolbar" aria-label="Library controls" data-i18n-aria-label="libraryControls">
|
||
<input id="search" type="search" autocomplete="off" placeholder="Name, Title ID, Content ID, or path" data-i18n-placeholder="searchPlaceholder">
|
||
<select id="filter" aria-label="Library filter" data-i18n-aria-label="libraryFilter">
|
||
<option value="all" data-i18n="allGames">All games</option>
|
||
<option value="folder" data-i18n="folders">Folders</option>
|
||
<option value="image" data-i18n="images">Images</option>
|
||
<option value="ps4">PS4</option>
|
||
<option value="ps5">PS5</option>
|
||
</select>
|
||
<label class="check"><input id="includeSize" type="checkbox"> <span data-i18n="calculateSize">Calculate size</span></label>
|
||
<button class="btn" id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
|
||
<button class="btn primary" id="scanBtn" type="button" data-i18n="scan">Scan</button>
|
||
</section>
|
||
|
||
<section class="stats" aria-label="Library summary" data-i18n-aria-label="librarySummary">
|
||
<div class="stat"><span class="subtle" data-i18n="totalGames">Total games</span><strong class="stat-value" id="totalGames">—</strong></div>
|
||
<div class="stat"><span class="subtle" data-i18n="images">Images</span><strong class="stat-value" id="imageGames">—</strong></div>
|
||
<div class="stat"><span class="subtle" data-i18n="folders">Folders</span><strong class="stat-value" id="folderGames">—</strong></div>
|
||
<div class="stat"><span class="subtle" data-i18n="mounted">Mounted</span><strong class="stat-value" id="mountedGames">—</strong></div>
|
||
</section>
|
||
|
||
<div class="layout">
|
||
<section aria-labelledby="gamesHeading">
|
||
<div class="section-head">
|
||
<h2 id="gamesHeading" data-i18n="library">Library</h2>
|
||
<span class="subtle" id="visibleCount"></span>
|
||
</div>
|
||
<div class="games" id="games" aria-live="polite">
|
||
<div class="empty" data-i18n="loadingLibrary">Loading library…</div>
|
||
</div>
|
||
</section>
|
||
|
||
<aside class="sidebar">
|
||
<section class="panel" aria-labelledby="storageHeading">
|
||
<h2 id="storageHeading" data-i18n="storage">Storage</h2>
|
||
<p class="subtle" data-i18n="storageDescription">Free space at available mount points</p>
|
||
<div class="storage-list" id="storageList" aria-live="polite"><span class="subtle" data-i18n="loading">Loading…</span></div>
|
||
</section>
|
||
|
||
<section class="panel" aria-labelledby="transferHeading">
|
||
<div class="storage-head">
|
||
<h2 id="transferHeading" data-i18n="dataOperations">Data operations</h2>
|
||
<span class="badge" id="transferState" data-i18n="checking">Checking</span>
|
||
</div>
|
||
<p class="subtle" id="transferIdle" data-i18n="gettingOperationStatus">Getting operation status…</p>
|
||
<div class="transfer-details" id="transferDetails" hidden>
|
||
<strong id="transferSummary"></strong>
|
||
<div class="transfer-route" id="transferRoute"></div>
|
||
<div class="storage-meter transfer-meter" id="transferMeter" role="progressbar" aria-label="Operation progress" data-i18n-aria-label="operationProgress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||
<div class="storage-fill" id="transferFill"></div>
|
||
</div>
|
||
<div class="transfer-stats">
|
||
<div class="transfer-stat"><span class="subtle" data-i18n="size">Size</span><strong id="transferBytes">—</strong></div>
|
||
<div class="transfer-stat"><span class="subtle" data-i18n="files">Files</span><strong id="transferFiles">—</strong></div>
|
||
<div class="transfer-stat"><span class="subtle" data-i18n="speed">Speed</span><strong id="transferSpeed">—</strong></div>
|
||
<div class="transfer-stat"><span class="subtle" data-i18n="time">Time</span><strong id="transferElapsed">—</strong></div>
|
||
</div>
|
||
<div class="transfer-actions">
|
||
<strong id="transferPercent">0%</strong>
|
||
<button class="btn small danger" id="cancelTransfer" type="button" data-i18n="cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel" aria-labelledby="settingsHeading">
|
||
<h2 id="settingsHeading" data-i18n="settings">Settings</h2>
|
||
<form class="settings-form" id="settingsForm">
|
||
<label class="check"><input id="debugEnabled" type="checkbox"> <span data-i18n="debugLog">Debug log</span></label>
|
||
<label class="check"><input id="quietMode" type="checkbox"> <span data-i18n="quietMode">Quiet mode</span></label>
|
||
<label class="check"><input id="updateEmulators" type="checkbox"> <span data-i18n="autoUpdateEmulator">Auto-update emulator</span></label>
|
||
<label class="check"><input id="allowLanAccess" type="checkbox"> <span data-i18n="allowLanAccess">Allow local network access</span></label>
|
||
<label class="setting-row"><span data-i18n="fanTemperature">Fan target</span><select id="fanTemperature"><option value="0" data-i18n="systemControlled">System controlled</option></select></label>
|
||
<div class="settings-actions">
|
||
<button class="btn small primary" type="submit" data-i18n="saveSettings">Save settings</button>
|
||
<button class="btn small" id="viewLog" type="button" data-i18n="viewLog">View debug log</button>
|
||
<button class="btn small" id="viewKernelLog" type="button" data-i18n="viewKernelLog">View kernel log</button>
|
||
</div>
|
||
</form>
|
||
<h3 class="subheading" data-i18n="scanPaths">Scan paths</h3>
|
||
<p class="subtle" data-i18n="scanPathsDescription">Custom recursive roots; defaults stay hidden</p>
|
||
<form class="manual-form" id="scanPathForm">
|
||
<input id="scanPath" required placeholder="/mnt/usb0/games">
|
||
<button class="btn small" type="submit" data-i18n="add">Add</button>
|
||
</form>
|
||
<div class="manual-list" id="scanPathList"><span class="subtle" data-i18n="loading">Loading…</span></div>
|
||
<h3 class="subheading" id="manualHeading">manual.lst</h3>
|
||
<p class="subtle" data-i18n="manualDescription">Direct paths to individual game folders or images</p>
|
||
<form class="manual-form" id="manualForm">
|
||
<input id="manualPath" required placeholder="/mnt/usb0/games/PPSA12345">
|
||
<button class="btn small" type="submit" data-i18n="add">Add</button>
|
||
</form>
|
||
<div class="manual-list" id="manualList"><span class="subtle" data-i18n="loading">Loading…</span></div>
|
||
</section>
|
||
|
||
<section class="panel" aria-labelledby="scanHeading">
|
||
<h2 id="scanHeading" data-i18n="scanOptions">Scan options</h2>
|
||
<label class="check"><input id="resetAttempts" type="checkbox"> <span data-i18n="resetAttempts">Reset attempt counters</span></label>
|
||
<p class="subtle" data-i18n="resetAttemptsDescription">This option applies only to the next forced scan.</p>
|
||
</section>
|
||
|
||
<section class="panel" aria-labelledby="activityHeading">
|
||
<h2 id="activityHeading" data-i18n="recentOperations">Recent operations</h2>
|
||
<div class="activity-list" id="activity"><span class="subtle" data-i18n="noOperations">No operations yet</span></div>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
</main>
|
||
|
||
<div class="modal" id="modal" hidden role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||
<div class="modal-card">
|
||
<div class="modal-head">
|
||
<div>
|
||
<h2 id="modalTitle" data-i18n="gameInformation">Game information</h2>
|
||
<p class="subtle" id="modalSubtitle"></p>
|
||
</div>
|
||
<button class="btn close" id="closeModal" type="button" aria-label="Close" data-i18n-aria-label="close">×</button>
|
||
</div>
|
||
<div class="details" id="details"></div>
|
||
<div class="modal-actions" id="modalActions"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal" id="logModal" hidden role="dialog" aria-modal="true" aria-labelledby="logTitle">
|
||
<div class="modal-card log-card">
|
||
<div class="modal-head">
|
||
<div>
|
||
<h2 id="logTitle" data-i18n="debugLogTitle">Debug log</h2>
|
||
<p class="subtle" id="logSubtitle"></p>
|
||
</div>
|
||
<div class="settings-actions">
|
||
<button class="btn small" id="refreshLog" type="button" data-i18n="refresh">Refresh</button>
|
||
<button class="btn close" id="closeLog" type="button" aria-label="Close" data-i18n-aria-label="close">×</button>
|
||
</div>
|
||
</div>
|
||
<pre class="log-view" id="logContent"></pre>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast" hidden role="status"></div>
|
||
|
||
<script>
|
||
(() => {
|
||
"use strict";
|
||
|
||
const API = "/api/v1";
|
||
const state = {
|
||
games: [],
|
||
selected: null,
|
||
activity: [],
|
||
busy: false,
|
||
storageJob: null,
|
||
storagePollTimer: 0,
|
||
reportedStorageJob: "",
|
||
scanPaths: [],
|
||
apiVersion: null,
|
||
connectionStatus: "",
|
||
connectionKey: "checking",
|
||
logRoute: "/debug-log",
|
||
logTitleKey: "debugLogTitle",
|
||
logTriggerId: "viewLog"
|
||
};
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const translations = {
|
||
en: {
|
||
pageTitle: "ShadowMount Library",
|
||
connecting: "Connecting to API…",
|
||
checking: "Checking",
|
||
libraryControls: "Library controls",
|
||
searchPlaceholder: "Name, Title ID, Content ID, or path",
|
||
libraryFilter: "Library filter",
|
||
allGames: "All games",
|
||
folders: "Folders",
|
||
images: "Images",
|
||
language: "Language",
|
||
calculateSize: "Calculate size",
|
||
refresh: "Refresh",
|
||
scan: "Scan",
|
||
librarySummary: "Library summary",
|
||
totalGames: "Total games",
|
||
mounted: "Mounted",
|
||
library: "Library",
|
||
loadingLibrary: "Loading library…",
|
||
storage: "Storage",
|
||
storageDescription: "Free space at available mount points",
|
||
loading: "Loading…",
|
||
dataOperations: "Data operations",
|
||
gettingOperationStatus: "Getting operation status…",
|
||
operationProgress: "Operation progress",
|
||
size: "Size",
|
||
files: "Files",
|
||
speed: "Speed",
|
||
perSecond: "/s",
|
||
time: "Time",
|
||
cancel: "Cancel",
|
||
manualDescription: "Direct paths to individual game folders or images",
|
||
add: "Add",
|
||
settings: "Settings",
|
||
debugLog: "Debug log",
|
||
quietMode: "Quiet mode",
|
||
autoUpdateEmulator: "Auto-update emulator",
|
||
allowLanAccess: "Allow local network access",
|
||
fanTemperature: "Fan target",
|
||
systemControlled: "System controlled",
|
||
saveSettings: "Save settings",
|
||
settingsSaved: "Settings saved; runtime reload queued",
|
||
viewLog: "View debug log",
|
||
viewKernelLog: "View kernel log",
|
||
debugLogTitle: "Debug log",
|
||
kernelLogTitle: "Kernel log",
|
||
logBytes: "Showing {shown} of {total}",
|
||
scanPaths: "Scan paths",
|
||
scanPathsDescription: "Custom recursive roots; an empty list uses built-in defaults",
|
||
scanPathAdded: "Scan path added locally; save settings to apply",
|
||
scanPathRemoved: "Scan path removed locally; save settings to apply",
|
||
invalidAbsolutePath: "Enter a non-root absolute path",
|
||
scanOptions: "Scan options",
|
||
resetAttempts: "Reset attempt counters",
|
||
resetAttemptsDescription: "This option applies only to the next forced scan.",
|
||
recentOperations: "Recent operations",
|
||
noOperations: "No operations yet",
|
||
gameInformation: "Game information",
|
||
close: "Close",
|
||
coverAlt: "Cover for {title}",
|
||
unknown: "unknown",
|
||
folder: "folder",
|
||
image: "image",
|
||
sourceUnavailable: "Source unavailable",
|
||
details: "Details",
|
||
connect: "Mount",
|
||
disconnect: "Unmount",
|
||
nothingFound: "Nothing found",
|
||
libraryEmpty: "The library is empty",
|
||
shown: "Shown: {count}",
|
||
jobIdle: "No job",
|
||
jobPreparing: "Preparing",
|
||
jobMeasuring: "Measuring",
|
||
jobDeleting: "Deleting",
|
||
jobFinalizing: "Finalizing",
|
||
jobCompleted: "Completed",
|
||
jobFailed: "Failed",
|
||
jobCancelled: "Cancelled",
|
||
operationCopy: "Copying",
|
||
operationMove: "Moving",
|
||
operationDelete: "Deleting",
|
||
operationUnpack: "Unpacking",
|
||
noActiveOperations: "No active operations",
|
||
calculating: "Calculating…",
|
||
cancelling: "Cancelling…",
|
||
operationCompleted: "{operation} {title} completed",
|
||
operationCompletedShort: "{operation} completed",
|
||
operationCancelled: "{operation} {title} cancelled",
|
||
operationCancelledShort: "{operation} cancelled",
|
||
statusError: "error {status}",
|
||
operationError: "{operation} {title}: {error}",
|
||
unavailable: "Unavailable",
|
||
cancelRequested: "Cancellation requested for job {id}",
|
||
storageNotFound: "No storage found",
|
||
freeSpace: "Free {size}",
|
||
totalSpace: "of {size}",
|
||
storageUsed: "Used space at {path}",
|
||
connected: "PS5 connected",
|
||
disconnected: "No connection",
|
||
apiError: "API error: {error}",
|
||
listEmpty: "The list is empty",
|
||
remove: "Remove",
|
||
manualAdded: "Source added to manual.lst",
|
||
manualExists: "Source is already in manual.lst",
|
||
removeManualConfirm: "Remove this source from manual.lst?\n\n{path}",
|
||
manualRemoved: "Source removed from manual.lst",
|
||
scanQueuedReset: "Scan queued with attempt counters reset",
|
||
scanQueued: "Scan queued",
|
||
scanStarted: "Scan started",
|
||
mountedTitle: "Mounted {title}{mode}",
|
||
unmountedTitle: "Unmounted {title}",
|
||
source: "Source",
|
||
runtimePath: "Runtime path",
|
||
sizeError: "Error {status}",
|
||
installed: "Installed",
|
||
lastLaunch: "Last launch",
|
||
mountReadOnly: "Mount R/O",
|
||
mountReadWrite: "Mount R/W",
|
||
copy: "Copy",
|
||
move: "Move",
|
||
unpack: "Unpack",
|
||
deleteSource: "Delete source",
|
||
storageBusyHint: "Wait for the current storage operation to finish",
|
||
uninstall: "Uninstall",
|
||
loadingSource: "Calculating source size…",
|
||
storageBusy: "A storage operation is already running",
|
||
copyDestination: "Destination folder for copying:",
|
||
moveDestination: "Destination folder for moving:",
|
||
unpackDestination: "Destination folder for unpacking. A {title} subfolder will be created:",
|
||
unpackDeleteConfirm: "Delete the source image only after unpacking completes successfully?\n\nCancel keeps the image.",
|
||
jobStarted: "{operation} {title} started as job {id}",
|
||
operationStarted: "Operation started",
|
||
uninstallConfirm: "Request uninstall of {game}?\n\nThe source image or folder will not be deleted.",
|
||
uninstallAccepted: "Uninstall request for {title} accepted",
|
||
deleteConfirm: "Permanently delete the physical source for {game}?\n\nThis cannot be undone."
|
||
},
|
||
ru: {
|
||
pageTitle: "Библиотека ShadowMount",
|
||
connecting: "Подключение к API…",
|
||
checking: "Проверка",
|
||
libraryControls: "Управление библиотекой",
|
||
searchPlaceholder: "Название, Title ID, Content ID или путь",
|
||
libraryFilter: "Фильтр библиотеки",
|
||
allGames: "Все игры",
|
||
folders: "Папки",
|
||
images: "Образы",
|
||
language: "Язык",
|
||
calculateSize: "Подсчитать размер",
|
||
refresh: "Обновить",
|
||
scan: "Сканировать",
|
||
librarySummary: "Сводка библиотеки",
|
||
totalGames: "Всего игр",
|
||
mounted: "Смонтировано",
|
||
library: "Библиотека",
|
||
loadingLibrary: "Загрузка библиотеки…",
|
||
storage: "Хранилища",
|
||
storageDescription: "Свободное место в доступных точках монтирования",
|
||
loading: "Загрузка…",
|
||
dataOperations: "Операции с данными",
|
||
gettingOperationStatus: "Получение статуса операции…",
|
||
operationProgress: "Прогресс операции",
|
||
size: "Размер",
|
||
files: "Файлы",
|
||
speed: "Скорость",
|
||
perSecond: "/с",
|
||
time: "Время",
|
||
cancel: "Отменить",
|
||
manualDescription: "Прямые пути к отдельным папкам игр или образам",
|
||
add: "Добавить",
|
||
settings: "Настройки",
|
||
debugLog: "Журнал отладки",
|
||
quietMode: "Тихий режим",
|
||
autoUpdateEmulator: "Автообновление эмулятора",
|
||
allowLanAccess: "Разрешить доступ из локальной сети",
|
||
fanTemperature: "Целевая температура",
|
||
systemControlled: "Управление системой",
|
||
saveSettings: "Сохранить настройки",
|
||
settingsSaved: "Настройки сохранены; обновление конфигурации поставлено в очередь",
|
||
viewLog: "Просмотреть журнал",
|
||
viewKernelLog: "Просмотреть журнал ядра",
|
||
debugLogTitle: "Журнал отладки",
|
||
kernelLogTitle: "Журнал ядра",
|
||
logBytes: "Показано {shown} из {total}",
|
||
scanPaths: "Точки сканирования",
|
||
scanPathsDescription: "Пользовательские корни; при пустом списке используются встроенные пути",
|
||
scanPathAdded: "Точка добавлена локально; сохраните настройки для применения",
|
||
scanPathRemoved: "Точка удалена локально; сохраните настройки для применения",
|
||
invalidAbsolutePath: "Введите абсолютный путь, отличный от корня",
|
||
scanOptions: "Параметры сканирования",
|
||
resetAttempts: "Сбросить счётчики попыток",
|
||
resetAttemptsDescription: "Параметр применяется только к следующему принудительному сканированию.",
|
||
recentOperations: "Последние операции",
|
||
noOperations: "Операций пока нет",
|
||
gameInformation: "Информация об игре",
|
||
close: "Закрыть",
|
||
coverAlt: "Обложка {title}",
|
||
unknown: "неизвестно",
|
||
folder: "папка",
|
||
image: "образ",
|
||
sourceUnavailable: "Источник недоступен",
|
||
details: "Подробнее",
|
||
connect: "Подключить",
|
||
disconnect: "Отключить",
|
||
nothingFound: "Ничего не найдено",
|
||
libraryEmpty: "Библиотека пуста",
|
||
shown: "Показано: {count}",
|
||
jobIdle: "Нет задачи",
|
||
jobPreparing: "Подготовка",
|
||
jobMeasuring: "Подсчёт",
|
||
jobDeleting: "Удаление",
|
||
jobFinalizing: "Завершение",
|
||
jobCompleted: "Готово",
|
||
jobFailed: "Ошибка",
|
||
jobCancelled: "Отменено",
|
||
operationCopy: "Копирование",
|
||
operationMove: "Перемещение",
|
||
operationDelete: "Удаление",
|
||
operationUnpack: "Распаковка",
|
||
noActiveOperations: "Активных операций нет",
|
||
calculating: "Подсчёт…",
|
||
cancelling: "Отмена…",
|
||
operationCompleted: "{operation} {title} завершено",
|
||
operationCompletedShort: "{operation} завершено",
|
||
operationCancelled: "{operation} {title} отменено",
|
||
operationCancelledShort: "{operation} отменено",
|
||
statusError: "ошибка {status}",
|
||
operationError: "{operation} {title}: {error}",
|
||
unavailable: "Недоступно",
|
||
cancelRequested: "Запрошена отмена задачи {id}",
|
||
storageNotFound: "Хранилища не найдены",
|
||
freeSpace: "Свободно {size}",
|
||
totalSpace: "из {size}",
|
||
storageUsed: "Заполнено {path}",
|
||
connected: "PS5 подключена",
|
||
disconnected: "Нет связи",
|
||
apiError: "Ошибка API: {error}",
|
||
listEmpty: "Список пуст",
|
||
remove: "Удалить",
|
||
manualAdded: "Источник добавлен в manual.lst",
|
||
manualExists: "Источник уже есть в manual.lst",
|
||
removeManualConfirm: "Удалить источник из manual.lst?\n\n{path}",
|
||
manualRemoved: "Источник удалён из manual.lst",
|
||
scanQueuedReset: "Сканирование поставлено в очередь со сбросом попыток",
|
||
scanQueued: "Сканирование поставлено в очередь",
|
||
scanStarted: "Сканирование запущено",
|
||
mountedTitle: "Смонтировано {title}{mode}",
|
||
unmountedTitle: "Размонтировано {title}",
|
||
source: "Источник",
|
||
runtimePath: "Runtime path",
|
||
sizeError: "Ошибка {status}",
|
||
installed: "Установлена",
|
||
lastLaunch: "Последний запуск",
|
||
mountReadOnly: "Подключить R/O",
|
||
mountReadWrite: "Подключить R/W",
|
||
copy: "Копировать",
|
||
move: "Переместить",
|
||
unpack: "Распаковать",
|
||
deleteSource: "Удалить источник",
|
||
storageBusyHint: "Дождитесь завершения текущей операции с хранилищем",
|
||
uninstall: "Деинсталлировать",
|
||
loadingSource: "Подсчёт размера источника…",
|
||
storageBusy: "Уже выполняется операция с хранилищем",
|
||
copyDestination: "Папка назначения для копирования:",
|
||
moveDestination: "Папка назначения для перемещения:",
|
||
unpackDestination: "Папка назначения для распаковки. В ней будет создана подпапка {title}:",
|
||
unpackDeleteConfirm: "Удалить исходный образ только после успешного завершения распаковки?\n\nОтмена сохранит образ.",
|
||
jobStarted: "{operation} {title} запущено, задача {id}",
|
||
operationStarted: "Операция запущена",
|
||
uninstallConfirm: "Запросить деинсталляцию {game}?\n\nИсходный образ или папка не удаляются.",
|
||
uninstallAccepted: "Деинсталляция {title} принята",
|
||
deleteConfirm: "Удалить физический источник игры {game}?\n\nОперацию нельзя отменить."
|
||
}
|
||
};
|
||
const byteUnits = {
|
||
en: ["B", "KiB", "MiB", "GiB", "TiB"],
|
||
ru: ["Б", "КиБ", "МиБ", "ГиБ", "ТиБ"]
|
||
};
|
||
const supportedLanguages = Object.keys(translations);
|
||
let savedLanguage = null;
|
||
try { savedLanguage = localStorage.getItem("shadowmount.language"); } catch (_) { /* Storage may be disabled. */ }
|
||
let currentLanguage = supportedLanguages.includes(savedLanguage)
|
||
? savedLanguage
|
||
: String(navigator.language || "en").toLowerCase().startsWith("ru") ? "ru" : "en";
|
||
|
||
function t(key, values = {}) {
|
||
const template = translations[currentLanguage][key] || translations.en[key] || key;
|
||
return template.replace(/\{(\w+)\}/g, (match, name) =>
|
||
Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match
|
||
);
|
||
}
|
||
|
||
function applyLanguage(language, persist = false) {
|
||
currentLanguage = supportedLanguages.includes(language) ? language : "en";
|
||
document.documentElement.lang = currentLanguage;
|
||
document.title = t("pageTitle");
|
||
$("language").value = currentLanguage;
|
||
document.querySelectorAll("[data-i18n]").forEach((item) => {
|
||
item.textContent = t(item.dataset.i18n);
|
||
});
|
||
document.querySelectorAll("[data-i18n-placeholder]").forEach((item) => {
|
||
item.placeholder = t(item.dataset.i18nPlaceholder);
|
||
});
|
||
document.querySelectorAll("[data-i18n-aria-label]").forEach((item) => {
|
||
item.setAttribute("aria-label", t(item.dataset.i18nAriaLabel));
|
||
});
|
||
if (state.apiVersion) {
|
||
$("version").textContent = `ShadowMount ${state.apiVersion.shadowmount_version} · API v${state.apiVersion.api_version}`;
|
||
}
|
||
if (state.connectionStatus) setConnection(state.connectionStatus, state.connectionKey);
|
||
if (!$("logModal").hidden) $("logTitle").textContent = t(state.logTitleKey);
|
||
if (persist) {
|
||
try { localStorage.setItem("shadowmount.language", currentLanguage); } catch (_) { /* Keep the in-memory selection. */ }
|
||
}
|
||
}
|
||
|
||
function node(tag, className, text) {
|
||
const item = document.createElement(tag);
|
||
if (className) item.className = className;
|
||
if (text !== undefined) item.textContent = text;
|
||
return item;
|
||
}
|
||
|
||
function formatBytes(value) {
|
||
if (!Number.isFinite(value) || value < 0) return "—";
|
||
const units = byteUnits[currentLanguage];
|
||
if (value === 0) return `0 ${units[0]}`;
|
||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||
const precision = index < 2 ? 0 : 1;
|
||
return `${(value / (1024 ** index)).toFixed(precision)} ${units[index]}`;
|
||
}
|
||
|
||
function formatDuration(value) {
|
||
const totalSeconds = Math.max(0, Math.floor((Number(value) || 0) / 1000));
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const seconds = totalSeconds % 60;
|
||
return hours > 0
|
||
? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
|
||
: `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||
}
|
||
|
||
function errorMessage(error) {
|
||
return error instanceof Error ? error.message : String(error);
|
||
}
|
||
|
||
async function api(path, payload = {}) {
|
||
const response = await fetch(`${API}${path}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
cache: "no-store"
|
||
});
|
||
const type = response.headers.get("content-type") || "";
|
||
const data = type.includes("application/json") ? await response.json() : null;
|
||
if (!response.ok || !data || data.status !== 0) {
|
||
const reason = data && data.error ? data.error : `HTTP ${response.status}`;
|
||
throw new Error(reason);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function setConnection(status, textKey) {
|
||
state.connectionStatus = status;
|
||
state.connectionKey = textKey;
|
||
const item = $("connection");
|
||
item.className = `connection ${status}`;
|
||
$("connectionText").textContent = t(textKey);
|
||
}
|
||
|
||
let toastTimer = 0;
|
||
function toast(message, isError = false) {
|
||
const item = $("toast");
|
||
item.textContent = message;
|
||
item.className = isError ? "toast error" : "toast";
|
||
item.hidden = false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { item.hidden = true; }, 4500);
|
||
}
|
||
|
||
function logActivity(message, kind = "ok") {
|
||
state.activity.unshift({ message, kind, time: new Date().toLocaleTimeString(currentLanguage, { hour: "2-digit", minute: "2-digit" }) });
|
||
state.activity = state.activity.slice(0, 8);
|
||
const list = $("activity");
|
||
list.replaceChildren();
|
||
state.activity.forEach((entry) => {
|
||
list.append(node("div", `activity-item ${entry.kind}`, `${entry.time} · ${entry.message}`));
|
||
});
|
||
}
|
||
|
||
function coverFor(game, large = false) {
|
||
const cover = node("div", "cover");
|
||
const fallback = node("div", "cover-fallback", (game.title_id || "SM").slice(0, 4));
|
||
cover.append(fallback);
|
||
if (game.icon_url) {
|
||
const image = new Image();
|
||
image.alt = large ? t("coverAlt", { title: game.title_name || game.title_id }) : "";
|
||
image.addEventListener("load", () => fallback.remove(), { once: true });
|
||
image.addEventListener("error", () => image.remove(), { once: true });
|
||
cover.append(image);
|
||
image.src = game.icon_url;
|
||
}
|
||
return cover;
|
||
}
|
||
|
||
function badge(text, extra = "") {
|
||
return node("span", `badge ${extra}`.trim(), text);
|
||
}
|
||
|
||
function gameLabel(game) {
|
||
const name = game.title_name || game.title_id;
|
||
return name === game.title_id ? game.title_id : `${name} (${game.title_id})`;
|
||
}
|
||
|
||
function gameCard(game) {
|
||
const card = node("article", "game-card");
|
||
card.append(coverFor(game));
|
||
const body = node("div", "game-main");
|
||
const heading = node("div", "game-heading");
|
||
const identity = node("div", "game-identity");
|
||
identity.append(node("h3", "game-title", game.title_name || game.title_id));
|
||
identity.append(node("div", "game-id", game.title_id));
|
||
heading.append(identity);
|
||
if (game.size_bytes !== undefined) {
|
||
heading.append(node("strong", "game-card-size", formatBytes(game.size_bytes)));
|
||
}
|
||
body.append(heading);
|
||
const badges = node("div", "badges");
|
||
badges.append(badge(game.platform || t("unknown")));
|
||
badges.append(badge(game.image_backed ? (game.image_type || t("image")) : t("folder"), game.image_backed ? "image" : ""));
|
||
if (game.mounted) badges.append(badge(t("mounted"), "live"));
|
||
body.append(badges);
|
||
const path = node("div", "game-path", game.path || t("sourceUnavailable"));
|
||
path.title = game.path || "";
|
||
body.append(path);
|
||
|
||
const actions = node("div", "game-actions");
|
||
const details = node("button", "btn small", t("details"));
|
||
details.type = "button";
|
||
details.addEventListener("click", () => showDetails(game.title_id));
|
||
actions.append(details);
|
||
if (game.mounted) {
|
||
actions.append(actionButton(t("disconnect"), () => titleRuntime(game, "unmount")));
|
||
} else if (game.image_backed) {
|
||
actions.append(actionButton(t("mountReadOnly"), () => titleRuntime(game, "mount", "ro")));
|
||
actions.append(actionButton(t("mountReadWrite"), () => titleRuntime(game, "mount", "rw")));
|
||
} else {
|
||
actions.append(actionButton(t("connect"), () => titleRuntime(game, "mount")));
|
||
}
|
||
body.append(actions);
|
||
card.append(body);
|
||
return card;
|
||
}
|
||
|
||
function filteredGames() {
|
||
const query = $("search").value.trim().toLocaleLowerCase(currentLanguage);
|
||
const filter = $("filter").value;
|
||
return state.games.filter((game) => {
|
||
const matchesFilter = filter === "all" ||
|
||
(filter === "folder" && !game.image_backed) ||
|
||
(filter === "image" && game.image_backed) ||
|
||
game.platform === filter;
|
||
if (!matchesFilter) return false;
|
||
if (!query) return true;
|
||
return [game.title_name, game.title_id, game.content_id, game.path]
|
||
.some((value) => String(value || "").toLocaleLowerCase(currentLanguage).includes(query));
|
||
});
|
||
}
|
||
|
||
function renderGames() {
|
||
const games = filteredGames();
|
||
const list = $("games");
|
||
list.replaceChildren();
|
||
if (!games.length) {
|
||
list.append(node("div", "empty", t(state.games.length ? "nothingFound" : "libraryEmpty")));
|
||
} else {
|
||
games.forEach((game) => list.append(gameCard(game)));
|
||
}
|
||
$("visibleCount").textContent = t("shown", { count: games.length });
|
||
}
|
||
|
||
function renderStats() {
|
||
const imageCount = state.games.filter((game) => game.image_backed).length;
|
||
$("totalGames").textContent = state.games.length;
|
||
$("imageGames").textContent = imageCount;
|
||
$("folderGames").textContent = state.games.length - imageCount;
|
||
$("mountedGames").textContent = state.games.filter((game) => game.mounted).length;
|
||
}
|
||
|
||
function storageJobStateLabel(job) {
|
||
const labels = {
|
||
idle: t("jobIdle"),
|
||
preparing: t("jobPreparing"),
|
||
measuring: t("jobMeasuring"),
|
||
transferring: storageOperationLabel(job.operation),
|
||
deleting: t("jobDeleting"),
|
||
finalizing: t("jobFinalizing"),
|
||
completed: t("jobCompleted"),
|
||
failed: t("jobFailed"),
|
||
cancelled: t("jobCancelled")
|
||
};
|
||
return labels[job.state] || job.state;
|
||
}
|
||
|
||
function storageOperationLabel(operation) {
|
||
if (operation === "move") return t("operationMove");
|
||
if (operation === "delete") return t("operationDelete");
|
||
if (operation === "unpack") return t("operationUnpack");
|
||
return t("operationCopy");
|
||
}
|
||
|
||
function renderStorageJob(job) {
|
||
state.storageJob = job;
|
||
const hasJob = job && Number(job.job_id) > 0;
|
||
$("transferState").textContent = hasJob ? storageJobStateLabel(job) : t("jobIdle");
|
||
$("transferState").className = `badge ${job && job.active ? "live" : ""}`.trim();
|
||
$("transferIdle").hidden = hasJob;
|
||
$("transferIdle").textContent = t("noActiveOperations");
|
||
$("transferDetails").hidden = !hasJob;
|
||
if (!hasJob) return;
|
||
|
||
const percent = Math.max(0, Math.min(100, Number(job.progress_percent) || 0));
|
||
const operation = storageOperationLabel(job.operation);
|
||
$("transferSummary").textContent = `${operation} ${job.title_id}`;
|
||
$("transferRoute").textContent = job.operation === "delete"
|
||
? job.source
|
||
: `${job.source} → ${job.destination}`;
|
||
$("transferFill").style.width = `${percent}%`;
|
||
$("transferMeter").setAttribute("aria-valuenow", String(Math.round(percent)));
|
||
$("transferBytes").textContent = job.total_bytes > 0
|
||
? `${formatBytes(Number(job.processed_bytes))} / ${formatBytes(Number(job.total_bytes))}`
|
||
: job.state === "measuring" ? t("calculating") : formatBytes(Number(job.processed_bytes));
|
||
$("transferFiles").textContent = job.total_files > 0
|
||
? `${job.processed_files} / ${job.total_files}`
|
||
: job.state === "measuring" ? t("calculating") : String(job.processed_files || 0);
|
||
$("transferSpeed").textContent = job.speed_bytes_per_second > 0
|
||
? `${formatBytes(Number(job.speed_bytes_per_second))}${t("perSecond")}`
|
||
: "—";
|
||
$("transferElapsed").textContent = formatDuration(job.elapsed_ms);
|
||
$("transferPercent").textContent = `${percent.toFixed(percent < 10 && percent > 0 ? 1 : 0)}%`;
|
||
$("cancelTransfer").disabled = !job.cancellable || job.cancel_requested;
|
||
$("cancelTransfer").textContent = job.cancel_requested ? t("cancelling") : t("cancel");
|
||
}
|
||
|
||
function reportStorageJob(job) {
|
||
const key = `${job.job_id}:${job.state}`;
|
||
if (!job.job_id || job.active || state.reportedStorageJob === key) return;
|
||
state.reportedStorageJob = key;
|
||
const operation = storageOperationLabel(job.operation);
|
||
if (job.state === "completed") {
|
||
logActivity(t("operationCompleted", { operation, title: job.title_id }));
|
||
toast(t("operationCompletedShort", { operation }));
|
||
} else if (job.state === "cancelled") {
|
||
logActivity(t("operationCancelled", { operation, title: job.title_id }));
|
||
toast(t("operationCancelledShort", { operation }));
|
||
} else if (job.state === "failed") {
|
||
const reason = job.result_error || t("statusError", { status: job.result_status });
|
||
logActivity(t("operationError", { operation, title: job.title_id, error: reason }), "error");
|
||
toast(reason, true);
|
||
}
|
||
refreshAll();
|
||
}
|
||
|
||
function scheduleStorageJobPoll() {
|
||
clearTimeout(state.storagePollTimer);
|
||
const delay = state.storageJob && state.storageJob.active ? 750 : 5000;
|
||
state.storagePollTimer = setTimeout(refreshStorageJob, delay);
|
||
}
|
||
|
||
async function refreshStorageJob() {
|
||
try {
|
||
const wasActive = Boolean(state.storageJob && state.storageJob.active);
|
||
const job = await api("/games/storage/status");
|
||
renderStorageJob(job);
|
||
if (wasActive && !job.active) reportStorageJob(job);
|
||
} catch (error) {
|
||
if (!state.storageJob) {
|
||
$("transferState").textContent = t("unavailable");
|
||
$("transferIdle").textContent = errorMessage(error);
|
||
}
|
||
} finally {
|
||
scheduleStorageJobPoll();
|
||
}
|
||
}
|
||
|
||
async function cancelStorageJob() {
|
||
const job = state.storageJob;
|
||
if (!job || !job.active || !job.cancellable) return;
|
||
$("cancelTransfer").disabled = true;
|
||
try {
|
||
renderStorageJob(await api("/games/storage/cancel", { job_id: job.job_id }));
|
||
logActivity(t("cancelRequested", { id: job.job_id }));
|
||
scheduleStorageJobPoll();
|
||
} catch (error) {
|
||
toast(errorMessage(error), true);
|
||
await refreshStorageJob();
|
||
}
|
||
}
|
||
|
||
async function refreshStorage() {
|
||
const list = $("storageList");
|
||
try {
|
||
const data = await api("/storage");
|
||
const allMounts = (data.mounts || []).slice().sort((left, right) =>
|
||
String(left.mount_point).localeCompare(String(right.mount_point))
|
||
);
|
||
const libraryMounts = allMounts.filter((mount) =>
|
||
mount.mount_point === "/user" || /^\/mnt\/(?:usb|ext)\d+(?:\/|$)/.test(mount.mount_point)
|
||
);
|
||
const mounts = libraryMounts.length ? libraryMounts : allMounts.filter((mount) => !mount.read_only);
|
||
list.replaceChildren();
|
||
if (!mounts.length) {
|
||
list.append(node("span", "subtle", t("storageNotFound")));
|
||
return;
|
||
}
|
||
|
||
mounts.forEach((mount) => {
|
||
const total = Number(mount.total_bytes) || 0;
|
||
const available = Number(mount.available_bytes) || 0;
|
||
const usedPercent = total > 0 ? Math.max(0, Math.min(100, ((total - available) / total) * 100)) : 0;
|
||
const freePercent = 100 - usedPercent;
|
||
const row = node("div", "storage-row");
|
||
const head = node("div", "storage-head");
|
||
const path = node("span", "storage-path", mount.mount_point);
|
||
path.title = mount.mount_point;
|
||
head.append(path, badge(mount.read_only ? "R/O" : mount.filesystem || "FS", mount.read_only ? "" : "live"));
|
||
row.append(head);
|
||
const meta = node("div", "storage-meta", `${mount.filesystem || t("unknown")} · ${mount.source || "—"}`);
|
||
meta.title = mount.source || "";
|
||
row.append(meta);
|
||
const space = node("div", "storage-space");
|
||
space.append(node("span", "", t("freeSpace", { size: formatBytes(available) })));
|
||
space.append(node("span", "", t("totalSpace", { size: formatBytes(total) })));
|
||
row.append(space);
|
||
const meter = node("div", "storage-meter");
|
||
meter.setAttribute("role", "progressbar");
|
||
meter.setAttribute("aria-label", t("storageUsed", { path: mount.mount_point }));
|
||
meter.setAttribute("aria-valuemin", "0");
|
||
meter.setAttribute("aria-valuemax", "100");
|
||
meter.setAttribute("aria-valuenow", String(Math.round(usedPercent)));
|
||
const level = freePercent < 10 ? "danger" : freePercent < 20 ? "warning" : "";
|
||
const fill = node("div", `storage-fill ${level}`.trim());
|
||
fill.style.width = `${usedPercent}%`;
|
||
meter.append(fill);
|
||
row.append(meter);
|
||
list.append(row);
|
||
});
|
||
} catch (error) {
|
||
list.replaceChildren(node("span", "subtle", errorMessage(error)));
|
||
}
|
||
}
|
||
|
||
async function refreshLibrary() {
|
||
if (state.busy) return;
|
||
state.busy = true;
|
||
$("refreshBtn").disabled = true;
|
||
try {
|
||
const [version, games] = await Promise.all([
|
||
api("/version"),
|
||
api("/games", { include_size: $("includeSize").checked })
|
||
]);
|
||
state.games = games.games || [];
|
||
state.apiVersion = version;
|
||
$("version").textContent = `ShadowMount ${version.shadowmount_version} · API v${version.api_version}`;
|
||
setConnection("online", "connected");
|
||
renderStats();
|
||
renderGames();
|
||
} catch (error) {
|
||
setConnection("offline", "disconnected");
|
||
$("games").replaceChildren(node("div", "empty", t("apiError", { error: errorMessage(error) })));
|
||
toast(errorMessage(error), true);
|
||
} finally {
|
||
state.busy = false;
|
||
$("refreshBtn").disabled = false;
|
||
}
|
||
}
|
||
|
||
function refreshAll() {
|
||
return Promise.all([refreshLibrary(), refreshStorage()]);
|
||
}
|
||
|
||
async function refreshManual() {
|
||
const list = $("manualList");
|
||
try {
|
||
const data = await api("/manual/list");
|
||
list.replaceChildren();
|
||
if (!data.paths.length) {
|
||
list.append(node("span", "subtle", t("listEmpty")));
|
||
return;
|
||
}
|
||
data.paths.forEach((path) => {
|
||
const row = node("div", "manual-row");
|
||
const value = node("span", "manual-path", path);
|
||
value.title = path;
|
||
const remove = node("button", "btn small danger", t("remove"));
|
||
remove.type = "button";
|
||
remove.addEventListener("click", () => removeManual(path));
|
||
row.append(value, remove);
|
||
list.append(row);
|
||
});
|
||
} catch (error) {
|
||
list.replaceChildren(node("span", "subtle", errorMessage(error)));
|
||
}
|
||
}
|
||
|
||
function renderScanPaths() {
|
||
const list = $("scanPathList");
|
||
list.replaceChildren();
|
||
if (!state.scanPaths.length) {
|
||
list.append(node("span", "subtle", t("listEmpty")));
|
||
return;
|
||
}
|
||
state.scanPaths.forEach((path, index) => {
|
||
const row = node("div", "manual-row");
|
||
const value = node("span", "manual-path", path);
|
||
value.title = path;
|
||
const remove = node("button", "btn small danger", t("remove"));
|
||
remove.type = "button";
|
||
remove.addEventListener("click", () => {
|
||
state.scanPaths.splice(index, 1);
|
||
renderScanPaths();
|
||
toast(t("scanPathRemoved"));
|
||
});
|
||
row.append(value, remove);
|
||
list.append(row);
|
||
});
|
||
}
|
||
|
||
async function refreshSettings() {
|
||
try {
|
||
const settings = await api("/settings");
|
||
$("debugEnabled").checked = Boolean(settings.debug);
|
||
$("quietMode").checked = Boolean(settings.quiet_mode);
|
||
$("updateEmulators").checked = Boolean(settings.update_emulators);
|
||
$("allowLanAccess").checked = Boolean(settings.allow_lan_access);
|
||
$("fanTemperature").value = String(settings.fan_target_temperature || 0);
|
||
state.scanPaths = Array.isArray(settings.scan_paths) ? settings.scan_paths.slice() : [];
|
||
renderScanPaths();
|
||
} catch (error) {
|
||
$("scanPathList").replaceChildren(node("span", "subtle", errorMessage(error)));
|
||
}
|
||
}
|
||
|
||
function addScanPath(event) {
|
||
event.preventDefault();
|
||
const input = $("scanPath");
|
||
let path = input.value.trim();
|
||
while (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
||
if (!path.startsWith("/") || path === "/") {
|
||
toast(t("invalidAbsolutePath"), true);
|
||
return;
|
||
}
|
||
if (!state.scanPaths.includes(path)) state.scanPaths.push(path);
|
||
input.value = "";
|
||
renderScanPaths();
|
||
toast(t("scanPathAdded"));
|
||
}
|
||
|
||
async function saveSettings(event) {
|
||
event.preventDefault();
|
||
const submit = $("settingsForm").querySelector('button[type="submit"]');
|
||
submit.disabled = true;
|
||
try {
|
||
await api("/settings/update", {
|
||
debug: $("debugEnabled").checked,
|
||
quiet_mode: $("quietMode").checked,
|
||
update_emulators: $("updateEmulators").checked,
|
||
allow_lan_access: $("allowLanAccess").checked,
|
||
fan_target_temperature: Number($("fanTemperature").value),
|
||
scan_paths: state.scanPaths
|
||
});
|
||
logActivity(t("settingsSaved"));
|
||
toast(t("settingsSaved"));
|
||
setTimeout(refreshSettings, 1200);
|
||
} catch (error) {
|
||
logActivity(errorMessage(error), "error");
|
||
toast(errorMessage(error), true);
|
||
} finally {
|
||
submit.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function refreshLog() {
|
||
$("refreshLog").disabled = true;
|
||
try {
|
||
const result = await api(state.logRoute, { max_bytes: 262144 });
|
||
$("logContent").textContent = result.content || "";
|
||
$("logSubtitle").textContent = t("logBytes", {
|
||
shown: formatBytes(Number(result.returned_bytes)),
|
||
total: formatBytes(Number(result.total_bytes !== undefined
|
||
? result.total_bytes : result.file_size))
|
||
});
|
||
$("logContent").scrollTop = $("logContent").scrollHeight;
|
||
} catch (error) {
|
||
$("logContent").textContent = errorMessage(error);
|
||
$("logSubtitle").textContent = "";
|
||
} finally {
|
||
$("refreshLog").disabled = false;
|
||
}
|
||
}
|
||
|
||
function showLog(route, titleKey, triggerId) {
|
||
state.logRoute = route;
|
||
state.logTitleKey = titleKey;
|
||
state.logTriggerId = triggerId;
|
||
$("logModal").hidden = false;
|
||
$("logTitle").textContent = t(titleKey);
|
||
$("logContent").textContent = t("loading");
|
||
$("refreshLog").focus();
|
||
refreshLog();
|
||
}
|
||
|
||
function showDebugLog() { showLog("/debug-log", "debugLogTitle", "viewLog"); }
|
||
function showKernelLog() { showLog("/kernel-log", "kernelLogTitle", "viewKernelLog"); }
|
||
|
||
function closeDebugLog() {
|
||
$("logModal").hidden = true;
|
||
$(state.logTriggerId).focus();
|
||
}
|
||
|
||
async function addManual(event) {
|
||
event.preventDefault();
|
||
const input = $("manualPath");
|
||
const path = input.value.trim();
|
||
if (!path) return;
|
||
try {
|
||
const result = await api("/manual/add", { path });
|
||
input.value = "";
|
||
logActivity(t(result.changed ? "manualAdded" : "manualExists"));
|
||
await refreshManual();
|
||
} catch (error) {
|
||
logActivity(errorMessage(error), "error");
|
||
toast(errorMessage(error), true);
|
||
}
|
||
}
|
||
|
||
async function removeManual(path) {
|
||
if (!confirm(t("removeManualConfirm", { path }))) return;
|
||
try {
|
||
await api("/manual/remove", { path });
|
||
logActivity(t("manualRemoved"));
|
||
await refreshManual();
|
||
} catch (error) {
|
||
logActivity(errorMessage(error), "error");
|
||
toast(errorMessage(error), true);
|
||
}
|
||
}
|
||
|
||
async function requestScan() {
|
||
const resetAttempts = $("resetAttempts").checked;
|
||
$("scanBtn").disabled = true;
|
||
try {
|
||
await api("/scan", { reset_attempts: resetAttempts });
|
||
logActivity(t(resetAttempts ? "scanQueuedReset" : "scanQueued"));
|
||
toast(t("scanStarted"));
|
||
setTimeout(refreshAll, 1600);
|
||
} catch (error) {
|
||
logActivity(errorMessage(error), "error");
|
||
toast(errorMessage(error), true);
|
||
} finally {
|
||
$("scanBtn").disabled = false;
|
||
}
|
||
}
|
||
|
||
async function runMutation(label, task, closeAfter = false) {
|
||
try {
|
||
await task();
|
||
logActivity(label);
|
||
toast(label);
|
||
if (closeAfter) closeModal();
|
||
await refreshAll();
|
||
} catch (error) {
|
||
logActivity(`${label}: ${errorMessage(error)}`, "error");
|
||
toast(errorMessage(error), true);
|
||
}
|
||
}
|
||
|
||
function titleRuntime(game, operation, mode) {
|
||
const payload = { title_id: game.title_id };
|
||
if (mode) payload.mode = mode;
|
||
const label = operation === "mount"
|
||
? t("mountedTitle", { title: game.title_id, mode: mode ? ` (${mode})` : "" })
|
||
: t("unmountedTitle", { title: game.title_id });
|
||
return runMutation(label, async () => {
|
||
await api(`/games/${operation}`, payload);
|
||
if (!$('modal').hidden && state.selected &&
|
||
state.selected.title_id === game.title_id) {
|
||
state.selected.mounted = operation === "mount";
|
||
renderDetails(state.selected, true);
|
||
}
|
||
});
|
||
}
|
||
|
||
function actionButton(text, action, extra = "") {
|
||
const button = node("button", `btn small ${extra}`.trim(), text);
|
||
button.type = "button";
|
||
button.addEventListener("click", action);
|
||
return button;
|
||
}
|
||
|
||
function detailRow(grid, key, value) {
|
||
grid.append(node("div", "detail-key", key));
|
||
grid.append(node("div", "detail-value", value || "—"));
|
||
}
|
||
|
||
function renderDetails(game, focusRuntimeAction = false) {
|
||
state.selected = game;
|
||
$("modalTitle").textContent = game.title_name || game.title_id;
|
||
$("modalSubtitle").textContent = `${game.title_id} · ${(game.platform || t("unknown")).toUpperCase()}`;
|
||
const details = $("details");
|
||
details.replaceChildren();
|
||
details.append(coverFor(game, true));
|
||
const content = node("div");
|
||
const badges = node("div", "badges");
|
||
badges.append(badge(game.source_type === "folder" ? t("folder") : game.source_type === "image" ? t("image") : game.source_type));
|
||
if (game.image_type) badges.append(badge(game.image_type, "image"));
|
||
if (game.mounted) badges.append(badge(t("mounted"), "live"));
|
||
content.append(badges);
|
||
const grid = node("div", "detail-grid");
|
||
detailRow(grid, "Content ID", game.content_id);
|
||
detailRow(grid, t("source"), game.path);
|
||
detailRow(grid, t("runtimePath"), game.runtime_path);
|
||
detailRow(grid, t("size"), game.size_status === 0 ? formatBytes(game.size_bytes) : t("sizeError", { status: game.size_status }));
|
||
detailRow(grid, "app.db", formatBytes(game.app_db_size_bytes));
|
||
detailRow(grid, t("installed"), game.install_time);
|
||
detailRow(grid, t("lastLaunch"), game.last_access_time);
|
||
content.append(grid);
|
||
details.append(content);
|
||
|
||
const actions = $("modalActions");
|
||
actions.replaceChildren();
|
||
let runtimeAction;
|
||
if (game.mounted) {
|
||
runtimeAction = actionButton(t("disconnect"), () => titleRuntime(game, "unmount"));
|
||
actions.append(runtimeAction);
|
||
} else if (game.image_backed) {
|
||
runtimeAction = actionButton(t("mountReadOnly"), () => titleRuntime(game, "mount", "ro"));
|
||
actions.append(runtimeAction);
|
||
actions.append(actionButton(t("mountReadWrite"), () => titleRuntime(game, "mount", "rw")));
|
||
} else {
|
||
runtimeAction = actionButton(t("connect"), () => titleRuntime(game, "mount"));
|
||
actions.append(runtimeAction);
|
||
}
|
||
const storageBusy = Boolean(state.storageJob && state.storageJob.active);
|
||
const copy = actionButton(t("copy"), () => storageOperation(game, "copy"));
|
||
const move = actionButton(t("move"), () => storageOperation(game, "move"));
|
||
const unpack = game.image_backed
|
||
? actionButton(t("unpack"), () => unpackImage(game))
|
||
: null;
|
||
const remove = actionButton(t("deleteSource"), () => deleteSource(game), "danger");
|
||
copy.disabled = storageBusy;
|
||
move.disabled = storageBusy;
|
||
if (unpack) unpack.disabled = storageBusy || game.mounted;
|
||
remove.disabled = storageBusy;
|
||
if (storageBusy) {
|
||
copy.title = t("storageBusyHint");
|
||
move.title = copy.title;
|
||
if (unpack) unpack.title = copy.title;
|
||
remove.title = copy.title;
|
||
}
|
||
actions.append(copy, move);
|
||
if (unpack) actions.append(unpack);
|
||
actions.append(actionButton(t("uninstall"), () => uninstallGame(game), "danger"));
|
||
actions.append(remove);
|
||
if (focusRuntimeAction) runtimeAction.focus();
|
||
}
|
||
|
||
async function showDetails(titleId) {
|
||
$("modal").hidden = false;
|
||
$("modalTitle").textContent = t("loading");
|
||
$("modalSubtitle").textContent = titleId;
|
||
$("details").replaceChildren(node("div", "empty", t("loadingSource")));
|
||
$("modalActions").replaceChildren();
|
||
try {
|
||
renderDetails(await api("/games/info", { title_id: titleId }), true);
|
||
} catch (error) {
|
||
$("details").replaceChildren(node("div", "empty", errorMessage(error)));
|
||
}
|
||
}
|
||
|
||
function closeModal() {
|
||
$("modal").hidden = true;
|
||
state.selected = null;
|
||
}
|
||
|
||
async function storageOperation(game, operation) {
|
||
if (state.storageJob && state.storageJob.active) {
|
||
toast(t("storageBusy"), true);
|
||
return;
|
||
}
|
||
const destination = prompt(t(operation === "copy" ? "copyDestination" : "moveDestination"), "/mnt/usb0");
|
||
if (!destination) return;
|
||
await startStorageJob(game, operation, { title_id: game.title_id, destination_dir: destination });
|
||
}
|
||
|
||
async function unpackImage(game) {
|
||
if (state.storageJob && state.storageJob.active) {
|
||
toast(t("storageBusy"), true);
|
||
return;
|
||
}
|
||
const destination = prompt(t("unpackDestination", { title: game.title_id }), "/mnt/usb0");
|
||
if (!destination) return;
|
||
const deleteSourceAfter = confirm(t("unpackDeleteConfirm"));
|
||
await startStorageJob(game, "unpack", {
|
||
title_id: game.title_id,
|
||
destination_dir: destination,
|
||
delete_source: deleteSourceAfter
|
||
});
|
||
}
|
||
|
||
async function startStorageJob(game, operation, payload) {
|
||
try {
|
||
const job = await api(`/games/${operation}`, payload);
|
||
renderStorageJob(job);
|
||
closeModal();
|
||
logActivity(t("jobStarted", { operation: storageOperationLabel(operation), title: game.title_id, id: job.job_id }));
|
||
toast(t("operationStarted"));
|
||
if (job.active) scheduleStorageJobPoll();
|
||
else reportStorageJob(job);
|
||
} catch (error) {
|
||
logActivity(`${storageOperationLabel(operation)} ${game.title_id}: ${errorMessage(error)}`, "error");
|
||
toast(errorMessage(error), true);
|
||
await refreshStorageJob();
|
||
}
|
||
}
|
||
|
||
function uninstallGame(game) {
|
||
if (!confirm(t("uninstallConfirm", { game: gameLabel(game) }))) return;
|
||
runMutation(t("uninstallAccepted", { title: game.title_id }), () => api("/games/uninstall", { title_id: game.title_id }), true);
|
||
}
|
||
|
||
async function deleteSource(game) {
|
||
if (state.storageJob && state.storageJob.active) {
|
||
toast(t("storageBusy"), true);
|
||
return;
|
||
}
|
||
if (!confirm(t("deleteConfirm", { game: gameLabel(game) }))) return;
|
||
await startStorageJob(game, "delete", { title_id: game.title_id, confirm: true });
|
||
}
|
||
|
||
$("search").addEventListener("input", renderGames);
|
||
$("filter").addEventListener("change", renderGames);
|
||
$("language").addEventListener("change", () => {
|
||
applyLanguage($("language").value, true);
|
||
renderStats();
|
||
renderGames();
|
||
if (state.storageJob) renderStorageJob(state.storageJob);
|
||
if (state.selected) renderDetails(state.selected);
|
||
renderScanPaths();
|
||
refreshStorage();
|
||
refreshManual();
|
||
});
|
||
$("includeSize").addEventListener("change", refreshLibrary);
|
||
$("refreshBtn").addEventListener("click", refreshAll);
|
||
$("scanBtn").addEventListener("click", requestScan);
|
||
$("cancelTransfer").addEventListener("click", cancelStorageJob);
|
||
$("manualForm").addEventListener("submit", addManual);
|
||
$("settingsForm").addEventListener("submit", saveSettings);
|
||
$("scanPathForm").addEventListener("submit", addScanPath);
|
||
$("viewLog").addEventListener("click", showDebugLog);
|
||
$("viewKernelLog").addEventListener("click", showKernelLog);
|
||
$("refreshLog").addEventListener("click", refreshLog);
|
||
$("closeLog").addEventListener("click", closeDebugLog);
|
||
$("logModal").addEventListener("click", (event) => { if (event.target === $("logModal")) closeDebugLog(); });
|
||
$("closeModal").addEventListener("click", closeModal);
|
||
$("modal").addEventListener("click", (event) => { if (event.target === $("modal")) closeModal(); });
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Escape") return;
|
||
if (!$("logModal").hidden) closeDebugLog();
|
||
else if (!$("modal").hidden) closeModal();
|
||
});
|
||
|
||
for (let temperature = 50; temperature <= 91; temperature += 1) {
|
||
const option = node("option", "", `${temperature} °C`);
|
||
option.value = String(temperature);
|
||
$("fanTemperature").append(option);
|
||
}
|
||
applyLanguage(currentLanguage);
|
||
Promise.all([refreshAll(), refreshManual(), refreshSettings(), refreshStorageJob()]);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|