ani-gui 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ani_gui/__init__.py +2 -0
- ani_gui/__main__.py +4 -0
- ani_gui/index.html +445 -0
- ani_gui/server.py +551 -0
- ani_gui-0.2.0.dist-info/METADATA +162 -0
- ani_gui-0.2.0.dist-info/RECORD +10 -0
- ani_gui-0.2.0.dist-info/WHEEL +5 -0
- ani_gui-0.2.0.dist-info/entry_points.txt +2 -0
- ani_gui-0.2.0.dist-info/licenses/LICENSE +674 -0
- ani_gui-0.2.0.dist-info/top_level.txt +1 -0
ani_gui/__init__.py
ADDED
ani_gui/__main__.py
ADDED
ani_gui/index.html
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>ani-gui</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0f0f0f;
|
|
10
|
+
--surface: #1a1a1a;
|
|
11
|
+
--surface-2: #202020;
|
|
12
|
+
--border: #2a2a2a;
|
|
13
|
+
--border-strong: #3a3a3a;
|
|
14
|
+
--text: #f0f0f0;
|
|
15
|
+
--muted: #8a8a8a;
|
|
16
|
+
--faint: #5f5f5f;
|
|
17
|
+
--accent: #00d4aa;
|
|
18
|
+
}
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
html, body { margin: 0; height: 100%; }
|
|
21
|
+
body {
|
|
22
|
+
background: var(--bg);
|
|
23
|
+
color: var(--text);
|
|
24
|
+
font: 14px/1.5 ui-sans-serif, -apple-system, "SF Pro Text", system-ui, sans-serif;
|
|
25
|
+
-webkit-font-smoothing: antialiased;
|
|
26
|
+
}
|
|
27
|
+
.wrap { max-width: 880px; margin: 0 auto; padding: 28px 20px 64px; }
|
|
28
|
+
|
|
29
|
+
header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 20px; }
|
|
30
|
+
header h1 { font-size: 17px; font-weight: 600; margin: 0; letter-spacing: -0.01em; }
|
|
31
|
+
header span { color: var(--faint); font-size: 13px; }
|
|
32
|
+
|
|
33
|
+
.bar { display: flex; gap: 8px; margin-bottom: 8px; }
|
|
34
|
+
input[type=text] {
|
|
35
|
+
flex: 1; background: var(--surface); color: var(--text);
|
|
36
|
+
border: 1px solid var(--border); border-radius: 8px;
|
|
37
|
+
padding: 10px 12px; font-size: 14px; outline: none;
|
|
38
|
+
}
|
|
39
|
+
input[type=text]:focus { border-color: var(--border-strong); }
|
|
40
|
+
select, button {
|
|
41
|
+
background: var(--surface); color: var(--text);
|
|
42
|
+
border: 1px solid var(--border); border-radius: 8px;
|
|
43
|
+
padding: 10px 12px; font-size: 13px; cursor: pointer; outline: none;
|
|
44
|
+
}
|
|
45
|
+
button:hover, select:hover { border-color: var(--border-strong); }
|
|
46
|
+
button.primary { background: var(--accent); color: #06231d; border-color: var(--accent); font-weight: 600; }
|
|
47
|
+
button.primary:hover { filter: brightness(1.06); }
|
|
48
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
49
|
+
|
|
50
|
+
.hint { color: var(--faint); font-size: 12px; margin: 4px 0 16px; }
|
|
51
|
+
|
|
52
|
+
.tabs { display: flex; gap: 18px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
|
|
53
|
+
.tab {
|
|
54
|
+
background: none; border: none; border-bottom: 2px solid transparent;
|
|
55
|
+
color: var(--muted); padding: 8px 2px; font-size: 13px; cursor: pointer; border-radius: 0;
|
|
56
|
+
}
|
|
57
|
+
.tab:hover { color: var(--text); border-color: transparent; }
|
|
58
|
+
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
|
59
|
+
|
|
60
|
+
.list { display: flex; flex-direction: column; gap: 6px; }
|
|
61
|
+
.item {
|
|
62
|
+
display: flex; align-items: center; gap: 12px; padding: 9px 11px;
|
|
63
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
64
|
+
border-radius: 8px; cursor: pointer;
|
|
65
|
+
}
|
|
66
|
+
.item:hover { background: var(--surface-2); }
|
|
67
|
+
.item.active { border-color: var(--accent); }
|
|
68
|
+
.item .name { font-weight: 500; flex: 1; }
|
|
69
|
+
.item .meta { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
70
|
+
.cover {
|
|
71
|
+
width: 38px; height: 52px; flex: none; object-fit: cover;
|
|
72
|
+
border-radius: 5px; background: var(--surface-2); border: 1px solid var(--border);
|
|
73
|
+
}
|
|
74
|
+
.cover.lg { width: 64px; height: 88px; border-radius: 6px; }
|
|
75
|
+
|
|
76
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
|
|
77
|
+
.card {
|
|
78
|
+
display: flex; gap: 12px; padding: 12px; background: var(--surface);
|
|
79
|
+
border: 1px solid var(--border); border-radius: 8px;
|
|
80
|
+
}
|
|
81
|
+
.card .body { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
|
82
|
+
.card .title { font-weight: 500; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
83
|
+
.card .prog { color: var(--muted); font-size: 12px; margin: 4px 0 10px; }
|
|
84
|
+
.card button { margin-top: auto; align-self: flex-start; padding: 7px 11px; font-size: 12px; }
|
|
85
|
+
|
|
86
|
+
.panel {
|
|
87
|
+
margin-top: 14px; background: var(--surface); border: 1px solid var(--border);
|
|
88
|
+
border-radius: 8px; padding: 16px;
|
|
89
|
+
}
|
|
90
|
+
.panel h2 { font-size: 14px; font-weight: 600; margin: 0 0 2px; }
|
|
91
|
+
.panel .sub { color: var(--muted); font-size: 12px; margin-bottom: 14px; }
|
|
92
|
+
|
|
93
|
+
.eps { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
94
|
+
.ep {
|
|
95
|
+
min-width: 40px; text-align: center; padding: 7px 9px;
|
|
96
|
+
background: var(--surface-2); border: 1px solid var(--border);
|
|
97
|
+
border-radius: 6px; font-size: 13px; cursor: pointer; color: var(--text);
|
|
98
|
+
}
|
|
99
|
+
.ep:hover { border-color: var(--border-strong); }
|
|
100
|
+
.ep.sel { background: var(--accent); color: #06231d; border-color: var(--accent); font-weight: 600; }
|
|
101
|
+
|
|
102
|
+
.actions { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
|
103
|
+
.status { color: var(--muted); font-size: 12px; min-height: 16px; margin-top: 12px; }
|
|
104
|
+
.status.err { color: #ff7a7a; }
|
|
105
|
+
.status.ok { color: var(--accent); }
|
|
106
|
+
.status.work { color: var(--text); }
|
|
107
|
+
.empty { color: var(--faint); font-size: 13px; padding: 24px 4px; }
|
|
108
|
+
.spin { color: var(--faint); font-size: 13px; padding: 16px 4px; }
|
|
109
|
+
|
|
110
|
+
.banner {
|
|
111
|
+
display: none; align-items: flex-start; gap: 10px; margin-bottom: 16px;
|
|
112
|
+
padding: 11px 13px; border: 1px solid var(--border-strong);
|
|
113
|
+
border-radius: 8px; background: var(--surface); font-size: 13px;
|
|
114
|
+
}
|
|
115
|
+
.banner.show { display: flex; }
|
|
116
|
+
.banner.warn { border-color: #5a4a1f; }
|
|
117
|
+
.banner code {
|
|
118
|
+
background: var(--surface-2); border: 1px solid var(--border);
|
|
119
|
+
border-radius: 5px; padding: 1px 6px; font-size: 12px;
|
|
120
|
+
}
|
|
121
|
+
.banner .x {
|
|
122
|
+
margin-left: auto; color: var(--faint); cursor: pointer;
|
|
123
|
+
border: none; background: none; padding: 0 2px; font-size: 15px;
|
|
124
|
+
}
|
|
125
|
+
footer {
|
|
126
|
+
margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border);
|
|
127
|
+
display: flex; gap: 14px; flex-wrap: wrap; align-items: center;
|
|
128
|
+
color: var(--faint); font-size: 12px;
|
|
129
|
+
}
|
|
130
|
+
footer a { color: var(--muted); text-decoration: none; }
|
|
131
|
+
footer a:hover { color: var(--text); }
|
|
132
|
+
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; vertical-align: middle; margin-right: 5px; }
|
|
133
|
+
.dot.ok { background: var(--accent); }
|
|
134
|
+
.dot.bad { background: #c46; }
|
|
135
|
+
</style>
|
|
136
|
+
</head>
|
|
137
|
+
<body>
|
|
138
|
+
<div class="wrap">
|
|
139
|
+
<header>
|
|
140
|
+
<h1>ani-gui</h1>
|
|
141
|
+
<span>front-end for ani-cli</span>
|
|
142
|
+
</header>
|
|
143
|
+
|
|
144
|
+
<div class="banner" id="banner">
|
|
145
|
+
<div id="bannerText"></div>
|
|
146
|
+
<button class="x" id="bannerClose" title="Dismiss">×</button>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div class="tabs">
|
|
150
|
+
<button class="tab active" data-tab="search">Search</button>
|
|
151
|
+
<button class="tab" data-tab="continue">Continue Watching</button>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div id="searchView">
|
|
155
|
+
<form class="bar" id="searchForm">
|
|
156
|
+
<input type="text" id="q" placeholder="Search anime…" autocomplete="off" autofocus>
|
|
157
|
+
<select id="mode" title="Language">
|
|
158
|
+
<option value="sub">Sub</option>
|
|
159
|
+
<option value="dub">Dub</option>
|
|
160
|
+
</select>
|
|
161
|
+
<select id="player" title="Player">
|
|
162
|
+
<option value="default">Default player</option>
|
|
163
|
+
<option value="vlc">VLC</option>
|
|
164
|
+
</select>
|
|
165
|
+
<button type="submit">Search</button>
|
|
166
|
+
</form>
|
|
167
|
+
<div class="hint">Pick a series, choose an episode, then play. Playback opens in your chosen player.</div>
|
|
168
|
+
|
|
169
|
+
<div id="results" class="list"></div>
|
|
170
|
+
<div id="detail"></div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div id="continueView" style="display:none">
|
|
174
|
+
<div class="hint">Resumes the next episode from ani-cli's watch history.</div>
|
|
175
|
+
<div id="continueList"></div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<footer id="footer">
|
|
179
|
+
<span id="verInfo">ani-gui</span>
|
|
180
|
+
<span id="health"></span>
|
|
181
|
+
<a href="/docs">Docs</a>
|
|
182
|
+
<a href="https://github.com/pystardust/ani-cli" target="_blank" rel="noreferrer">ani-cli</a>
|
|
183
|
+
</footer>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<script>
|
|
187
|
+
const $ = s => document.querySelector(s);
|
|
188
|
+
const state = { mode: "sub", player: "default", selected: null, ep: null };
|
|
189
|
+
|
|
190
|
+
const coverImg = (src, cls="cover") =>
|
|
191
|
+
src ? `<img class="${cls}" src="${src}" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'">`
|
|
192
|
+
: `<span class="${cls}"></span>`;
|
|
193
|
+
|
|
194
|
+
// tabs
|
|
195
|
+
document.querySelectorAll(".tab").forEach(t => t.addEventListener("click", () => {
|
|
196
|
+
document.querySelectorAll(".tab").forEach(x => x.classList.remove("active"));
|
|
197
|
+
t.classList.add("active");
|
|
198
|
+
const tab = t.dataset.tab;
|
|
199
|
+
$("#searchView").style.display = tab === "search" ? "" : "none";
|
|
200
|
+
$("#continueView").style.display = tab === "continue" ? "" : "none";
|
|
201
|
+
if (tab === "continue") loadContinue();
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
$("#mode").addEventListener("change", e => {
|
|
205
|
+
state.mode = e.target.value;
|
|
206
|
+
if ($("#q").value.trim()) runSearch();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
$("#player").addEventListener("change", e => { state.player = e.target.value; });
|
|
210
|
+
|
|
211
|
+
$("#bannerClose").addEventListener("click", () => $("#banner").classList.remove("show"));
|
|
212
|
+
|
|
213
|
+
function showBanner(html, warn) {
|
|
214
|
+
$("#bannerText").innerHTML = html;
|
|
215
|
+
const b = $("#banner");
|
|
216
|
+
b.className = "banner show" + (warn ? " warn" : "");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function loadVersion() {
|
|
220
|
+
try {
|
|
221
|
+
const v = await (await fetch("/api/version")).json();
|
|
222
|
+
const c = v.ani_cli;
|
|
223
|
+
$("#verInfo").textContent =
|
|
224
|
+
`ani-gui ${v.ani_gui} · ani-cli ${c.installed || "not found"}`;
|
|
225
|
+
|
|
226
|
+
if (!c.installed) {
|
|
227
|
+
$("#health").innerHTML = `<span class="dot bad"></span>ani-cli missing`;
|
|
228
|
+
} else if (!v.has_player) {
|
|
229
|
+
$("#health").innerHTML = `<span class="dot bad"></span>no player found`;
|
|
230
|
+
} else {
|
|
231
|
+
$("#health").innerHTML = `<span class="dot ok"></span>ready`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Show the most important message. Errors first, then updates.
|
|
235
|
+
if (!c.installed) {
|
|
236
|
+
showBanner("<b>ani-cli isn't installed or isn't on PATH.</b> Install it from " +
|
|
237
|
+
`<a href="https://github.com/pystardust/ani-cli" target="_blank" rel="noreferrer">github.com/pystardust/ani-cli</a>, then reload.`, true);
|
|
238
|
+
} else if (!v.has_player) {
|
|
239
|
+
showBanner("<b>No video player found.</b> Install <code>mpv</code> or <code>iina</code> to watch.", true);
|
|
240
|
+
} else if (v.ani_gui_update) {
|
|
241
|
+
showBanner(`<b>ani-gui ${v.ani_gui} → ${v.ani_gui_latest} available.</b> ` +
|
|
242
|
+
`Update with <code>pipx upgrade ani-gui</code>`, false);
|
|
243
|
+
} else if (c.update_available) {
|
|
244
|
+
showBanner(`<b>ani-cli ${c.installed} → ${c.latest} available.</b> ` +
|
|
245
|
+
`Update with <code>ani-cli -U</code>` +
|
|
246
|
+
` (or <code>brew upgrade ani-cli</code> if installed via Homebrew).`, false);
|
|
247
|
+
}
|
|
248
|
+
} catch (_) { /* footer just stays minimal */ }
|
|
249
|
+
}
|
|
250
|
+
loadVersion();
|
|
251
|
+
|
|
252
|
+
$("#searchForm").addEventListener("submit", e => { e.preventDefault(); runSearch(); });
|
|
253
|
+
|
|
254
|
+
async function runSearch() {
|
|
255
|
+
const q = $("#q").value.trim();
|
|
256
|
+
$("#detail").innerHTML = "";
|
|
257
|
+
state.selected = null; state.ep = null;
|
|
258
|
+
if (!q) { $("#results").innerHTML = ""; return; }
|
|
259
|
+
$("#results").innerHTML = `<div class="spin">Searching…</div>`;
|
|
260
|
+
try {
|
|
261
|
+
const r = await fetch(`/api/search?q=${encodeURIComponent(q)}&mode=${state.mode}`);
|
|
262
|
+
const d = await r.json();
|
|
263
|
+
if (d.error) throw new Error(d.error);
|
|
264
|
+
renderResults(d.results);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
$("#results").innerHTML = `<div class="empty">Error: ${err.message}</div>`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function renderResults(results) {
|
|
271
|
+
if (!results.length) { $("#results").innerHTML = `<div class="empty">No results.</div>`; return; }
|
|
272
|
+
$("#results").innerHTML = "";
|
|
273
|
+
for (const a of results) {
|
|
274
|
+
const el = document.createElement("div");
|
|
275
|
+
el.className = "item";
|
|
276
|
+
const meta = state.mode === "dub" ? `${a.dub} dub` : `${a.sub} eps`;
|
|
277
|
+
el.innerHTML = `${coverImg(a.thumbnail)}<span class="name"></span><span class="meta">${meta}</span>`;
|
|
278
|
+
el.querySelector(".name").textContent = a.name;
|
|
279
|
+
el.addEventListener("click", () => selectShow(a, el));
|
|
280
|
+
$("#results").appendChild(el);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function selectShow(a, el) {
|
|
285
|
+
document.querySelectorAll(".item").forEach(i => i.classList.remove("active"));
|
|
286
|
+
el.classList.add("active");
|
|
287
|
+
state.selected = a; state.ep = null;
|
|
288
|
+
$("#detail").innerHTML = `<div class="panel"><div class="spin">Loading episodes…</div></div>`;
|
|
289
|
+
try {
|
|
290
|
+
const r = await fetch(`/api/episodes?id=${encodeURIComponent(a.id)}&mode=${state.mode}`);
|
|
291
|
+
const d = await r.json();
|
|
292
|
+
if (d.error) throw new Error(d.error);
|
|
293
|
+
renderDetail(a, d.episodes);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
$("#detail").innerHTML = `<div class="panel"><div class="empty">Error: ${err.message}</div></div>`;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function renderDetail(a, episodes) {
|
|
300
|
+
const panel = document.createElement("div");
|
|
301
|
+
panel.className = "panel";
|
|
302
|
+
panel.innerHTML = `
|
|
303
|
+
<div style="display:flex; gap:14px; margin-bottom:14px;">
|
|
304
|
+
${coverImg(a.thumbnail, "cover lg")}
|
|
305
|
+
<div style="min-width:0;">
|
|
306
|
+
<h2></h2>
|
|
307
|
+
<div class="sub" style="margin:4px 0 0;">${episodes.length} episode${episodes.length===1?"":"s"} · ${state.mode}</div>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
<div class="eps"></div>
|
|
311
|
+
<div class="actions">
|
|
312
|
+
<select id="quality" title="Quality">
|
|
313
|
+
<option value="best">Best</option>
|
|
314
|
+
<option value="1080">1080p</option>
|
|
315
|
+
<option value="720">720p</option>
|
|
316
|
+
<option value="480">480p</option>
|
|
317
|
+
<option value="worst">Worst</option>
|
|
318
|
+
</select>
|
|
319
|
+
<button class="primary" id="playBtn" disabled>Play</button>
|
|
320
|
+
<button id="dlBtn" disabled>Download</button>
|
|
321
|
+
</div>
|
|
322
|
+
<div class="status" id="status"></div>`;
|
|
323
|
+
panel.querySelector("h2").textContent = a.name;
|
|
324
|
+
const epsBox = panel.querySelector(".eps");
|
|
325
|
+
if (!episodes.length) {
|
|
326
|
+
epsBox.innerHTML = `<span class="empty">No episodes available.</span>`;
|
|
327
|
+
}
|
|
328
|
+
for (const ep of episodes) {
|
|
329
|
+
const b = document.createElement("div");
|
|
330
|
+
b.className = "ep";
|
|
331
|
+
b.textContent = ep;
|
|
332
|
+
b.addEventListener("click", () => {
|
|
333
|
+
epsBox.querySelectorAll(".ep").forEach(x => x.classList.remove("sel"));
|
|
334
|
+
b.classList.add("sel");
|
|
335
|
+
state.ep = ep;
|
|
336
|
+
panel.querySelector("#playBtn").disabled = false;
|
|
337
|
+
panel.querySelector("#dlBtn").disabled = false;
|
|
338
|
+
});
|
|
339
|
+
epsBox.appendChild(b);
|
|
340
|
+
}
|
|
341
|
+
panel.querySelector("#playBtn").addEventListener("click", () => doPlay(panel, false));
|
|
342
|
+
panel.querySelector("#dlBtn").addEventListener("click", () => doPlay(panel, true));
|
|
343
|
+
$("#detail").innerHTML = "";
|
|
344
|
+
$("#detail").appendChild(panel);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function doPlay(panel, download) {
|
|
348
|
+
const status = panel.querySelector("#status");
|
|
349
|
+
const btns = panel.querySelectorAll("button");
|
|
350
|
+
if (state.ep == null) {
|
|
351
|
+
status.className = "status err"; status.textContent = "Pick an episode first."; return;
|
|
352
|
+
}
|
|
353
|
+
setStatus(status, "work", download
|
|
354
|
+
? `Asking ani-cli to download episode ${state.ep}…`
|
|
355
|
+
: `Resolving episode ${state.ep} via ani-cli (finding a working source)…`);
|
|
356
|
+
btns.forEach(b => b.disabled = true);
|
|
357
|
+
try {
|
|
358
|
+
const r = await fetch("/api/play", {
|
|
359
|
+
method: "POST", headers: {"Content-Type": "application/json"},
|
|
360
|
+
body: JSON.stringify({
|
|
361
|
+
query: $("#q").value.trim(),
|
|
362
|
+
nth: state.selected.nth,
|
|
363
|
+
ep: state.ep,
|
|
364
|
+
quality: panel.querySelector("#quality").value,
|
|
365
|
+
mode: state.mode,
|
|
366
|
+
player: state.player,
|
|
367
|
+
download,
|
|
368
|
+
})});
|
|
369
|
+
const d = await r.json();
|
|
370
|
+
if (d.ok) setStatus(status, d.stage === "slow" ? "work" : "ok", d.message);
|
|
371
|
+
else setStatus(status, "err", d.error || "Playback failed.");
|
|
372
|
+
} catch (err) {
|
|
373
|
+
setStatus(status, "err", "Couldn't reach the server: " + err.message);
|
|
374
|
+
} finally {
|
|
375
|
+
btns.forEach(b => b.disabled = false);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function setStatus(el, kind, text) {
|
|
380
|
+
el.className = "status" + (kind === "err" ? " err" : kind === "ok" ? " ok" : " work");
|
|
381
|
+
el.textContent = (kind === "work" ? "⏳ " : kind === "ok" ? "▶ " : "⚠ ") + text;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function loadContinue() {
|
|
385
|
+
const box = $("#continueList");
|
|
386
|
+
box.innerHTML = `<div class="spin">Loading history…</div>`;
|
|
387
|
+
try {
|
|
388
|
+
const r = await fetch(`/api/continue?mode=${state.mode}`);
|
|
389
|
+
const d = await r.json();
|
|
390
|
+
if (d.error) throw new Error(d.error);
|
|
391
|
+
renderContinue(d.items);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
box.innerHTML = `<div class="empty">Error: ${err.message}</div>`;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function renderContinue(items) {
|
|
398
|
+
const box = $("#continueList");
|
|
399
|
+
if (!items.length) {
|
|
400
|
+
box.innerHTML = `<div class="empty">No watch history yet. Play something from the Search tab.</div>`;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
box.innerHTML = `<div class="cards"></div>`;
|
|
404
|
+
const grid = box.querySelector(".cards");
|
|
405
|
+
for (const it of items) {
|
|
406
|
+
const card = document.createElement("div");
|
|
407
|
+
card.className = "card";
|
|
408
|
+
const caughtUp = !it.next_ep;
|
|
409
|
+
const prog = caughtUp
|
|
410
|
+
? `Watched ep ${it.watched}${it.total ? ` · latest ${it.total}` : ""} · caught up`
|
|
411
|
+
: `Watched ep ${it.watched} · next ep ${it.next_ep}${it.total ? ` of ${it.total}` : ""}`;
|
|
412
|
+
card.innerHTML = `
|
|
413
|
+
${coverImg(it.thumbnail, "cover lg")}
|
|
414
|
+
<div class="body">
|
|
415
|
+
<div class="title"></div>
|
|
416
|
+
<div class="prog">${prog}</div>
|
|
417
|
+
<button class="primary"${caughtUp ? " disabled" : ""}>Resume ep ${it.next_ep ?? it.watched}</button>
|
|
418
|
+
<div class="status"></div>
|
|
419
|
+
</div>`;
|
|
420
|
+
card.querySelector(".title").textContent = it.title;
|
|
421
|
+
const status = card.querySelector(".status");
|
|
422
|
+
card.querySelector("button").addEventListener("click", async (e) => {
|
|
423
|
+
const btn = e.target;
|
|
424
|
+
btn.disabled = true;
|
|
425
|
+
setStatus(status, "work", `Resolving ep ${it.next_ep} via ani-cli…`);
|
|
426
|
+
try {
|
|
427
|
+
const r = await fetch("/api/resume", {
|
|
428
|
+
method: "POST", headers: {"Content-Type": "application/json"},
|
|
429
|
+
body: JSON.stringify({ id: it.id, title: it.title, ep: it.next_ep,
|
|
430
|
+
mode: it.mode, player: state.player })});
|
|
431
|
+
const d = await r.json();
|
|
432
|
+
if (d.ok) setStatus(status, d.stage === "slow" ? "work" : "ok", d.message);
|
|
433
|
+
else setStatus(status, "err", d.error || "Playback failed.");
|
|
434
|
+
} catch (err) {
|
|
435
|
+
setStatus(status, "err", "Couldn't reach the server: " + err.message);
|
|
436
|
+
} finally {
|
|
437
|
+
btn.disabled = false;
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
grid.appendChild(card);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
</script>
|
|
444
|
+
</body>
|
|
445
|
+
</html>
|