Show soundtrack covers and edit alternative names (#49)
Build / test (push) Successful in 1m43s
Build / build (push) Successful in 1m14s

Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.

Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.

The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.

Closes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-14 22:06:30 +02:00
1 parent a735c515bf
commit ef6ac8f1fd
37 files changed
+8029 -1763

No files matched your search

+14 -62
View File
@@ -14,15 +14,15 @@ extends RefCounted
## can't be reached at all, the cached pictures are used as they are.
const FOLDER_NAME: String = "characters"
const INDEX_FILE_NAME: String = "index.json"
const INDEX_FILE_NAME: String = FileCache.INDEX_FILE_NAME
## How long a picture is used before downloading it again, when the server can't tell
## whether it changed.
const MAX_AGE_SECONDS: int = 7 * 24 * 60 * 60
## The change time of a picture when the server can't tell it.
const UNKNOWN_TIME: int = -1
const UNKNOWN_TIME: int = FileCache.UNKNOWN_TIME
# What index.json holds: name -> {"modified": int, "downloaded": int}
var _index: Dictionary = {}
# The pictures on disk and their index
var _files: FileCache = FileCache.new(FOLDER_NAME)
# When the server last changed each picture on the list, or UNKNOWN_TIME
var _modified: Dictionary[String, int] = {}
# True when the server could not be reached and only cached pictures are shown
@@ -38,7 +38,7 @@ static func folder() -> String:
## [code]Array[String][/code]. Cached pictures of characters no longer on the server are
## deleted.
func load_list(on_done: Callable) -> void:
_load_index()
_files.load_index()
_cache_only = false
var details_fetched: Callable = func details_fetched(details: Array) -> void:
_modified.clear()
@@ -51,9 +51,8 @@ func load_list(on_done: Callable) -> void:
## Calls [param on_done] with the picture of [param character_name] as an [Image], from
## disk when the cached one is still fresh, otherwise from the server.
func fetch_image(character_name: String, on_done: Callable) -> void:
var path: String = _file_path(character_name)
if (_cache_only || _is_fresh(character_name)) && FileAccess.file_exists(path):
var cached: Image = CharacterCard.image_from_bytes(FileAccess.get_file_as_bytes(path))
if (_cache_only || _is_fresh(character_name)) && _files.has_file(character_name):
var cached: Image = CharacterCard.image_from_bytes(_files.read(character_name))
if cached != null:
on_done.call(cached)
return
@@ -63,7 +62,7 @@ func fetch_image(character_name: String, on_done: Callable) -> void:
if picture == null:
GameLog.warning("Could not load the picture of ", character_name)
return
_store(character_name, bytes)
_files.store(character_name, bytes, _modified.get(character_name, UNKNOWN_TIME))
on_done.call(picture)
MusicServerClient.fetch_character_image(character_name, image_fetched)
@@ -84,71 +83,24 @@ func _load_cached_only(on_done: Callable) -> void:
GameLog.warning("Could not get the characters from the server, showing the cached ones")
_cache_only = true
var names: Array[String] = []
for character_name: String in _index:
if FileAccess.file_exists(_file_path(character_name)):
for character_name: String in _files.keys():
if _files.has_file(character_name):
names.append(character_name)
names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0)
on_done.call(names)
func _list_loaded(on_done: Callable) -> void:
var removed: bool = false
for character_name: String in _index.keys():
if !_modified.has(character_name):
DirAccess.remove_absolute(_file_path(character_name))
_index.erase(character_name)
removed = true
if removed:
_save_index()
_files.keep_only(_modified)
var names: Array[String] = []
names.assign(_modified.keys())
on_done.call(names)
func _is_fresh(character_name: String) -> bool:
if !_index.has(character_name):
if !_files.keys().has(character_name):
return false
var entry: Dictionary = _index[character_name]
var modified: int = _modified.get(character_name, UNKNOWN_TIME)
if modified != UNKNOWN_TIME:
return int(entry.get("modified", UNKNOWN_TIME)) == modified
var age: float = Time.get_unix_time_from_system() - float(entry.get("downloaded", 0))
return age < MAX_AGE_SECONDS
func _store(character_name: String, bytes: PackedByteArray) -> void:
DirAccess.make_dir_recursive_absolute(folder())
var file: FileAccess = FileAccess.open(_file_path(character_name), FileAccess.WRITE)
if file == null:
GameLog.warning("Could not cache the picture of ", character_name, ": ",
error_string(FileAccess.get_open_error()))
return
file.store_buffer(bytes)
file.close()
_index[character_name] = {
"modified": _modified.get(character_name, UNKNOWN_TIME),
"downloaded": int(Time.get_unix_time_from_system()),
}
_save_index()
func _load_index() -> void:
_index = {}
var path: String = folder().path_join(INDEX_FILE_NAME)
if FileAccess.file_exists(path):
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if parsed is Dictionary:
_index = parsed
func _save_index() -> void:
var file: FileAccess = FileAccess.open(folder().path_join(INDEX_FILE_NAME),
FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(_index, "\t", true))
# The server's names are plain file names, but they are made safe anyway since they
# become paths on this computer
func _file_path(character_name: String) -> String:
return folder().path_join(character_name.validate_filename())
return _files.modified(character_name) == modified
return _files.age(character_name) < MAX_AGE_SECONDS