feat: add asset browser and ANN event playback

This commit is contained in:
Patryk Gensch
2026-08-25 00:01:21 +02:00
parent c2036e1f72
commit 8a870a60bb
6 changed files with 769 additions and 91 deletions
+7 -3
View File
@@ -76,9 +76,13 @@ rex-catalog cat arcade.cnv # odszyfrowany skrypt na wyjście
rex-catalog titles # lista wydań z metadanymi
```
Front ma cztery widoki: **Katalog** (tytuły, wydania, kopie, pliki), **Skrypty**
(wyszukiwanie pełnotekstowe i podgląd z listą użytych zasobów), **Dźwięk** (wyszukiwanie
nagrań, odtwarzacz, transkrypcja) i **Formaty** (rozkład zawartości kolekcji).
Front otwiera się na widoku **Zasoby** — paginowanej galerii obrazów, animacji i nagrań,
filtrowanej po grze, rodzaju i nazwie. Zaznaczenie otwiera inspektor z pełnym podglądem,
parametrami i miejscami użycia. Animacje można odtwarzać w tempie zapisanym w ANN — jako
wszystkie obrazy albo konkretne nazwane zdarzenie, np. `BEZRUCH` czy `GADA_1`, razem z jego
właściwą kolejnością klatek i zakresem pętli. Dalej są **Skrypty** (wyszukiwanie
pełnotekstowe i podgląd z listą użytych zasobów), **Dźwięk** (wyszukiwanie nagrań,
odtwarzacz, transkrypcja), **Kolekcja** (tytuły, wydania, kopie, pliki) i **Statystyki**.
Pod `/mcp` stoi serwer MCP (Streamable HTTP, tylko do odczytu) z dziewięcioma narzędziami —
od `list_titles` i `search_scripts` po `get_script_assets`, które pokazuje, co dany skrypt
@@ -112,6 +112,11 @@ public final class Database implements AutoCloseable {
addColumnIfMissing("media", "frames", "INTEGER");
addColumnIfMissing("media", "fps", "INTEGER");
addColumnIfMissing("media", "author", "TEXT");
// Starszy indeks zdarzeń przechowywał tylko ich długość. Do wiernego
// podglądu potrzebna jest właściwa kolejność obrazów i reguły pętli.
addColumnIfMissing("anim_event", "frames", "TEXT");
addColumnIfMissing("anim_event", "repeat_count", "INTEGER");
addColumnIfMissing("anim_event", "flags", "INTEGER");
addBasenameIndex();
}
@@ -412,6 +417,9 @@ public final class Database implements AutoCloseable {
frames_count INTEGER,
loop_start INTEGER,
loop_end INTEGER,
frames TEXT,
repeat_count INTEGER,
flags INTEGER,
PRIMARY KEY (ann_sha1, name)
)
"""
@@ -36,7 +36,8 @@ import java.util.Set;
*/
public final class GraphicsIndexer {
public static final String VERSION = "grafika-1";
public static final String IMAGE_VERSION = "grafika-1";
public static final String ANIMATION_VERSION = "grafika-2";
private static final String KIND = "preview";
@@ -93,11 +94,13 @@ public final class GraphicsIndexer {
PreparedStatement eventDelete = conn.prepareStatement(
"DELETE FROM anim_event WHERE ann_sha1 = ?");
PreparedStatement eventInsert = conn.prepareStatement("""
INSERT INTO anim_event (ann_sha1, name, frames_count, loop_start, loop_end)
VALUES (?, ?, ?, ?, ?)
INSERT INTO anim_event (ann_sha1, name, frames_count, loop_start, loop_end,
frames, repeat_count, flags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ann_sha1, name) DO UPDATE SET
frames_count = excluded.frames_count, loop_start = excluded.loop_start,
loop_end = excluded.loop_end
loop_end = excluded.loop_end, frames = excluded.frames,
repeat_count = excluded.repeat_count, flags = excluded.flags
""")) {
for (Map.Entry<String, List<Pending>> group : byContainer.entrySet()) {
@@ -152,11 +155,11 @@ public final class GraphicsIndexer {
private void indexImage(String sha1, byte[] raw, PreparedStatement mediaStmt,
PreparedStatement artifactStmt) throws Exception {
PikGraphics.Picture picture = PikGraphics.readImage(raw);
writePreview(sha1, picture.image(), artifactStmt);
writePreview(sha1, picture.image(), artifactStmt, IMAGE_VERSION);
write(mediaStmt, sha1, "image", "pik" + picture.colorDepth(), picture.width(),
picture.height(), 1, 0, null,
"kompresja " + picture.compression());
"kompresja " + picture.compression(), IMAGE_VERSION);
}
private int indexAnimation(String sha1, byte[] raw, PreparedStatement mediaStmt,
@@ -164,12 +167,14 @@ public final class GraphicsIndexer {
PreparedStatement eventInsert) throws Exception {
PikGraphics.Animation animation = PikGraphics.readAnimation(raw);
if (!animation.frames().isEmpty()) {
writePreview(sha1, animation.frames().get(0).image(), artifactStmt);
writePreview(sha1, animation.frames().get(0).image(), artifactStmt,
ANIMATION_VERSION);
}
write(mediaStmt, sha1, "anim", "nvp" + animation.colorDepth(), animation.maxWidth(),
animation.maxHeight(), animation.frames().size(), animation.fps(),
blankToNull(animation.author()), blankToNull(animation.description()));
blankToNull(animation.author()), blankToNull(animation.description()),
ANIMATION_VERSION);
eventDelete.setString(1, sha1);
eventDelete.executeUpdate();
@@ -179,13 +184,17 @@ public final class GraphicsIndexer {
eventInsert.setInt(3, event.framesCount());
eventInsert.setInt(4, event.loopStart());
eventInsert.setInt(5, event.loopEnd());
eventInsert.setString(6, event.frames().stream()
.map(String::valueOf).collect(java.util.stream.Collectors.joining(",")));
eventInsert.setInt(7, event.repeatCount());
eventInsert.setInt(8, event.flags());
eventInsert.executeUpdate();
}
return animation.frames().size();
}
private void writePreview(String sha1, BufferedImage image, PreparedStatement artifactStmt)
throws Exception {
private void writePreview(String sha1, BufferedImage image, PreparedStatement artifactStmt,
String version) throws Exception {
String relative = cachePath(sha1);
Path target = dataDir.resolve(relative);
Files.createDirectories(target.getParent());
@@ -194,7 +203,7 @@ public final class GraphicsIndexer {
artifactStmt.setString(1, sha1);
artifactStmt.setString(2, KIND);
artifactStmt.setString(3, relative);
artifactStmt.setString(4, VERSION);
artifactStmt.setString(4, version);
artifactStmt.setString(5, Instant.now().toString());
artifactStmt.executeUpdate();
}
@@ -223,12 +232,13 @@ public final class GraphicsIndexer {
throws Exception {
String reason = cause.getMessage() == null ? cause.toString() : cause.getMessage();
write(mediaStmt, pending.sha1(), "ANN".equals(pending.format()) ? "anim" : "image",
null, 0, 0, 0, 0, null, "nieczytelny: " + reason);
null, 0, 0, 0, 0, null, "nieczytelny: " + reason,
"ANN".equals(pending.format()) ? ANIMATION_VERSION : IMAGE_VERSION);
}
private void write(PreparedStatement stmt, String sha1, String kind, String codec,
int width, int height, int frames, int fps, String author, String note)
throws Exception {
int width, int height, int frames, int fps, String author, String note,
String version) throws Exception {
stmt.setString(1, sha1);
stmt.setString(2, kind);
stmt.setString(3, codec);
@@ -238,7 +248,7 @@ public final class GraphicsIndexer {
stmt.setInt(7, fps);
stmt.setString(8, author);
stmt.setString(9, note);
stmt.setString(10, VERSION);
stmt.setString(10, version);
stmt.setString(11, Instant.now().toString());
stmt.executeUpdate();
}
@@ -262,13 +272,15 @@ public final class GraphicsIndexer {
LEFT JOIN media m ON m.sha1 = b.sha1
WHERE c.status <> 'unreadable' AND b.format IN ('IMG', 'ANN')
""" + (force ? "" : """
AND (m.sha1 IS NULL OR m.tool_version <> ?)
AND (m.sha1 IS NULL OR m.tool_version <>
CASE b.format WHEN 'ANN' THEN ? ELSE ? END)
""") + "ORDER BY c.id";
Map<String, List<Pending>> byContainer = new LinkedHashMap<>();
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
if (!force) {
st.setString(1, VERSION);
st.setString(1, ANIMATION_VERSION);
st.setString(2, IMAGE_VERSION);
}
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
@@ -49,7 +49,7 @@ public final class PikGraphics {
/** Zdarzenie animacji: nazwana sekwencja odsyłająca do numerów klatek. */
public record AnimEvent(String name, int framesCount, int loopStart, int loopEnd,
List<Integer> frames) {
int repeatCount, int flags, List<Integer> frames) {
}
public record Animation(int colorDepth, int fps, int opacity, int maxWidth, int maxHeight,
@@ -144,9 +144,10 @@ public final class PikGraphics {
reader.skip(4);
int loopStart = reader.u16();
int loopEnd = reader.u16();
reader.skip(4); // liczba powtórzeń i licznik
int repeatCount = reader.u16();
reader.skip(2); // bieżący licznik powtórzeń
reader.skip(4);
reader.skip(4); // flagi
int flags = reader.i32();
reader.u8(); // krycie
reader.skip(12);
@@ -157,7 +158,7 @@ public final class PikGraphics {
for (int i = 0; i < framesCount; i++) {
skipFrameData(reader);
}
return new AnimEvent(name, framesCount, loopStart, loopEnd, frames);
return new AnimEvent(name, framesCount, loopStart, loopEnd, repeatCount, flags, frames);
}
/** Dane klatki opisują ruch i dźwięk — do podglądu nam niepotrzebne, ale trzeba je przejść. */
@@ -37,6 +37,9 @@ public final class WebServer {
private final MediaIndex media;
private final Transcriber transcriber;
private final Object lock;
private final Object animationCacheLock = new Object();
private String cachedAnimationSha1;
private PikGraphics.Animation cachedAnimation;
public WebServer(Database db, Path dataDir, Object lock) {
this.db = db;
@@ -139,6 +142,9 @@ public final class WebServer {
if (path.equals("/titles")) {
return titles();
}
if (path.equals("/assets")) {
return assets(query);
}
if (path.equals("/search")) {
return searchScripts(query.getOrDefault("q", ""),
parseInt(query.get("limit"), 30));
@@ -250,19 +256,17 @@ public final class WebServer {
private void serveRender(HttpExchange exchange, String sha1, int frame) throws IOException {
byte[] png;
try {
MediaIndex.Clip clip;
String kind;
synchronized (lock) {
clip = media.read(sha1);
kind = mediaKind(sha1);
}
if (clip == null) {
respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8));
return;
}
java.awt.image.BufferedImage image;
if ("anim".equals(kind)) {
PikGraphics.Animation animation = PikGraphics.readAnimation(clip.bytes());
PikGraphics.Animation animation = animation(sha1);
if (animation == null) {
respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8));
return;
}
if (animation.frames().isEmpty()) {
respond(exchange, 404, "text/plain", "Animacja bez klatek".getBytes(UTF8));
return;
@@ -270,6 +274,14 @@ public final class WebServer {
int index = Math.max(0, Math.min(frame, animation.frames().size() - 1));
image = animation.frames().get(index).image();
} else {
MediaIndex.Clip clip;
synchronized (lock) {
clip = media.read(sha1);
}
if (clip == null) {
respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8));
return;
}
image = PikGraphics.readImage(clip.bytes()).image();
}
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
@@ -286,6 +298,31 @@ public final class WebServer {
exchange.getResponseBody().write(png);
}
/**
* Trzyma ostatnio oglądane ANN w pamięci. Odtwarzacz prosi o kilkanaście
* klatek na sekundę, a ponowne rozpakowywanie całego pliku dla każdej z nich
* byłoby znacznie droższe niż sam zapis PNG. Jedna pozycja ogranicza koszt
* pamięci także dla animacji z dużymi obrazami.
*/
private PikGraphics.Animation animation(String sha1) throws Exception {
synchronized (animationCacheLock) {
if (sha1.equals(cachedAnimationSha1)) {
return cachedAnimation;
}
MediaIndex.Clip clip;
synchronized (lock) {
clip = media.read(sha1);
}
if (clip == null) {
return null;
}
PikGraphics.Animation decoded = PikGraphics.readAnimation(clip.bytes());
cachedAnimationSha1 = sha1;
cachedAnimation = decoded;
return decoded;
}
}
private String previewPath(String sha1) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement(
"SELECT path FROM artifact WHERE blob_sha1 = ? AND kind = 'preview'")) {
@@ -555,6 +592,146 @@ public final class WebServer {
return out;
}
/**
* Paginowana lista zasobów do głównej galerii. Jedna treść może występować
* na kilku płytach, dlatego karta jest kluczowana SHA-1, a liczba miejsc
* pozostaje jawna. Wpisy archiwów SND są włączone do tej samej listy co
* zwykłe pliki — z punktu widzenia przeglądającego oba są nagraniami.
*/
private JsonObject assets(Map<String, String> query) throws Exception {
Integer edition = query.containsKey("edition")
? parseInt(query.get("edition"), -1) : null;
if (edition != null && edition < 0) {
edition = null;
}
String kind = query.getOrDefault("kind", "visual").toLowerCase(Locale.ROOT);
if (!List.of("visual", "image", "anim", "audio", "all").contains(kind)) {
kind = "visual";
}
String text = query.getOrDefault("q", "").trim().toLowerCase(Locale.ROOT);
int limit = Math.max(1, Math.min(parseInt(query.get("limit"), 80), 200));
int offset = Math.max(0, parseInt(query.get("offset"), 0));
String sort = query.getOrDefault("sort", "name");
String places = """
WITH places AS (
SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.width, m.height,
m.frames, m.fps, COALESCE(f.path_raw, f.path) AS path,
c.display_name AS copy, e.id AS edition_id, t.name AS title,
0 AS in_archive
FROM media m
JOIN file f ON f.blob_sha1 = m.sha1
JOIN copy c ON c.id = f.copy_id
LEFT JOIN game_root g ON g.copy_id = c.id
AND (g.root_path = '' OR f.path = g.root_path
OR f.path LIKE g.root_path || '/%')
LEFT JOIN edition e ON e.id = g.edition_id
LEFT JOIN title t ON t.id = e.title_id
UNION ALL
SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.width, m.height,
m.frames, m.fps, COALESCE(ae.name_raw, ae.name) AS path,
c.display_name AS copy, e.id AS edition_id, t.name AS title,
1 AS in_archive
FROM media m
JOIN archive_entry ae ON ae.sha1 = m.sha1
JOIN file f ON f.blob_sha1 = ae.container_sha1
JOIN copy c ON c.id = f.copy_id
LEFT JOIN game_root g ON g.copy_id = c.id
AND (g.root_path = '' OR f.path = g.root_path
OR f.path LIKE g.root_path || '/%')
LEFT JOIN edition e ON e.id = g.edition_id
LEFT JOIN title t ON t.id = e.title_id
)
""";
StringBuilder where = new StringBuilder(" WHERE 1 = 1\n");
List<Object> params = new ArrayList<>();
switch (kind) {
case "image", "anim", "audio" -> {
where.append(" AND kind = ?\n");
params.add(kind);
}
case "visual" -> where.append(" AND kind IN ('image', 'anim')\n");
default -> { }
}
if (edition != null) {
where.append(" AND edition_id = ?\n");
params.add(edition);
}
if (!text.isBlank()) {
where.append(" AND (lower(path) LIKE ? OR lower(COALESCE(title, '')) LIKE ? "
+ "OR lower(COALESCE(copy, '')) LIKE ? OR lower(COALESCE(codec, '')) LIKE ?)\n");
String like = "%" + text + "%";
params.add(like);
params.add(like);
params.add(like);
params.add(like);
}
long total;
try (PreparedStatement st = db.connection().prepareStatement(
places + "SELECT COUNT(DISTINCT sha1) FROM places" + where)) {
bind(st, params);
try (ResultSet rs = st.executeQuery()) {
total = rs.next() ? rs.getLong(1) : 0;
}
}
String order = switch (sort) {
case "game" -> "lower(COALESCE(MIN(title), '')), lower(MIN(path)), sha1";
case "largest" -> "COALESCE(MAX(width), 0) * COALESCE(MAX(height), 0) "
+ "DESC, lower(MIN(path)), sha1";
default -> "lower(MIN(path)), sha1";
};
String sql = places + """
SELECT sha1, MIN(kind) AS kind, MIN(codec) AS codec,
MAX(duration_ms) AS duration_ms, MAX(width) AS width,
MAX(height) AS height, MAX(frames) AS frames, MAX(fps) AS fps,
MIN(path) AS path, MIN(copy) AS copy, MIN(edition_id) AS edition_id,
MIN(title) AS title, MAX(in_archive) AS in_archive,
COUNT(*) AS places_count, COUNT(DISTINCT edition_id) AS games_count
FROM places
""" + where + " GROUP BY sha1 ORDER BY " + order + " LIMIT ? OFFSET ?";
JsonArray items = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
List<Object> pageParams = new ArrayList<>(params);
pageParams.add(limit);
pageParams.add(offset);
bind(st, pageParams);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("sha1", rs.getString("sha1"));
row.addProperty("kind", rs.getString("kind"));
row.addProperty("codec", rs.getString("codec"));
row.addProperty("duration_ms", rs.getInt("duration_ms"));
row.addProperty("width", rs.getInt("width"));
row.addProperty("height", rs.getInt("height"));
row.addProperty("frames", rs.getInt("frames"));
row.addProperty("fps", rs.getInt("fps"));
row.addProperty("path", rs.getString("path"));
row.addProperty("name", baseName(rs.getString("path")));
row.addProperty("copy", rs.getString("copy"));
row.addProperty("edition_id", rs.getInt("edition_id"));
row.addProperty("title", rs.getString("title"));
row.addProperty("in_archive", rs.getInt("in_archive") == 1);
row.addProperty("places_count", rs.getInt("places_count"));
row.addProperty("games_count", rs.getInt("games_count"));
items.add(row);
}
}
}
JsonObject out = new JsonObject();
out.addProperty("total", total);
out.addProperty("offset", offset);
out.addProperty("limit", limit);
out.addProperty("has_more", offset + items.size() < total);
out.add("items", items);
return out;
}
private JsonObject searchScripts(String query, int limit) throws Exception {
JsonObject out = new JsonObject();
if (query.isBlank()) {
@@ -871,7 +1048,7 @@ public final class WebServer {
JsonArray events = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT name, frames_count, loop_start, loop_end
SELECT name, frames_count, loop_start, loop_end, frames, repeat_count, flags
FROM anim_event WHERE ann_sha1 = ? ORDER BY rowid
""")) {
st.setString(1, sha1);
@@ -882,6 +1059,16 @@ public final class WebServer {
row.addProperty("frames_count", rs.getInt("frames_count"));
row.addProperty("loop_start", rs.getInt("loop_start"));
row.addProperty("loop_end", rs.getInt("loop_end"));
row.addProperty("repeat_count", rs.getInt("repeat_count"));
row.addProperty("flags", rs.getInt("flags"));
JsonArray frames = new JsonArray();
String encoded = rs.getString("frames");
if (encoded != null && !encoded.isBlank()) {
for (String value : encoded.split(",")) {
frames.add(parseInt(value, 0));
}
}
row.add("frames", frames);
events.add(row);
}
}
@@ -900,6 +1087,21 @@ public final class WebServer {
return array;
}
private static void bind(PreparedStatement statement, List<Object> values)
throws Exception {
for (int i = 0; i < values.size(); i++) {
statement.setObject(i + 1, values.get(i));
}
}
private static String baseName(String path) {
if (path == null) {
return null;
}
int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
return slash >= 0 ? path.substring(slash + 1) : path;
}
private long scalar(String sql) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement(sql);
ResultSet rs = st.executeQuery()) {
+510 -59
View File
@@ -6,13 +6,16 @@
<title>rex-catalog</title>
<style>
:root {
--bg: #f6f5f2; --panel: #fff; --line: #ddd8cf; --ink: #2b2822; --dim: #7a7368;
--accent: #8a5a2b; --hl: #f2e6c9; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
--bg: #f4f2ed; --panel: #fffefa; --panel-2: #ebe8e0; --line: #d9d4c9;
--ink: #292721; --dim: #736e64; --accent: #9b5c28; --accent-soft: #f2e3d1;
--hl: #f2e6d5; --shadow: 0 8px 30px rgba(55, 45, 31, .08);
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1815; --panel: #232019; --line: #3a352c; --ink: #e8e2d6; --dim: #948b7c;
--accent: #d3a05e; --hl: #3d3323;
--bg: #171613; --panel: #211f1a; --panel-2: #2a2721; --line: #3c3830;
--ink: #eee9df; --dim: #aaa294; --accent: #dfa45f; --accent-soft: #3a2c20;
--hl: #342b21; --shadow: 0 12px 34px rgba(0, 0, 0, .24);
}
}
* { box-sizing: border-box; }
@@ -21,19 +24,23 @@ body {
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
header {
padding: 14px 20px; border-bottom: 1px solid var(--line); background: var(--panel);
display: flex; gap: 20px; align-items: baseline; flex-wrap: wrap;
min-height: 62px; padding: 10px 18px; border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--panel) 94%, transparent);
display: flex; gap: 18px; align-items: center; position: sticky; top: 0; z-index: 8;
backdrop-filter: blur(14px);
}
h1 { margin: 0; font-size: 17px; letter-spacing: .02em; }
h1 { margin: 0; font-size: 18px; letter-spacing: -.02em; white-space: nowrap; }
h1 span { color: var(--accent); }
#stats { color: var(--dim); font-size: 12.5px; }
nav { margin-left: auto; display: flex; gap: 4px; }
#stats { color: var(--dim); font-size: 12px; white-space: nowrap; }
nav { margin-left: auto; display: flex; gap: 3px; padding: 3px; border-radius: 9px;
background: var(--panel-2); }
nav button {
border: 1px solid var(--line); background: transparent; color: var(--ink);
padding: 5px 12px; border-radius: 5px; cursor: pointer; font-size: 13px;
border: 0; background: transparent; color: var(--dim);
padding: 7px 12px; border-radius: 7px; cursor: pointer; font-size: 13px;
}
nav button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
main { padding: 18px 20px; max-width: 1500px; }
nav button:hover { color: var(--ink); }
nav button.on { background: var(--panel); color: var(--ink); box-shadow: 0 1px 5px rgba(0,0,0,.12); }
main { padding: 16px 18px; max-width: 1800px; margin: 0 auto; }
/* własna reguła display bije [hidden] z arkusza przeglądarki, więc sekcja
z klasą .cols pokazywałaby się mimo ukrycia — stąd to wymuszenie */
[hidden] { display: none !important; }
@@ -118,6 +125,129 @@ button.akcja { border: 1px solid var(--line); background: var(--panel); color: v
padding: 6px 14px; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12.5px; }
button.akcja:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button.akcja:disabled { opacity: .45; cursor: default; }
/* Główna przeglądarka zasobów. Trzy kolumny zachowują kontekst: wybór kolekcji,
wyniki i szczegóły zaznaczonego elementu są widoczne jednocześnie. */
.asset-layout { display: grid; grid-template-columns: 218px minmax(440px, 1fr) 330px;
gap: 14px; min-height: calc(100vh - 95px); align-items: stretch; }
.asset-sidebar, .asset-browser, .asset-inspector {
background: var(--panel); border: 1px solid var(--line); border-radius: 11px;
min-width: 0;
}
.asset-sidebar { padding: 14px 10px; }
.side-heading { margin: 4px 8px 7px; color: var(--dim); font-size: 10.5px;
font-weight: 700; letter-spacing: .09em; text-transform: uppercase; }
.side-heading:not(:first-child) { margin-top: 22px; }
.side-button { display: flex; width: 100%; align-items: center; gap: 8px; border: 0;
padding: 7px 9px; border-radius: 7px; background: transparent; color: var(--ink);
cursor: pointer; text-align: left; font: inherit; font-size: 12.5px; }
.side-button:hover { background: var(--panel-2); }
.side-button.on { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
.side-button .count { margin-left: auto; color: var(--dim); font-size: 11px;
font-variant-numeric: tabular-nums; }
.kind-dot { width: 8px; height: 8px; border-radius: 3px; background: var(--line); flex: 0 0 auto; }
.side-button.on .kind-dot { background: var(--accent); }
.game-buttons { max-height: calc(100vh - 390px); overflow-y: auto; scrollbar-width: thin; }
.asset-browser { display: flex; flex-direction: column; overflow: hidden; }
.asset-toolbar { padding: 13px 14px; border-bottom: 1px solid var(--line); }
.asset-title-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
.asset-title-row h2 { font-size: 16px; margin: 0; }
.asset-title-row .result-count { color: var(--dim); font-size: 12px; }
.asset-controls { display: grid; grid-template-columns: minmax(180px, 1fr) 135px; gap: 8px; }
.search-wrap { position: relative; }
.search-wrap::before { content: '⌕'; color: var(--dim); position: absolute; left: 11px; top: 5px;
font-size: 19px; pointer-events: none; }
.search-wrap input { padding-left: 34px; background: var(--bg); }
.asset-content { flex: 1; padding: 14px; overflow: auto; }
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 11px; align-content: start; }
.asset-card { display: block; padding: 0; overflow: hidden; border: 1px solid var(--line);
border-radius: 9px; background: var(--bg); color: var(--ink); cursor: pointer;
text-align: left; min-width: 0; font: inherit; transition: border-color .12s, transform .12s,
box-shadow .12s; }
.asset-card:hover { border-color: color-mix(in srgb, var(--accent) 68%, var(--line));
transform: translateY(-1px); box-shadow: var(--shadow); }
.asset-card.on { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.asset-thumb { height: 122px; display: flex; align-items: center; justify-content: center;
background-color: var(--panel-2); overflow: hidden; }
.asset-thumb img { width: 100%; height: 100%; display: block; object-fit: contain; }
.asset-audio-art { width: 58px; height: 58px; border-radius: 50%; display: grid; place-items: center;
color: var(--accent); background: var(--accent-soft); font-size: 25px; }
.asset-card-body { padding: 8px 9px 9px; }
.asset-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--mono); font-size: 11.5px; font-weight: 650; }
.asset-game { color: var(--dim); font-size: 10.5px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
.asset-badges { display: flex; gap: 4px; align-items: center; margin-top: 6px; }
.asset-badge { padding: 1px 5px; border-radius: 4px; background: var(--panel-2);
color: var(--dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .03em; }
.asset-badge.frames { color: var(--accent); background: var(--accent-soft); }
.asset-more { padding: 18px 0 5px; text-align: center; }
.skeleton { min-height: 180px; border-radius: 9px; background: linear-gradient(90deg,
var(--panel-2), var(--bg), var(--panel-2)); background-size: 200% 100%;
animation: shimmer 1.2s infinite; }
@keyframes shimmer { to { background-position: -200% 0; } }
.asset-inspector { overflow: auto; position: sticky; top: 78px; max-height: calc(100vh - 95px); }
.inspector-empty { height: 100%; min-height: 340px; display: grid; place-items: center;
padding: 28px; color: var(--dim); text-align: center; }
.inspector-hero { min-height: 235px; display: flex; align-items: center; justify-content: center;
background-color: var(--panel-2); border-bottom: 1px solid var(--line); overflow: hidden;
border-radius: 10px 10px 0 0; }
.inspector-hero img { display: block; max-width: 100%; max-height: 330px; object-fit: contain; }
.inspector-body { padding: 15px; }
.inspector-body h2 { font-family: var(--mono); font-size: 13px; overflow-wrap: anywhere;
margin-bottom: 4px; }
.inspector-sub { color: var(--dim); font-size: 11.5px; margin-bottom: 14px; }
.inspector-actions { display: flex; gap: 7px; margin-bottom: 14px; }
.inspector-section { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 12px; }
.inspector-section h3 { margin-top: 0; }
.path-list { font-size: 10.5px; font-family: var(--mono); color: var(--dim);
overflow-wrap: anywhere; }
.inspector-player { width: calc(100% - 24px); margin: 12px; }
.anim-player { min-width: 0; }
.anim-stage { min-height: 235px; max-height: 58vh; display: flex; align-items: center;
justify-content: center; background-color: var(--panel-2); overflow: hidden; }
.anim-stage img { display: block; max-width: 100%; max-height: 58vh; object-fit: contain;
image-rendering: auto; }
.anim-toolbar { display: flex; align-items: center; gap: 7px; padding: 9px 10px;
border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: var(--panel); }
.anim-toolbar .frame-slider { flex: 1; min-width: 60px; }
.anim-toolbar select { width: auto; padding: 5px 7px; font-size: 11px; }
.anim-play { width: 31px; height: 29px; padding: 0 !important; font-size: 15px !important; }
.anim-counter { min-width: 76px; color: var(--dim); font: 10.5px var(--mono);
text-align: right; white-space: nowrap; }
.anim-loop { display: flex; align-items: center; gap: 4px; color: var(--dim);
font-size: 10.5px; white-space: nowrap; cursor: pointer; }
.anim-events { padding: 12px 14px 14px; }
.anim-event-list { display: flex; flex-wrap: wrap; gap: 5px; max-height: 180px;
overflow: auto; scrollbar-width: thin; }
.anim-event { border: 1px solid var(--line); border-radius: 6px; padding: 4px 7px;
background: var(--bg); color: var(--ink); cursor: pointer; font: 10.5px var(--mono); }
.anim-event:hover { border-color: var(--accent); }
.anim-event.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.anim-event small { color: var(--dim); font: 9px system-ui, sans-serif; }
.anim-event-info { min-height: 18px; margin-top: 7px; color: var(--dim); font-size: 10.5px; }
.asset-inspector .anim-stage { border-radius: 10px 10px 0 0; }
.asset-inspector .anim-events { border-bottom: 1px solid var(--line); }
@media (max-width: 1180px) {
.asset-layout { grid-template-columns: 190px minmax(400px, 1fr) 290px; }
.asset-grid { grid-template-columns: repeat(auto-fill, minmax(135px, 1fr)); }
}
@media (max-width: 900px) {
header { flex-wrap: wrap; }
#stats { display: none; }
nav { order: 3; width: 100%; overflow-x: auto; margin: 0; }
main { padding: 10px; }
.asset-layout { grid-template-columns: 1fr; }
.asset-sidebar { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.side-heading { grid-column: 1 / -1; }
.game-buttons { grid-column: 1 / -1; max-height: 150px; }
.asset-inspector { position: static; max-height: none; }
}
</style>
</head>
<body>
@@ -126,15 +256,66 @@ button.akcja:disabled { opacity: .45; cursor: default; }
<h1>rex<span>·</span>catalog</h1>
<div id="stats">wczytuję…</div>
<nav>
<button data-view="katalog" class="on">Katalog</button>
<button data-view="zasoby" class="on">Zasoby</button>
<button data-view="skrypty">Skrypty</button>
<button data-view="dzwiek">Dźwięk</button>
<button data-view="formaty">Formaty</button>
<button data-view="katalog">Kolekcja</button>
<button data-view="formaty">Statystyki</button>
</nav>
</header>
<main>
<section id="katalog" class="cols">
<section id="zasoby" class="asset-layout">
<aside class="asset-sidebar">
<div class="side-heading">Rodzaj zasobu</div>
<button class="side-button on" data-kind="visual"><span class="kind-dot"></span>
Grafika <span class="count" id="licz-grafika"></span></button>
<button class="side-button" data-kind="image"><span class="kind-dot"></span>
Obrazy <span class="count" id="licz-obrazy"></span></button>
<button class="side-button" data-kind="anim"><span class="kind-dot"></span>
Animacje <span class="count" id="licz-animacje"></span></button>
<button class="side-button" data-kind="audio"><span class="kind-dot"></span>
Nagrania <span class="count" id="licz-audio"></span></button>
<div class="side-heading">Gra</div>
<div class="game-buttons" id="gryzasobow">
<button class="side-button on" data-edition=""><span>Wszystkie gry</span></button>
</div>
</aside>
<div class="asset-browser">
<div class="asset-toolbar">
<div class="asset-title-row">
<h2 id="tytulzasobow">Wszystkie zasoby</h2>
<span class="result-count" id="licznikzasobow">wczytuję…</span>
</div>
<div class="asset-controls">
<div class="search-wrap">
<input type="search" id="szukajzasobow" placeholder="Szukaj po nazwie lub ścieżce…"
autocomplete="off">
</div>
<select id="sortzasobow" aria-label="Sortowanie zasobów">
<option value="name">według nazwy</option>
<option value="game">według gry</option>
<option value="largest">największe</option>
</select>
</div>
</div>
<div class="asset-content" id="przewijakzasobow">
<div class="asset-grid" id="siatkazasobow"></div>
<div class="asset-more" id="wiecejzasobow"></div>
</div>
</div>
<aside class="asset-inspector" id="inspektorzasobu">
<div class="inspector-empty">
<div><div style="font-size:30px;margin-bottom:8px"></div>
Wybierz zasób, żeby zobaczyć podgląd, parametry i miejsca użycia.</div>
</div>
</aside>
</section>
<section id="katalog" hidden class="cols">
<div>
<div class="card">
<input type="search" id="filtr" placeholder="Filtruj tytuły…" autocomplete="off">
@@ -185,8 +366,10 @@ button.akcja:disabled { opacity: .45; cursor: default; }
</section>
<section id="formaty" hidden>
<div class="card" style="max-width:620px">
<h2>Rozkład formatów</h2>
<div class="card" style="max-width:720px">
<h2>Statystyki kolekcji</h2>
<div id="pelneStatystyki" class="dim" style="font-size:12.5px;margin:7px 0 18px"></div>
<h3>Rozkład formatów</h3>
<table><thead><tr><th>Format</th><th>Plików</th><th>Rozmiar</th><th></th></tr></thead>
<tbody id="tabformaty"></tbody></table>
</div>
@@ -210,10 +393,291 @@ let tytuly = [];
// ---- nawigacja ----
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
if (b.dataset.view !== 'zasoby') zatrzymajWszystkieAnimacje();
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b));
['katalog','skrypty','dzwiek','formaty'].forEach(v => $('#'+v).hidden = v !== b.dataset.view);
['zasoby','katalog','skrypty','dzwiek','formaty'].forEach(v =>
$('#'+v).hidden = v !== b.dataset.view);
});
// ---- zasoby ----
const stanZasobow = {
kind: 'visual', edition: null, q: '', sort: 'name', offset: 0, total: 0,
loading: false, selected: null, items: new Map()
};
const nazwyRodzajow = {
visual: 'Grafika', image: 'Obrazy', anim: 'Animacje', audio: 'Nagrania'
};
let czekajZasoby;
function rysujGryZasobow() {
$('#gryzasobow').innerHTML = `
<button class="side-button ${stanZasobow.edition == null ? 'on' : ''}" data-edition="">
<span>Wszystkie gry</span></button>` +
tytuly.map(t => `<button class="side-button ${stanZasobow.edition == t.edition_id ? 'on' : ''}"
data-edition="${t.edition_id}" title="${esc(t.title)}">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.title)}</span>
</button>`).join('');
$('#gryzasobow').querySelectorAll('[data-edition]').forEach(b => b.onclick = () => {
stanZasobow.edition = b.dataset.edition ? +b.dataset.edition : null;
$('#gryzasobow').querySelectorAll('.side-button').forEach(x => x.classList.toggle('on', x === b));
aktualizujTytulZasobow();
ladujZasoby(true);
});
}
function aktualizujTytulZasobow() {
const gra = tytuly.find(t => t.edition_id === stanZasobow.edition);
$('#tytulzasobow').textContent = gra ? gra.title : nazwyRodzajow[stanZasobow.kind];
}
function opisKarty(a) {
if (a.kind === 'audio') return czas(a.duration_ms);
if (a.kind === 'anim') return `${a.frames || 1} kl. · ${a.width || '?'}×${a.height || '?'}`;
return `${a.width || '?'}×${a.height || '?'}`;
}
const aktywneOdtwarzacze = new Set();
function htmlOdtwarzaczaAnimacji(m, sha1, pokazZdarzenia = true) {
return `<div class="anim-player" data-animation="${sha1}">
<div class="anim-stage kratka"><img src="/api/render/${sha1}?frame=0" alt=""></div>
<div class="anim-toolbar">
<button class="akcja anim-play" data-anim-play title="Odtwórz lub zatrzymaj">▶</button>
<input class="frame-slider" data-anim-slider type="range" min="0"
max="${Math.max(0, m.frames - 1)}" value="0" aria-label="Klatka animacji">
<span class="anim-counter" data-anim-counter>1 / ${m.frames}</span>
<select data-anim-speed title="Prędkość odtwarzania">
<option value="0.5">½×</option><option value="1" selected>1×</option>
<option value="2">2×</option>
</select>
<label class="anim-loop"><input type="checkbox" data-anim-loop checked> pętla</label>
</div>
${pokazZdarzenia && m.events?.length ? `<div class="anim-events">
<h3 style="margin-top:0">Zdarzenia (${m.events.length})</h3>
<div class="anim-event-list">
<button class="anim-event on" data-anim-event="">Wszystkie klatki</button>
${m.events.map((e, i) => `<button class="anim-event" data-anim-event="${i}">
${esc(e.name)} <small>${e.frames_count}</small></button>`).join('')}
</div>
<div class="anim-event-info" data-anim-event-info>
Kolejno wszystkie ${m.frames} obrazów zapisanych w pliku.
</div>
</div>` : ''}
</div>`;
}
function zatrzymajOdtwarzacz(root) {
const controller = root?._animController;
if (controller) controller.stop();
}
function podlaczOdtwarzacz(root, m, sha1) {
if (!root) return;
zatrzymajOdtwarzacz(root);
const image = root.querySelector('.anim-stage img');
const play = root.querySelector('[data-anim-play]');
const slider = root.querySelector('[data-anim-slider]');
const counter = root.querySelector('[data-anim-counter]');
const speed = root.querySelector('[data-anim-speed]');
const loop = root.querySelector('[data-anim-loop]');
const eventInfo = root.querySelector('[data-anim-event-info]');
let sequence = Array.from({length: m.frames}, (_, i) => i);
let event = null, position = 0, playing = false, timer = null;
const controller = {
stop() {
playing = false;
clearTimeout(timer);
play.textContent = '▶';
aktywneOdtwarzacze.delete(controller);
}
};
root._animController = controller;
const pokaz = () => {
if (!sequence.length) return;
position = Math.max(0, Math.min(position, sequence.length - 1));
const frame = Math.max(0, Math.min(sequence[position], m.frames - 1));
image.src = `/api/render/${sha1}?frame=${frame}`;
slider.max = Math.max(0, sequence.length - 1);
slider.value = position;
counter.textContent = `${position + 1} / ${sequence.length} · #${frame + 1}`;
};
const nastepna = () => {
if (!playing) return;
let next = position + 1;
if (loop.checked && event && event.loop_end > 0 && next > event.loop_end) {
next = Math.max(0, Math.min(event.loop_start, sequence.length - 1));
} else if (next >= sequence.length) {
if (loop.checked) next = 0;
else { controller.stop(); return; }
}
position = next;
pokaz();
const fps = Math.max(1, m.fps || 16) * Number(speed.value || 1);
timer = setTimeout(nastepna, 1000 / fps);
};
const start = () => {
if (playing || sequence.length < 2) return;
if (position >= sequence.length - 1 && !loop.checked) position = 0;
playing = true;
play.textContent = '❚❚';
aktywneOdtwarzacze.add(controller);
const fps = Math.max(1, m.fps || 16) * Number(speed.value || 1);
timer = setTimeout(nastepna, 1000 / fps);
};
play.onclick = () => playing ? controller.stop() : start();
slider.oninput = () => { position = +slider.value; pokaz(); };
root.querySelectorAll('[data-anim-event]').forEach(button => button.onclick = () => {
controller.stop();
root.querySelectorAll('[data-anim-event]').forEach(x => x.classList.toggle('on', x === button));
event = button.dataset.animEvent === '' ? null : m.events[+button.dataset.animEvent];
sequence = event?.frames?.length
? event.frames.slice()
: event
? Array.from({length: Math.min(event.frames_count, m.frames)}, (_, i) => i)
: Array.from({length: m.frames}, (_, i) => i);
position = 0;
loop.checked = !event || event.loop_end > 0;
if (eventInfo) eventInfo.innerHTML = event
? `<span class="mono">${esc(event.name)}</span> · ${event.frames_count} kroków`
+ (event.loop_end > 0
? ` · pętla ${event.loop_start + 1}${event.loop_end + 1}`
: ' · bez pętli')
: `Kolejno wszystkie ${m.frames} obrazów zapisanych w pliku.`;
pokaz();
start();
});
pokaz();
}
function zatrzymajWszystkieAnimacje() {
[...aktywneOdtwarzacze].forEach(controller => controller.stop());
}
function kartaZasobu(a) {
const podglad = a.kind === 'audio'
? '<div class="asset-audio-art" aria-hidden="true">♪</div>'
: `<img class="kratka" loading="lazy" src="/api/preview/${a.sha1}" alt="">`;
return `<button class="asset-card ${stanZasobow.selected === a.sha1 ? 'on' : ''}"
data-asset="${a.sha1}" title="${esc(a.path)}">
<div class="asset-thumb">${podglad}</div>
<div class="asset-card-body">
<div class="asset-name">${esc(a.name || a.path)}</div>
<div class="asset-game">${esc(a.title || a.copy || 'bez przypisanej gry')}</div>
<div class="asset-badges">
<span class="asset-badge">${esc(a.codec || a.kind)}</span>
<span class="asset-badge ${a.kind === 'anim' ? 'frames' : ''}">${opisKarty(a)}</span>
${a.places_count > 1 ? `<span class="asset-badge" title="${a.places_count} wystąpień">×${a.places_count}</span>` : ''}
</div>
</div>
</button>`;
}
async function ladujZasoby(reset) {
if (stanZasobow.loading) return;
stanZasobow.loading = true;
if (reset) {
stanZasobow.offset = 0;
stanZasobow.items.clear();
$('#siatkazasobow').innerHTML = Array.from({length: 8}, () => '<div class="skeleton"></div>').join('');
$('#wiecejzasobow').innerHTML = '';
$('#licznikzasobow').textContent = 'wczytuję…';
}
try {
const p = new URLSearchParams({kind: stanZasobow.kind, q: stanZasobow.q,
sort: stanZasobow.sort, offset: stanZasobow.offset, limit: 80});
if (stanZasobow.edition != null) p.set('edition', stanZasobow.edition);
const r = await get('/api/assets?' + p);
if (reset) $('#siatkazasobow').innerHTML = '';
r.items.forEach(a => stanZasobow.items.set(a.sha1, a));
$('#siatkazasobow').insertAdjacentHTML('beforeend', r.items.map(kartaZasobu).join(''));
$('#siatkazasobow').querySelectorAll('[data-asset]').forEach(k =>
k.onclick = () => pokazZasobWInspektor(k.dataset.asset));
stanZasobow.offset += r.items.length;
stanZasobow.total = r.total;
$('#licznikzasobow').textContent = `${r.total.toLocaleString('pl')} zasobów`;
if (!r.items.length && reset) {
$('#siatkazasobow').innerHTML = '<div class="empty" style="grid-column:1/-1">Nic nie pasuje do filtrów.</div>';
}
$('#wiecejzasobow').innerHTML = r.has_more
? '<button class="akcja" id="wiecej">Pokaż więcej</button>' : '';
if ($('#wiecej')) $('#wiecej').onclick = () => ladujZasoby(false);
} catch (e) {
$('#siatkazasobow').innerHTML = `<div class="empty" style="grid-column:1/-1">Błąd: ${esc(e.message)}</div>`;
} finally {
stanZasobow.loading = false;
}
}
async function pokazZasobWInspektor(sha1) {
zatrzymajOdtwarzacz($('#inspektorzasobu').querySelector('.anim-player'));
stanZasobow.selected = sha1;
$('#siatkazasobow').querySelectorAll('.asset-card').forEach(k =>
k.classList.toggle('on', k.dataset.asset === sha1));
const a = stanZasobow.items.get(sha1) || {};
$('#inspektorzasobu').innerHTML = '<div class="inspector-empty">Wczytuję szczegóły…</div>';
try {
const m = await get('/api/media/' + sha1);
const anim = m.kind === 'anim', audio = m.kind === 'audio';
const hero = audio
? `<audio class="inspector-player" controls autoplay preload="metadata" src="/api/audio/${sha1}"></audio>`
: anim ? htmlOdtwarzaczaAnimacji(m, sha1)
: `<div class="inspector-hero kratka"><img src="/api/render/${sha1}" alt=""></div>`;
const d = m.dialogue?.[0] || {};
$('#inspektorzasobu').innerHTML = `${hero}<div class="inspector-body">
<h2>${esc(a.name || m.places?.[0]?.path || sha1)}</h2>
<div class="inspector-sub">${esc(a.title || a.copy || '')}</div>
${!audio ? `<div class="inspector-actions">
<button class="akcja" id="pelnyPodglad">Pełny podgląd</button>
</div>` : ''}
<dl>
<dt>Rodzaj</dt><dd>${m.kind === 'anim' ? 'animacja' : m.kind === 'image' ? 'obraz' : 'nagranie'}</dd>
<dt>Format</dt><dd>${esc(m.codec || '—')}</dd>
${audio ? `<dt>Długość</dt><dd>${czas(m.duration_ms)}</dd>
<dt>Dźwięk</dt><dd>${m.sample_rate || '—'} Hz · ${m.channels === 1 ? 'mono' : m.channels + ' kanały'}</dd>`
: `<dt>Wymiary</dt><dd>${m.width}×${m.height}</dd>`}
${anim ? `<dt>Klatki</dt><dd>${m.frames}${m.fps ? ' · ' + m.fps + ' kl./s' : ''}</dd>` : ''}
${d.speaker ? `<dt>Mówi</dt><dd>${esc(d.speaker)}</dd>` : ''}
${m.author ? `<dt>Autor</dt><dd>${esc(m.author)}</dd>` : ''}
</dl>
${m.transcript ? `<div class="inspector-section"><h3>Co słychać</h3>
<div>${esc(m.transcript)}</div><div class="maszyna">rozpoznane maszynowo</div></div>` : ''}
${m.used_by?.length ? `<div class="inspector-section"><h3>Używane w skryptach (${m.used_by.length})</h3>
${m.used_by.slice(0, 10).map(u => `<div style="padding:2px 0;font-size:12px">
<a href="#" data-skrypt="${u.script_sha1}" class="mono">${esc(u.object)}</a>
<span class="dim">:${esc(u.field)}</span></div>`).join('')}</div>` : ''}
<div class="inspector-section"><h3>Miejsca (${m.places?.length || 0})</h3>
<div class="path-list">${(m.places || []).slice(0, 12).map(p =>
`${esc(p.path)}<br><span class="dim">${esc(p.copy)}</span>`).join('<br><br>')}</div></div>
<div class="inspector-section"><div class="mono dim" style="font-size:9.5px">sha1 ${esc(sha1)}</div></div>
</div>`;
if ($('#pelnyPodglad')) $('#pelnyPodglad').onclick = () => otworzZasob(sha1);
if (anim) podlaczOdtwarzacz($('#inspektorzasobu').querySelector('.anim-player'), m, sha1);
$('#inspektorzasobu').querySelectorAll('[data-skrypt]').forEach(link =>
link.onclick = e => { e.preventDefault(); otworzSkrypt(link.dataset.skrypt); });
} catch (e) {
$('#inspektorzasobu').innerHTML = `<div class="inspector-empty">Nie udało się wczytać zasobu:<br>${esc(e.message)}</div>`;
}
}
document.querySelectorAll('[data-kind]').forEach(b => b.onclick = () => {
stanZasobow.kind = b.dataset.kind;
document.querySelectorAll('[data-kind]').forEach(x => x.classList.toggle('on', x === b));
aktualizujTytulZasobow();
ladujZasoby(true);
});
$('#szukajzasobow').oninput = e => {
clearTimeout(czekajZasoby);
stanZasobow.q = e.target.value.trim();
czekajZasoby = setTimeout(() => ladujZasoby(true), 250);
};
$('#sortzasobow').onchange = e => { stanZasobow.sort = e.target.value; ladujZasoby(true); };
// ---- katalog ----
function rysujTytuly(filtr = '') {
const f = filtr.toLowerCase();
@@ -529,12 +993,9 @@ async function otworzNagranie(sha1) {
}
// ---- podgląd grafiki ----
let klatki = null;
async function otworzZasob(sha1) {
const m = await get('/api/media/' + sha1);
const anim = m.kind === 'anim';
klatki = anim ? {sha1, ile: m.frames, teraz: 0} : null;
$('#zaslona').innerHTML = `<div>
<div style="display:flex;align-items:baseline;gap:10px;margin-bottom:10px">
@@ -544,13 +1005,8 @@ async function otworzZasob(sha1) {
</div>
${m.note && m.note.startsWith('nieczytelny')
? `<div class="empty">${esc(m.note)}</div>`
: `<img class="duzy kratka" src="/api/render/${sha1}${anim ? '?frame=0' : ''}" alt="">`}
${anim && m.frames > 1 ? `<div class="postep" style="margin-top:12px">
<button class="akcja" id="poprz"></button>
<input type="range" id="suwak" min="0" max="${m.frames - 1}" value="0" style="flex:1">
<button class="akcja" id="nast"></button>
<span class="mono" style="font-size:12px" id="numer">1 / ${m.frames}</span>
</div>` : ''}
: anim ? htmlOdtwarzaczaAnimacji(m, sha1)
: `<img class="duzy kratka" src="/api/render/${sha1}" alt="">`}
<dl style="margin-top:12px">
<dt>Wymiary</dt><dd>${m.width}×${m.height}</dd>
${anim ? `<dt>Klatki</dt><dd>${m.frames}${m.fps ? ' · ' + m.fps + ' kl./s' : ''}</dd>` : ''}
@@ -559,12 +1015,6 @@ async function otworzZasob(sha1) {
${m.author ? `<dt>Autor</dt><dd>${esc(m.author)}</dd>` : ''}
<dt>sha1</dt><dd class="mono" style="font-size:11px">${esc(sha1)}</dd>
</dl>
${m.events && m.events.length ? `<h3>Zdarzenia (${m.events.length})</h3>
<div style="columns:3;column-gap:18px;font-size:12.5px">
${m.events.map(e => `<div style="break-inside:avoid;padding:1px 0">
<span class="mono">${esc(e.name)}</span>
<span class="dim">${e.frames_count} kl.</span></div>`).join('')}
</div>` : ''}
${m.used_by && m.used_by.length ? `<h3>Używane przez ${m.used_by.length} obiektów</h3>
<div class="dim" style="font-size:12px">${m.used_by.slice(0, 10)
.map(u => esc(u.object)).join(', ')}</div>` : ''}
@@ -573,34 +1023,27 @@ async function otworzZasob(sha1) {
$('#zamknij').onclick = zamknijZaslone;
$('#zaslona').onclick = e => { if (e.target.id === 'zaslona') zamknijZaslone(); };
const suwak = $('#suwak');
if (suwak) {
const pokaz = n => {
klatki.teraz = Math.max(0, Math.min(n, klatki.ile - 1));
suwak.value = klatki.teraz;
$('#numer').textContent = (klatki.teraz + 1) + ' / ' + klatki.ile;
$('#zaslona').querySelector('img.duzy').src =
`/api/render/${klatki.sha1}?frame=${klatki.teraz}`;
};
suwak.oninput = () => pokaz(+suwak.value);
$('#poprz').onclick = () => pokaz(klatki.teraz - 1);
$('#nast').onclick = () => pokaz(klatki.teraz + 1);
}
if (anim) podlaczOdtwarzacz($('#zaslona').querySelector('.anim-player'), m, sha1);
}
function zamknijZaslone() {
zatrzymajOdtwarzacz($('#zaslona').querySelector('.anim-player'));
$('#zaslona').hidden = true;
$('#zaslona').innerHTML = '';
klatki = null;
}
document.addEventListener('keydown', e => {
if ($('#zaslona').hidden) return;
if (e.key === 'Escape') zamknijZaslone();
if (klatki && $('#suwak')) {
if (e.key === 'ArrowLeft') $('#poprz').click();
if (e.key === 'ArrowRight') $('#nast').click();
const slider = $('#zaslona').querySelector('[data-anim-slider]');
if (slider && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
slider.value = Math.max(0, Math.min(+slider.max,
+slider.value + (e.key === 'ArrowLeft' ? -1 : 1)));
slider.dispatchEvent(new Event('input'));
}
if (slider && e.key === ' ' && !['INPUT','SELECT','BUTTON'].includes(e.target.tagName)) {
e.preventDefault();
$('#zaslona').querySelector('[data-anim-play]').click();
}
});
@@ -663,12 +1106,17 @@ function sledzPostep() {
(async () => {
const s = await get('/api/stats');
$('#stats').textContent =
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików `
+ `· ${s.blobs.toLocaleString('pl')} unikalnych · ${s.scripts} skryptów `
+ `· ${s.audio.toLocaleString('pl')} nagrań (${(s.audio_ms/3600000).toFixed(1)} h) `
+ `· ${s.images.toLocaleString('pl')} obrazów · ${s.animations.toLocaleString('pl')} animacji `
+ `(${s.anim_frames.toLocaleString('pl')} klatek) `
+ `· ${s.refs.toLocaleString('pl')} powiązań · ${mb(s.bytes)}`;
`${s.files.toLocaleString('pl')} plików · ${s.copies} kopii · ${mb(s.bytes)}`;
$('#licz-grafika').textContent = (s.images + s.animations).toLocaleString('pl');
$('#licz-obrazy').textContent = s.images.toLocaleString('pl');
$('#licz-animacje').textContent = s.animations.toLocaleString('pl');
$('#licz-audio').textContent = s.audio.toLocaleString('pl');
$('#pelneStatystyki').textContent =
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików · `
+ `${s.blobs.toLocaleString('pl')} unikalnych treści · ${s.scripts.toLocaleString('pl')} skryptów · `
+ `${s.audio.toLocaleString('pl')} nagrań (${(s.audio_ms/3600000).toFixed(1)} h) · `
+ `${s.images.toLocaleString('pl')} obrazów · ${s.animations.toLocaleString('pl')} animacji `
+ `(${s.anim_frames.toLocaleString('pl')} klatek) · ${s.refs.toLocaleString('pl')} powiązań · ${mb(s.bytes)}`;
const max = Math.max(...s.formats.map(f => f.n));
$('#tabformaty').innerHTML = s.formats.map(f => `
@@ -683,6 +1131,9 @@ function sledzPostep() {
tytuly = await get('/api/titles');
rysujTytuly();
rysujGryZasobow();
aktualizujTytulZasobow();
await ladujZasoby(true);
$('#filtr').oninput = e => rysujTytuly(e.target.value);
})().catch(e => $('#stats').textContent = 'Błąd: ' + e.message);
</script>