Compare commits
2
Commits
20ce26364c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a870a60bb | ||
|
|
c2036e1f72 |
@@ -0,0 +1,168 @@
|
|||||||
|
# rex-catalog
|
||||||
|
|
||||||
|
Katalog kolekcji gier Aidem Media — serii *Reksio* i *Poznaj Mity*. Indeksuje obrazy płyt,
|
||||||
|
zagląda do środka i pozwala je przeglądać: odszyfrowane skrypty, odtwarzalne kwestie
|
||||||
|
mówione, podgląd grafiki i animacji oraz powiązania między nimi.
|
||||||
|
|
||||||
|
Powstał do celów archiwizacyjnych i testów emulatora. Niczego nie modyfikuje w kolekcji —
|
||||||
|
obrazy płyt otwiera wyłącznie do odczytu, a wszystko, co wytworzy, ląduje w osobnym
|
||||||
|
katalogu `data/`.
|
||||||
|
|
||||||
|
## Skąd bierze wiedzę o formatach
|
||||||
|
|
||||||
|
Formaty Aidem — zaszyfrowane skrypty CNV, obrazy `PIK\0`, animacje `NVP\0`, kompresje
|
||||||
|
CLZW i CRLE — czyta przez `:core` z [Rex-EMoolatora](https://github.com/patryk025/Rex-EMoolator),
|
||||||
|
podpiętego jako **composite build**, a nie fork. Emulator jest przypięty submodułem na
|
||||||
|
konkretnym tagu (`gradle.properties` → `coreVersion`), a zadanie `verifyCoreVersion`
|
||||||
|
pilnuje, żeby tag i submoduł się nie rozjechały.
|
||||||
|
|
||||||
|
Dzięki temu wiedza o formatach ma jedno źródło. Tam, gdzie katalog musiał napisać własny
|
||||||
|
parser, jest to powiedziane wprost w komentarzu razem z powodem — najczęściej dlatego,
|
||||||
|
że loader z `:core` buduje tekstury OpenGL-a i w procesie bez okna nie da się go użyć.
|
||||||
|
|
||||||
|
## Jak to jest poukładane
|
||||||
|
|
||||||
|
**Trzy poziomy tożsamości.** `title` (dzieło) → `edition` (wydanie) → `copy` (konkretny
|
||||||
|
plik na dysku). Między wydaniem a kopią stoi jeszcze `game_root`, bo jedna płyta bywa
|
||||||
|
dwupackiem z dwiema grami, każdą z własnym silnikiem.
|
||||||
|
|
||||||
|
**Plik osobno, treść osobno.** `file` mówi, gdzie coś leży, `blob` — czym jest. Porównanie
|
||||||
|
dwóch wydań jest więc operacją na zbiorach odcisków, a wszystko, co wytworzone
|
||||||
|
(odszyfrowany skrypt, miniatura), kluczuje się treścią: ten sam plik na trzech płytach
|
||||||
|
przerabiamy raz.
|
||||||
|
|
||||||
|
**Fakty z dowodem.** Detektory nie nadpisują pól, tylko dopisują wiersze do `detection`
|
||||||
|
z wartością, pewnością i dowodem. Poprawki człowieka siedzą osobno, w polach `*_override`,
|
||||||
|
i nigdy nie są nadpisywane przez kolejny przebieg.
|
||||||
|
|
||||||
|
**Nic nie wypakowujemy.** Dźwięk i klatki animacji czytamy z obrazu płyty na żądanie,
|
||||||
|
sięgając od razu na właściwą pozycję. W `data/` zostaje tylko to, co drogo policzyć:
|
||||||
|
odszyfrowane skrypty i miniatury.
|
||||||
|
|
||||||
|
## Uruchomienie
|
||||||
|
|
||||||
|
Potrzebne: **JDK 21** i submoduł z emulatorem.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git submodule update --init --recursive
|
||||||
|
./gradlew installDist
|
||||||
|
```
|
||||||
|
|
||||||
|
Dalej wygodnie przez launcher (`build/install/rex-catalog/bin/rex-catalog`) albo
|
||||||
|
`./gradlew run --args="..."`. Katalog bazy wskazuje `-Dcatalog.data` (domyślnie `./data`).
|
||||||
|
|
||||||
|
### Potok
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rex-catalog ingest /ścieżka/do/kolekcji # indeksuje obrazy, katalogi gier i całe kolekcje
|
||||||
|
rex-catalog decode # odszyfrowuje skrypty do cache'u i indeksu FTS5
|
||||||
|
rex-catalog probe # dźwięk, grafika, dialogi, powiązania
|
||||||
|
rex-catalog analyze # metadane z application.def i install.ini
|
||||||
|
rex-catalog promote # tworzy tytuły i wydania z wykrytych faktów
|
||||||
|
```
|
||||||
|
|
||||||
|
Każdy krok jest **przyrostowy** — dołożenie nowej płyty przerabia tylko ją. Ponowne
|
||||||
|
uruchomienie bez `--force` nie robi nic, jeśli nic się nie zmieniło.
|
||||||
|
|
||||||
|
`ingest` przyjmuje obraz `.iso` (ISO 9660, Joliet i UDF), archiwum `.zip`, katalog
|
||||||
|
rozpakowanej gry albo katalog z całą kolekcją.
|
||||||
|
|
||||||
|
### Przeglądanie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rex-catalog serve # front i MCP na http://127.0.0.1:8765
|
||||||
|
rex-catalog find CANVAS_OBSERVER # przeszukanie skryptów (składnia FTS5)
|
||||||
|
rex-catalog cat arcade.cnv # odszyfrowany skrypt na wyjście
|
||||||
|
rex-catalog titles # lista wydań z metadanymi
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
odtwarza, kto to mówi i jak długo trwa.
|
||||||
|
|
||||||
|
### Metadane ręczne
|
||||||
|
|
||||||
|
Tego, czego nie da się wykryć, nie zgadujemy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rex-catalog set copy "Wojna Troj" source_kind=wlasny_zgraj rip_tool=dd
|
||||||
|
rex-catalog set edition 3 distributor="Aidem Media" release_date_override=2003-11-18
|
||||||
|
rex-catalog lang add 3 cs audio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transkrypcja mowy
|
||||||
|
|
||||||
|
Opcjonalna i uruchamiana świadomie — wymaga [whisper.cpp](https://github.com/ggerganov/whisper.cpp)
|
||||||
|
i modelu, których repozytorium nie dostarcza.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install whisper-cpp # albo dowolna inna instalacja
|
||||||
|
rex-catalog transcribe --limit 100 # -Dcatalog.whisper.model=ścieżka/do/ggml-medium.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Da się też wyklikać z zakładki **Dźwięk** — z paskiem postępu i zatrzymaniem, które nie
|
||||||
|
gubi tego, co już policzone.
|
||||||
|
|
||||||
|
Kwestie są sklejane po kilka w trzydziestosekundowe okna z sekundą ciszy między nimi,
|
||||||
|
a wynik rozcinany z powrotem po znacznikach czasu. Whisper koduje zawsze pełne okno,
|
||||||
|
więc podawanie mu czterosekundowych kwestii po jednej marnuje większość pracy.
|
||||||
|
|
||||||
|
Transkrypt jest **wygenerowany, nie odczytany z płyty**. Siedzi w osobnej tabeli razem
|
||||||
|
z nazwą modelu i narzędzia, a interfejs oznacza go jako maszynowy. Zdania, które model
|
||||||
|
dopisuje z siebie na ciszy — podpisy ekip od napisów — są odsiewane.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COLLECTION=/ścieżka/do/kolekcji docker compose up -d
|
||||||
|
docker compose exec katalog /app/entrypoint.sh ingest /media
|
||||||
|
```
|
||||||
|
|
||||||
|
Kolekcja montuje się tylko do odczytu, baza i cache lądują w wolumenie. Obraz niesie
|
||||||
|
whisper.cpp i ffmpeg, ale **nie model** — ten podmontuj pod `/models` i wskaż zmienną
|
||||||
|
`WHISPER_MODEL`.
|
||||||
|
|
||||||
|
`Dockerfile.cuda` buduje wariant z akceleracją NVIDII (domyślnie pod compute capability
|
||||||
|
8.6, czyli GeForce RTX 30). Na macOS akceleracja w kontenerze nie działa — tam transkrypcję
|
||||||
|
uruchamiaj na hoście, gdzie whisper.cpp sam korzysta z Metala.
|
||||||
|
|
||||||
|
## Przenoszenie między maszynami
|
||||||
|
|
||||||
|
Baza zapisuje bezwzględne ścieżki do obrazów płyt, bo czyta z nich dźwięk i grafikę.
|
||||||
|
Po skopiowaniu `data/` na inną maszynę trzeba ją przypiąć do kolekcji:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rex-catalog relocate D:\Reksio --dry-run # najpierw pokaż, co zrobisz
|
||||||
|
rex-catalog relocate D:\Reksio
|
||||||
|
```
|
||||||
|
|
||||||
|
Dopasowuje po nazwie i sprawdza rozmiar (`--verify` dokłada SHA-256), a nazwy porównuje po
|
||||||
|
normalizacji Unicode — bez tego kolekcja z macOS nie dogaduje się z tą samą kolekcją na
|
||||||
|
Windowsie, bo `ń` jest tam zapisane inaczej.
|
||||||
|
|
||||||
|
## Co siedzi w bazie
|
||||||
|
|
||||||
|
SQLite, jeden plik obok cache'u, więc przeniesienie katalogu to skopiowanie `data/`.
|
||||||
|
|
||||||
|
| tabela | co trzyma |
|
||||||
|
|---|---|
|
||||||
|
| `title`, `edition`, `copy`, `game_root` | tożsamość: dzieło, wydanie, nośnik, korzeń gry |
|
||||||
|
| `file`, `blob` | gdzie plik leży i czym jest |
|
||||||
|
| `detection` | fakty detektorów z dowodem i pewnością |
|
||||||
|
| `artifact` | wytworzone: odszyfrowane skrypty, miniatury |
|
||||||
|
| `media` | fakty o zasobie: długość, wymiary, klatki, kodek |
|
||||||
|
| `archive_entry` | zawartość archiwów `.snd` — kwestie spakowane w jeden plik |
|
||||||
|
| `script_ref` | odwołania skryptów do zasobów |
|
||||||
|
| `dialogue_line`, `anim_event` | mówca i zdarzenie kwestii, nazwane sekwencje animacji |
|
||||||
|
| `transcript` | rozpoznana mowa, osobno od reszty |
|
||||||
|
| `script_fts`, `transcript_fts` | indeksy pełnotekstowe |
|
||||||
|
|
||||||
|
`rex-catalog stats` pokazuje, co w danej chwili jest w środku.
|
||||||
@@ -112,6 +112,11 @@ public final class Database implements AutoCloseable {
|
|||||||
addColumnIfMissing("media", "frames", "INTEGER");
|
addColumnIfMissing("media", "frames", "INTEGER");
|
||||||
addColumnIfMissing("media", "fps", "INTEGER");
|
addColumnIfMissing("media", "fps", "INTEGER");
|
||||||
addColumnIfMissing("media", "author", "TEXT");
|
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();
|
addBasenameIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +417,9 @@ public final class Database implements AutoCloseable {
|
|||||||
frames_count INTEGER,
|
frames_count INTEGER,
|
||||||
loop_start INTEGER,
|
loop_start INTEGER,
|
||||||
loop_end INTEGER,
|
loop_end INTEGER,
|
||||||
|
frames TEXT,
|
||||||
|
repeat_count INTEGER,
|
||||||
|
flags INTEGER,
|
||||||
PRIMARY KEY (ann_sha1, name)
|
PRIMARY KEY (ann_sha1, name)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ import java.util.Set;
|
|||||||
*/
|
*/
|
||||||
public final class GraphicsIndexer {
|
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";
|
private static final String KIND = "preview";
|
||||||
|
|
||||||
@@ -93,11 +94,13 @@ public final class GraphicsIndexer {
|
|||||||
PreparedStatement eventDelete = conn.prepareStatement(
|
PreparedStatement eventDelete = conn.prepareStatement(
|
||||||
"DELETE FROM anim_event WHERE ann_sha1 = ?");
|
"DELETE FROM anim_event WHERE ann_sha1 = ?");
|
||||||
PreparedStatement eventInsert = conn.prepareStatement("""
|
PreparedStatement eventInsert = conn.prepareStatement("""
|
||||||
INSERT INTO anim_event (ann_sha1, name, frames_count, loop_start, loop_end)
|
INSERT INTO anim_event (ann_sha1, name, frames_count, loop_start, loop_end,
|
||||||
VALUES (?, ?, ?, ?, ?)
|
frames, repeat_count, flags)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(ann_sha1, name) DO UPDATE SET
|
ON CONFLICT(ann_sha1, name) DO UPDATE SET
|
||||||
frames_count = excluded.frames_count, loop_start = excluded.loop_start,
|
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()) {
|
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,
|
private void indexImage(String sha1, byte[] raw, PreparedStatement mediaStmt,
|
||||||
PreparedStatement artifactStmt) throws Exception {
|
PreparedStatement artifactStmt) throws Exception {
|
||||||
PikGraphics.Picture picture = PikGraphics.readImage(raw);
|
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(),
|
write(mediaStmt, sha1, "image", "pik" + picture.colorDepth(), picture.width(),
|
||||||
picture.height(), 1, 0, null,
|
picture.height(), 1, 0, null,
|
||||||
"kompresja " + picture.compression());
|
"kompresja " + picture.compression(), IMAGE_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int indexAnimation(String sha1, byte[] raw, PreparedStatement mediaStmt,
|
private int indexAnimation(String sha1, byte[] raw, PreparedStatement mediaStmt,
|
||||||
@@ -164,12 +167,14 @@ public final class GraphicsIndexer {
|
|||||||
PreparedStatement eventInsert) throws Exception {
|
PreparedStatement eventInsert) throws Exception {
|
||||||
PikGraphics.Animation animation = PikGraphics.readAnimation(raw);
|
PikGraphics.Animation animation = PikGraphics.readAnimation(raw);
|
||||||
if (!animation.frames().isEmpty()) {
|
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(),
|
write(mediaStmt, sha1, "anim", "nvp" + animation.colorDepth(), animation.maxWidth(),
|
||||||
animation.maxHeight(), animation.frames().size(), animation.fps(),
|
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.setString(1, sha1);
|
||||||
eventDelete.executeUpdate();
|
eventDelete.executeUpdate();
|
||||||
@@ -179,13 +184,17 @@ public final class GraphicsIndexer {
|
|||||||
eventInsert.setInt(3, event.framesCount());
|
eventInsert.setInt(3, event.framesCount());
|
||||||
eventInsert.setInt(4, event.loopStart());
|
eventInsert.setInt(4, event.loopStart());
|
||||||
eventInsert.setInt(5, event.loopEnd());
|
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();
|
eventInsert.executeUpdate();
|
||||||
}
|
}
|
||||||
return animation.frames().size();
|
return animation.frames().size();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void writePreview(String sha1, BufferedImage image, PreparedStatement artifactStmt)
|
private void writePreview(String sha1, BufferedImage image, PreparedStatement artifactStmt,
|
||||||
throws Exception {
|
String version) throws Exception {
|
||||||
String relative = cachePath(sha1);
|
String relative = cachePath(sha1);
|
||||||
Path target = dataDir.resolve(relative);
|
Path target = dataDir.resolve(relative);
|
||||||
Files.createDirectories(target.getParent());
|
Files.createDirectories(target.getParent());
|
||||||
@@ -194,7 +203,7 @@ public final class GraphicsIndexer {
|
|||||||
artifactStmt.setString(1, sha1);
|
artifactStmt.setString(1, sha1);
|
||||||
artifactStmt.setString(2, KIND);
|
artifactStmt.setString(2, KIND);
|
||||||
artifactStmt.setString(3, relative);
|
artifactStmt.setString(3, relative);
|
||||||
artifactStmt.setString(4, VERSION);
|
artifactStmt.setString(4, version);
|
||||||
artifactStmt.setString(5, Instant.now().toString());
|
artifactStmt.setString(5, Instant.now().toString());
|
||||||
artifactStmt.executeUpdate();
|
artifactStmt.executeUpdate();
|
||||||
}
|
}
|
||||||
@@ -223,12 +232,13 @@ public final class GraphicsIndexer {
|
|||||||
throws Exception {
|
throws Exception {
|
||||||
String reason = cause.getMessage() == null ? cause.toString() : cause.getMessage();
|
String reason = cause.getMessage() == null ? cause.toString() : cause.getMessage();
|
||||||
write(mediaStmt, pending.sha1(), "ANN".equals(pending.format()) ? "anim" : "image",
|
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,
|
private void write(PreparedStatement stmt, String sha1, String kind, String codec,
|
||||||
int width, int height, int frames, int fps, String author, String note)
|
int width, int height, int frames, int fps, String author, String note,
|
||||||
throws Exception {
|
String version) throws Exception {
|
||||||
stmt.setString(1, sha1);
|
stmt.setString(1, sha1);
|
||||||
stmt.setString(2, kind);
|
stmt.setString(2, kind);
|
||||||
stmt.setString(3, codec);
|
stmt.setString(3, codec);
|
||||||
@@ -238,7 +248,7 @@ public final class GraphicsIndexer {
|
|||||||
stmt.setInt(7, fps);
|
stmt.setInt(7, fps);
|
||||||
stmt.setString(8, author);
|
stmt.setString(8, author);
|
||||||
stmt.setString(9, note);
|
stmt.setString(9, note);
|
||||||
stmt.setString(10, VERSION);
|
stmt.setString(10, version);
|
||||||
stmt.setString(11, Instant.now().toString());
|
stmt.setString(11, Instant.now().toString());
|
||||||
stmt.executeUpdate();
|
stmt.executeUpdate();
|
||||||
}
|
}
|
||||||
@@ -262,13 +272,15 @@ public final class GraphicsIndexer {
|
|||||||
LEFT JOIN media m ON m.sha1 = b.sha1
|
LEFT JOIN media m ON m.sha1 = b.sha1
|
||||||
WHERE c.status <> 'unreadable' AND b.format IN ('IMG', 'ANN')
|
WHERE c.status <> 'unreadable' AND b.format IN ('IMG', 'ANN')
|
||||||
""" + (force ? "" : """
|
""" + (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";
|
""") + "ORDER BY c.id";
|
||||||
|
|
||||||
Map<String, List<Pending>> byContainer = new LinkedHashMap<>();
|
Map<String, List<Pending>> byContainer = new LinkedHashMap<>();
|
||||||
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
|
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
|
||||||
if (!force) {
|
if (!force) {
|
||||||
st.setString(1, VERSION);
|
st.setString(1, ANIMATION_VERSION);
|
||||||
|
st.setString(2, IMAGE_VERSION);
|
||||||
}
|
}
|
||||||
try (ResultSet rs = st.executeQuery()) {
|
try (ResultSet rs = st.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public final class PikGraphics {
|
|||||||
|
|
||||||
/** Zdarzenie animacji: nazwana sekwencja odsyłająca do numerów klatek. */
|
/** Zdarzenie animacji: nazwana sekwencja odsyłająca do numerów klatek. */
|
||||||
public record AnimEvent(String name, int framesCount, int loopStart, int loopEnd,
|
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,
|
public record Animation(int colorDepth, int fps, int opacity, int maxWidth, int maxHeight,
|
||||||
@@ -144,9 +144,10 @@ public final class PikGraphics {
|
|||||||
reader.skip(4);
|
reader.skip(4);
|
||||||
int loopStart = reader.u16();
|
int loopStart = reader.u16();
|
||||||
int loopEnd = 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);
|
||||||
reader.skip(4); // flagi
|
int flags = reader.i32();
|
||||||
reader.u8(); // krycie
|
reader.u8(); // krycie
|
||||||
reader.skip(12);
|
reader.skip(12);
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ public final class PikGraphics {
|
|||||||
for (int i = 0; i < framesCount; i++) {
|
for (int i = 0; i < framesCount; i++) {
|
||||||
skipFrameData(reader);
|
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ść. */
|
/** 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 MediaIndex media;
|
||||||
private final Transcriber transcriber;
|
private final Transcriber transcriber;
|
||||||
private final Object lock;
|
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) {
|
public WebServer(Database db, Path dataDir, Object lock) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
@@ -139,6 +142,9 @@ public final class WebServer {
|
|||||||
if (path.equals("/titles")) {
|
if (path.equals("/titles")) {
|
||||||
return titles();
|
return titles();
|
||||||
}
|
}
|
||||||
|
if (path.equals("/assets")) {
|
||||||
|
return assets(query);
|
||||||
|
}
|
||||||
if (path.equals("/search")) {
|
if (path.equals("/search")) {
|
||||||
return searchScripts(query.getOrDefault("q", ""),
|
return searchScripts(query.getOrDefault("q", ""),
|
||||||
parseInt(query.get("limit"), 30));
|
parseInt(query.get("limit"), 30));
|
||||||
@@ -250,19 +256,17 @@ public final class WebServer {
|
|||||||
private void serveRender(HttpExchange exchange, String sha1, int frame) throws IOException {
|
private void serveRender(HttpExchange exchange, String sha1, int frame) throws IOException {
|
||||||
byte[] png;
|
byte[] png;
|
||||||
try {
|
try {
|
||||||
MediaIndex.Clip clip;
|
|
||||||
String kind;
|
String kind;
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
clip = media.read(sha1);
|
|
||||||
kind = mediaKind(sha1);
|
kind = mediaKind(sha1);
|
||||||
}
|
}
|
||||||
if (clip == null) {
|
|
||||||
respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
java.awt.image.BufferedImage image;
|
java.awt.image.BufferedImage image;
|
||||||
if ("anim".equals(kind)) {
|
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()) {
|
if (animation.frames().isEmpty()) {
|
||||||
respond(exchange, 404, "text/plain", "Animacja bez klatek".getBytes(UTF8));
|
respond(exchange, 404, "text/plain", "Animacja bez klatek".getBytes(UTF8));
|
||||||
return;
|
return;
|
||||||
@@ -270,6 +274,14 @@ public final class WebServer {
|
|||||||
int index = Math.max(0, Math.min(frame, animation.frames().size() - 1));
|
int index = Math.max(0, Math.min(frame, animation.frames().size() - 1));
|
||||||
image = animation.frames().get(index).image();
|
image = animation.frames().get(index).image();
|
||||||
} else {
|
} 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();
|
image = PikGraphics.readImage(clip.bytes()).image();
|
||||||
}
|
}
|
||||||
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
|
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
|
||||||
@@ -286,6 +298,31 @@ public final class WebServer {
|
|||||||
exchange.getResponseBody().write(png);
|
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 {
|
private String previewPath(String sha1) throws Exception {
|
||||||
try (PreparedStatement st = db.connection().prepareStatement(
|
try (PreparedStatement st = db.connection().prepareStatement(
|
||||||
"SELECT path FROM artifact WHERE blob_sha1 = ? AND kind = 'preview'")) {
|
"SELECT path FROM artifact WHERE blob_sha1 = ? AND kind = 'preview'")) {
|
||||||
@@ -555,6 +592,146 @@ public final class WebServer {
|
|||||||
return out;
|
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 {
|
private JsonObject searchScripts(String query, int limit) throws Exception {
|
||||||
JsonObject out = new JsonObject();
|
JsonObject out = new JsonObject();
|
||||||
if (query.isBlank()) {
|
if (query.isBlank()) {
|
||||||
@@ -871,7 +1048,7 @@ public final class WebServer {
|
|||||||
|
|
||||||
JsonArray events = new JsonArray();
|
JsonArray events = new JsonArray();
|
||||||
try (PreparedStatement st = db.connection().prepareStatement("""
|
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
|
FROM anim_event WHERE ann_sha1 = ? ORDER BY rowid
|
||||||
""")) {
|
""")) {
|
||||||
st.setString(1, sha1);
|
st.setString(1, sha1);
|
||||||
@@ -882,6 +1059,16 @@ public final class WebServer {
|
|||||||
row.addProperty("frames_count", rs.getInt("frames_count"));
|
row.addProperty("frames_count", rs.getInt("frames_count"));
|
||||||
row.addProperty("loop_start", rs.getInt("loop_start"));
|
row.addProperty("loop_start", rs.getInt("loop_start"));
|
||||||
row.addProperty("loop_end", rs.getInt("loop_end"));
|
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);
|
events.add(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -900,6 +1087,21 @@ public final class WebServer {
|
|||||||
return array;
|
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 {
|
private long scalar(String sql) throws Exception {
|
||||||
try (PreparedStatement st = db.connection().prepareStatement(sql);
|
try (PreparedStatement st = db.connection().prepareStatement(sql);
|
||||||
ResultSet rs = st.executeQuery()) {
|
ResultSet rs = st.executeQuery()) {
|
||||||
|
|||||||
@@ -6,13 +6,16 @@
|
|||||||
<title>rex-catalog</title>
|
<title>rex-catalog</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #f6f5f2; --panel: #fff; --line: #ddd8cf; --ink: #2b2822; --dim: #7a7368;
|
--bg: #f4f2ed; --panel: #fffefa; --panel-2: #ebe8e0; --line: #d9d4c9;
|
||||||
--accent: #8a5a2b; --hl: #f2e6c9; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--bg: #1a1815; --panel: #232019; --line: #3a352c; --ink: #e8e2d6; --dim: #948b7c;
|
--bg: #171613; --panel: #211f1a; --panel-2: #2a2721; --line: #3c3830;
|
||||||
--accent: #d3a05e; --hl: #3d3323;
|
--ink: #eee9df; --dim: #aaa294; --accent: #dfa45f; --accent-soft: #3a2c20;
|
||||||
|
--hl: #342b21; --shadow: 0 12px 34px rgba(0, 0, 0, .24);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -21,19 +24,23 @@ body {
|
|||||||
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
padding: 14px 20px; border-bottom: 1px solid var(--line); background: var(--panel);
|
min-height: 62px; padding: 10px 18px; border-bottom: 1px solid var(--line);
|
||||||
display: flex; gap: 20px; align-items: baseline; flex-wrap: wrap;
|
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); }
|
h1 span { color: var(--accent); }
|
||||||
#stats { color: var(--dim); font-size: 12.5px; }
|
#stats { color: var(--dim); font-size: 12px; white-space: nowrap; }
|
||||||
nav { margin-left: auto; display: flex; gap: 4px; }
|
nav { margin-left: auto; display: flex; gap: 3px; padding: 3px; border-radius: 9px;
|
||||||
|
background: var(--panel-2); }
|
||||||
nav button {
|
nav button {
|
||||||
border: 1px solid var(--line); background: transparent; color: var(--ink);
|
border: 0; background: transparent; color: var(--dim);
|
||||||
padding: 5px 12px; border-radius: 5px; cursor: pointer; font-size: 13px;
|
padding: 7px 12px; border-radius: 7px; cursor: pointer; font-size: 13px;
|
||||||
}
|
}
|
||||||
nav button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
nav button:hover { color: var(--ink); }
|
||||||
main { padding: 18px 20px; max-width: 1500px; }
|
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
|
/* 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 */
|
z klasą .cols pokazywałaby się mimo ukrycia — stąd to wymuszenie */
|
||||||
[hidden] { display: none !important; }
|
[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; }
|
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:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
||||||
button.akcja:disabled { opacity: .45; cursor: default; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -126,15 +256,66 @@ button.akcja:disabled { opacity: .45; cursor: default; }
|
|||||||
<h1>rex<span>·</span>catalog</h1>
|
<h1>rex<span>·</span>catalog</h1>
|
||||||
<div id="stats">wczytuję…</div>
|
<div id="stats">wczytuję…</div>
|
||||||
<nav>
|
<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="skrypty">Skrypty</button>
|
||||||
<button data-view="dzwiek">Dźwięk</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>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<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>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<input type="search" id="filtr" placeholder="Filtruj tytuły…" autocomplete="off">
|
<input type="search" id="filtr" placeholder="Filtruj tytuły…" autocomplete="off">
|
||||||
@@ -185,8 +366,10 @@ button.akcja:disabled { opacity: .45; cursor: default; }
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="formaty" hidden>
|
<section id="formaty" hidden>
|
||||||
<div class="card" style="max-width:620px">
|
<div class="card" style="max-width:720px">
|
||||||
<h2>Rozkład formatów</h2>
|
<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>
|
<table><thead><tr><th>Format</th><th>Plików</th><th>Rozmiar</th><th></th></tr></thead>
|
||||||
<tbody id="tabformaty"></tbody></table>
|
<tbody id="tabformaty"></tbody></table>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,10 +393,291 @@ let tytuly = [];
|
|||||||
|
|
||||||
// ---- nawigacja ----
|
// ---- nawigacja ----
|
||||||
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
|
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));
|
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 ----
|
// ---- katalog ----
|
||||||
function rysujTytuly(filtr = '') {
|
function rysujTytuly(filtr = '') {
|
||||||
const f = filtr.toLowerCase();
|
const f = filtr.toLowerCase();
|
||||||
@@ -529,12 +993,9 @@ async function otworzNagranie(sha1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- podgląd grafiki ----
|
// ---- podgląd grafiki ----
|
||||||
let klatki = null;
|
|
||||||
|
|
||||||
async function otworzZasob(sha1) {
|
async function otworzZasob(sha1) {
|
||||||
const m = await get('/api/media/' + sha1);
|
const m = await get('/api/media/' + sha1);
|
||||||
const anim = m.kind === 'anim';
|
const anim = m.kind === 'anim';
|
||||||
klatki = anim ? {sha1, ile: m.frames, teraz: 0} : null;
|
|
||||||
|
|
||||||
$('#zaslona').innerHTML = `<div>
|
$('#zaslona').innerHTML = `<div>
|
||||||
<div style="display:flex;align-items:baseline;gap:10px;margin-bottom:10px">
|
<div style="display:flex;align-items:baseline;gap:10px;margin-bottom:10px">
|
||||||
@@ -544,13 +1005,8 @@ async function otworzZasob(sha1) {
|
|||||||
</div>
|
</div>
|
||||||
${m.note && m.note.startsWith('nieczytelny')
|
${m.note && m.note.startsWith('nieczytelny')
|
||||||
? `<div class="empty">${esc(m.note)}</div>`
|
? `<div class="empty">${esc(m.note)}</div>`
|
||||||
: `<img class="duzy kratka" src="/api/render/${sha1}${anim ? '?frame=0' : ''}" alt="">`}
|
: anim ? htmlOdtwarzaczaAnimacji(m, sha1)
|
||||||
${anim && m.frames > 1 ? `<div class="postep" style="margin-top:12px">
|
: `<img class="duzy kratka" src="/api/render/${sha1}" alt="">`}
|
||||||
<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>` : ''}
|
|
||||||
<dl style="margin-top:12px">
|
<dl style="margin-top:12px">
|
||||||
<dt>Wymiary</dt><dd>${m.width}×${m.height}</dd>
|
<dt>Wymiary</dt><dd>${m.width}×${m.height}</dd>
|
||||||
${anim ? `<dt>Klatki</dt><dd>${m.frames}${m.fps ? ' · ' + m.fps + ' kl./s' : ''}</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>` : ''}
|
${m.author ? `<dt>Autor</dt><dd>${esc(m.author)}</dd>` : ''}
|
||||||
<dt>sha1</dt><dd class="mono" style="font-size:11px">${esc(sha1)}</dd>
|
<dt>sha1</dt><dd class="mono" style="font-size:11px">${esc(sha1)}</dd>
|
||||||
</dl>
|
</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>
|
${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)
|
<div class="dim" style="font-size:12px">${m.used_by.slice(0, 10)
|
||||||
.map(u => esc(u.object)).join(', ')}</div>` : ''}
|
.map(u => esc(u.object)).join(', ')}</div>` : ''}
|
||||||
@@ -573,34 +1023,27 @@ async function otworzZasob(sha1) {
|
|||||||
|
|
||||||
$('#zamknij').onclick = zamknijZaslone;
|
$('#zamknij').onclick = zamknijZaslone;
|
||||||
$('#zaslona').onclick = e => { if (e.target.id === 'zaslona') zamknijZaslone(); };
|
$('#zaslona').onclick = e => { if (e.target.id === 'zaslona') zamknijZaslone(); };
|
||||||
|
if (anim) podlaczOdtwarzacz($('#zaslona').querySelector('.anim-player'), m, sha1);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function zamknijZaslone() {
|
function zamknijZaslone() {
|
||||||
|
zatrzymajOdtwarzacz($('#zaslona').querySelector('.anim-player'));
|
||||||
$('#zaslona').hidden = true;
|
$('#zaslona').hidden = true;
|
||||||
$('#zaslona').innerHTML = '';
|
$('#zaslona').innerHTML = '';
|
||||||
klatki = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if ($('#zaslona').hidden) return;
|
if ($('#zaslona').hidden) return;
|
||||||
if (e.key === 'Escape') zamknijZaslone();
|
if (e.key === 'Escape') zamknijZaslone();
|
||||||
if (klatki && $('#suwak')) {
|
const slider = $('#zaslona').querySelector('[data-anim-slider]');
|
||||||
if (e.key === 'ArrowLeft') $('#poprz').click();
|
if (slider && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
||||||
if (e.key === 'ArrowRight') $('#nast').click();
|
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 () => {
|
(async () => {
|
||||||
const s = await get('/api/stats');
|
const s = await get('/api/stats');
|
||||||
$('#stats').textContent =
|
$('#stats').textContent =
|
||||||
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików `
|
`${s.files.toLocaleString('pl')} plików · ${s.copies} kopii · ${mb(s.bytes)}`;
|
||||||
+ `· ${s.blobs.toLocaleString('pl')} unikalnych · ${s.scripts} skryptów `
|
$('#licz-grafika').textContent = (s.images + s.animations).toLocaleString('pl');
|
||||||
+ `· ${s.audio.toLocaleString('pl')} nagrań (${(s.audio_ms/3600000).toFixed(1)} h) `
|
$('#licz-obrazy').textContent = s.images.toLocaleString('pl');
|
||||||
+ `· ${s.images.toLocaleString('pl')} obrazów · ${s.animations.toLocaleString('pl')} animacji `
|
$('#licz-animacje').textContent = s.animations.toLocaleString('pl');
|
||||||
+ `(${s.anim_frames.toLocaleString('pl')} klatek) `
|
$('#licz-audio').textContent = s.audio.toLocaleString('pl');
|
||||||
+ `· ${s.refs.toLocaleString('pl')} powiązań · ${mb(s.bytes)}`;
|
$('#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));
|
const max = Math.max(...s.formats.map(f => f.n));
|
||||||
$('#tabformaty').innerHTML = s.formats.map(f => `
|
$('#tabformaty').innerHTML = s.formats.map(f => `
|
||||||
@@ -683,6 +1131,9 @@ function sledzPostep() {
|
|||||||
|
|
||||||
tytuly = await get('/api/titles');
|
tytuly = await get('/api/titles');
|
||||||
rysujTytuly();
|
rysujTytuly();
|
||||||
|
rysujGryZasobow();
|
||||||
|
aktualizujTytulZasobow();
|
||||||
|
await ladujZasoby(true);
|
||||||
$('#filtr').oninput = e => rysujTytuly(e.target.value);
|
$('#filtr').oninput = e => rysujTytuly(e.target.value);
|
||||||
})().catch(e => $('#stats').textContent = 'Błąd: ' + e.message);
|
})().catch(e => $('#stats').textContent = 'Błąd: ' + e.message);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user