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

+47 -4
View File
@@ -12,9 +12,52 @@ script = ExtResource("card_script")
layout_mode = 2
mouse_filter = 2
[node name="NameLabel" type="Label" parent="VBox" unique_id=1211000003]
[node name="Header" type="HBoxContainer" parent="VBox"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 10
[node name="CoverFrame" type="Panel" parent="VBox/Header"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 62)
clip_children = 2
custom_minimum_size = Vector2(72, 100)
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"SoundtrackCover"
[node name="CoverPlaceholder" type="Label" parent="VBox/Header/CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_type_variation = &"TextLarge"
horizontal_alignment = 1
vertical_alignment = 1
[node name="CoverPicture" type="TextureRect" parent="VBox/Header/CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="Info" type="VBoxContainer" parent="VBox/Header"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="NameLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000003]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 50)
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"TextLarge"
@@ -22,12 +65,12 @@ autowrap_mode = 3
text_overrun_behavior = 3
max_lines_visible = 2
[node name="PlayedLabel" type="Label" parent="VBox" unique_id=1211000004]
[node name="PlayedLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000004]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
[node name="LastPlayedLabel" type="Label" parent="VBox" unique_id=1211000005]
[node name="LastPlayedLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000005]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
+43
View File
@@ -4,6 +4,9 @@ extends PanelContainer
## played, and its most and least played songs. Songs never played are not listed, and
## the least played are only shown when there are enough played songs to not repeat
## any. Clicking it asks for all its songs.
##
## Its cover shows on the left once [method load_cover] has fetched it; until then, and
## when the soundtrack has no cover, the soundtrack's initials stand in for it.
## Emitted when the card is clicked.
signal pressed(soundtrack: SoundtrackWithSongs)
@@ -15,6 +18,11 @@ const SONGS_SHOWN: int = 3
## The soundtrack the card shows.
var soundtrack: SoundtrackWithSongs
# The cover time asked for, so a cover is only asked for again when it changed
var _cover_asked: int = 0
@onready var cover_picture: TextureRect = %CoverPicture
@onready var cover_placeholder: Label = %CoverPlaceholder
@onready var name_label: Label = %NameLabel
@onready var played_label: Label = %PlayedLabel
@onready var last_played_label: Label = %LastPlayedLabel
@@ -32,7 +40,14 @@ func _ready() -> void:
## Shows [param data] on the card.
func show_soundtrack(data: SoundtrackWithSongs) -> void:
var cover_changed: bool = soundtrack == null \
|| soundtrack.soundtrack_uuid != data.soundtrack_uuid \
|| soundtrack.cover_modified != data.cover_modified
soundtrack = data
if cover_changed:
show_cover(null)
_cover_asked = 0
cover_placeholder.text = initials(data.soundtrack_name)
name_label.text = data.soundtrack_name
tooltip_text = data.soundtrack_name
played_label.text = played_text(data.soundtrack_played)
@@ -55,6 +70,34 @@ func show_soundtrack(data: SoundtrackWithSongs) -> void:
_fill_song_list(least_played_list, least_played)
## Asks [param covers] for the soundtrack's cover, unless it already has, and shows it
## when it comes. Soundtracks without a cover keep their initials.
func load_cover(covers: CoverCache) -> void:
if soundtrack == null || soundtrack.cover_modified <= 0 \
|| _cover_asked == soundtrack.cover_modified:
return
_cover_asked = soundtrack.cover_modified
covers.fetch_cover(soundtrack.soundtrack_uuid, soundtrack.cover_modified, show_cover)
## Shows [param cover], or the initials when it is null.
func show_cover(cover: Image) -> void:
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
cover_placeholder.visible = cover == null
## Up to two initials of [param soundtrack_name], e.g. "FF" for "Final Fantasy VI".
static func initials(soundtrack_name: String) -> String:
var letters: String = ""
for word: String in soundtrack_name.split(" ", false):
var first: String = word.left(1)
if first.to_upper() != first.to_lower():
letters += first.to_upper()
if letters.length() == 2:
break
return letters if !letters.is_empty() else soundtrack_name.left(1)
## "Played 3 times", or "Never played" for [code]0[/code].
static func played_text(times_played: int) -> String:
if times_played == 0:
+50 -3
View File
@@ -5,6 +5,9 @@ extends Panel
## cards filtered on whether the soundtrack has been played.
##
## Shown instead of the quiz when the Statistics tab is chosen.
##
## Covers are loaded for the cards on screen as they scroll into view, a few at a time,
## not for every soundtrack when the tab opens. See [CoverCache].
## Emitted when a card is clicked, to show all songs of [param soundtrack].
signal soundtrack_selected(soundtrack: SoundtrackWithSongs)
@@ -19,12 +22,18 @@ enum ShowFilter { ALL, PLAYED, NEVER_PLAYED }
enum Mode { SOUNDTRACKS, SONGS }
const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn")
## How far outside the visible part cards still get their covers, so they are ready when
## scrolled to.
const COVER_MARGIN: float = 400.0
## The cards in the order they are shown, hidden ones included.
var cards: Array[SoundtrackCard] = []
## Whether soundtracks or songs are shown. Change it with [method set_mode].
var mode: Mode = Mode.SOUNDTRACKS
## The covers on disk, shared by the cards.
var covers: CoverCache = CoverCache.new()
var _search: SoundtrackSearch = SoundtrackSearch.new()
var _loading: bool = false
# The soundtracks last loaded, by name
@@ -61,6 +70,11 @@ func _ready() -> void:
sort_option.item_selected.connect(func(_index: int) -> void: apply_sort())
retry_button.pressed.connect(refresh)
retry_button.visible = false
# The cards have moved by the end of the frame
card_scroll.get_v_scroll_bar().value_changed.connect(
func(_value: float) -> void: load_covers_on_screen.call_deferred())
card_scroll.resized.connect(load_covers_on_screen)
visibility_changed.connect(load_covers_on_screen)
## Loads the statistics from the server. The cards already shown stay until the new
@@ -108,6 +122,7 @@ func apply_sort() -> void:
return _comes_before(a.soundtrack, b.soundtrack, order))
for i: int in cards.size():
card_grid.move_child(cards[i], i)
load_covers_on_screen.call_deferred()
## Shows only the cards or songs that match the search text and the show menu.
@@ -136,6 +151,8 @@ func apply_filter() -> void:
count_label.text = "%d soundtracks" % cards.size()
else:
count_label.text = "%d of %d soundtracks" % [shown, cards.size()]
# The grid has moved the cards by the end of the frame
load_covers_on_screen.call_deferred()
func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
@@ -149,19 +166,49 @@ func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
cards.append(card)
while cards.size() > soundtracks.size():
cards.pop_back().free()
var names: Array[String] = []
_soundtracks.clear()
var cover_times: Dictionary = {}
for i: int in soundtracks.size():
cards[i].show_soundtrack(soundtracks[i])
names.append(soundtracks[i].soundtrack_name)
_soundtracks[soundtracks[i].soundtrack_name] = soundtracks[i]
_search.set_names(names)
cover_times[soundtracks[i].soundtrack_uuid] = soundtracks[i].cover_modified
covers.keep_only(cover_times)
_update_search_names()
_songs_ready = false
apply_sort()
if mode == Mode.SOUNDTRACKS:
apply_filter()
## Asks for the covers of the cards that are on screen, or nearly.
func load_covers_on_screen() -> void:
if !is_visible_in_tree() || mode != Mode.SOUNDTRACKS:
return
var view: Rect2 = card_scroll.get_global_rect().grow(COVER_MARGIN)
for card: SoundtrackCard in cards:
if card.visible && view.intersects(card.get_global_rect()):
card.load_cover(covers)
## Shows the new alternative names of the soundtrack [param updated] and finds it by
## them, without loading the statistics again.
func update_alternative_names(updated: Soundtrack) -> void:
for soundtrack: SoundtrackWithSongs in _soundtracks.values():
if soundtrack.soundtrack_uuid == updated.uuid:
soundtrack.alternative_names = updated.alternative_names
_update_search_names()
apply_filter()
func _update_search_names() -> void:
var names: Array[String] = []
var alternatives: Dictionary = {}
for soundtrack: SoundtrackWithSongs in _soundtracks.values():
names.append(soundtrack.soundtrack_name)
alternatives[soundtrack.soundtrack_name] = soundtrack.alternative_names
_search.set_names(names, alternatives)
# Typing in the song list waits for a pause, since searching every song takes a moment
func _on_search_text_changed(_text: String) -> void:
if mode == Mode.SONGS: