Change soundtrack covers from the app and show the cover with the answer
Build / test (push) Successful in 1m50s
Build / build (push) Successful in 1m14s

Cover menu in the soundtrack popup ("Change cover"):
- Choose picture uploads a JPEG, PNG or WebP picture (up to 10 MB).
- Pick by ID uses a game's cover on SteamGridDB or an anime's on AniList.
- Remove cover deletes it, so the server searches for a new one.
- The server's reason is shown when it refuses a change.
After a change the new cover time is fetched, and the popup, the
statistics card and the quiz show the new cover.

Quiz: once the answer is shown, the soundtrack's cover shows to the left of
the titles. Cover and titles are centered together, left and right and up
and down, and each title keeps to two lines. Cover times come from the
soundtrack list, and songs keep their soundtrack's UUID, also in saves.

Requests can now send any body with its content type, and every 2xx answer
counts as success. Cover frames are 2:3 like the server's covers. Small
copies of real covers are used by the tests and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-14 23:32:09 +02:00
1 parent ef6ac8f1fd
commit 06421bb63e
33 files changed
+3966 -920

No files matched your search

+121 -4
View File
@@ -36,8 +36,8 @@ clip_children = 2
layout_mode = 0
offset_left = 20.0
offset_top = 64.0
offset_right = 164.0
offset_bottom = 264.0
offset_right = 160.0
offset_bottom = 274.0
mouse_filter = 2
theme_type_variation = &"SoundtrackCover"
@@ -90,13 +90,22 @@ autowrap_mode = 3
text_overrun_behavior = 3
max_lines_visible = 3
[node name="EditNamesButton" type="Button" parent="StatsPanel/Info"]
[node name="InfoButtons" type="HBoxContainer" parent="StatsPanel/Info"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="EditNamesButton" type="Button" parent="StatsPanel/Info/InfoButtons"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 0
focus_mode = 0
text = "Edit names"
[node name="ChangeCoverButton" type="Button" parent="StatsPanel/Info/InfoButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Change cover"
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
unique_name_in_owner = true
layout_mode = 0
@@ -180,3 +189,111 @@ unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Save"
[node name="CoverEditor" type="VBoxContainer" parent="StatsPanel"]
unique_name_in_owner = true
visible = false
layout_mode = 0
offset_left = 20.0
offset_top = 280.0
offset_right = 680.0
offset_bottom = 784.0
theme_override_constants/separation = 10
[node name="CoverEditorTitle" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_type_variation = &"Title"
text = "Cover"
[node name="CoverEditorHelp" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
autowrap_mode = 3
text = "The server finds covers by itself. When it found the wrong one, or none, choose a picture of your own or the cover of a game on SteamGridDB or an anime on AniList. The server keeps a cover set here until it is removed."
[node name="PictureRow" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="ChoosePictureButton" type="Button" parent="StatsPanel/CoverEditor/PictureRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Choose picture…"
[node name="RemoveCoverButton" type="Button" parent="StatsPanel/CoverEditor/PictureRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Remove cover"
[node name="SourceTitle" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "Pick by ID"
[node name="SourceHelp" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_type_variation = &"TextMedium"
autowrap_mode = 3
text = "The ID is the number in the game's or anime's web address."
[node name="SourceRow" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="CoverSourceButton" type="OptionButton" parent="StatsPanel/CoverEditor/SourceRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
selected = 0
item_count = 2
popup/item_0/text = "SteamGridDB"
popup/item_0/id = 0
popup/item_1/text = "AniList"
popup/item_1/id = 1
[node name="CoverIdField" type="LineEdit" parent="StatsPanel/CoverEditor/SourceRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "ID"
max_length = 50
[node name="UseSourceButton" type="Button" parent="StatsPanel/CoverEditor/SourceRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Use"
[node name="CoverEditorSpace" type="Control" parent="StatsPanel/CoverEditor"]
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
[node name="CoverStatus" type="Label" parent="StatsPanel/CoverEditor"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
autowrap_mode = 3
[node name="CoverEditorButtons" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
layout_mode = 2
alignment = 2
[node name="CloseCoverButton" type="Button" parent="StatsPanel/CoverEditor/CoverEditorButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Done"
[node name="PictureDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Choose a cover picture"
initial_position = 2
size = Vector2i(800, 500)
ok_button_text = "Open"
file_mode = 0
access = 2
filters = PackedStringArray("*.jpg, *.jpeg, *.png, *.webp ; Pictures")
use_native_dialog = true
+4
View File
@@ -4,6 +4,9 @@ extends BasePopup
## highlights the letters that matched. Soundtracks are also found by their alternative
## names; the alternative name that matched is shown after the real name.
## Emitted with every soundtrack on the server when the list has loaded.
signal soundtracks_loaded(list: Array[Soundtrack])
## Every soundtrack on the server, sorted by name.
var soundtracks: Array = []
@@ -98,6 +101,7 @@ func get_list_of_soundtracks() -> void:
names.sort_custom(func(a: String, b: String) -> bool:
return a.naturalnocasecmp_to(b) < 0)
set_soundtracks(names, alternatives)
soundtracks_loaded.emit(list)
MusicServerClient.fetch_soundtracks(with_names, _load_names_only)
+143 -11
View File
@@ -5,9 +5,19 @@ extends BasePopup
##
## Edit names opens an editor for the alternative names in place of the song list. Saving
## sends them to the server, which search then finds the soundtrack by.
##
## Change cover opens the cover menu there instead: upload a picture, pick a game's or
## anime's cover by its ID on SteamGridDB or AniList, or remove the cover so the server
## looks for one again.
## Emitted when the alternative names of [param soundtrack] have been saved.
signal alternative_names_changed(soundtrack: Soundtrack)
## Emitted when the cover of the soundtrack [param uuid] changed, with its new cover time
## [param modified], 0 when it was removed.
signal cover_changed(uuid: String, modified: int)
## The largest picture the server takes as a cover.
const MAX_COVER_BYTES: int = 10 << 20
## The covers on disk, shared with the statistics view. No cover is shown without it.
var covers: CoverCache
@@ -32,6 +42,16 @@ var _edited_names: Array[String] = []
@onready var editor_status: Label = %EditorStatus
@onready var save_names_button: Button = %SaveNamesButton
@onready var cancel_names_button: Button = %CancelNamesButton
@onready var change_cover_button: Button = %ChangeCoverButton
@onready var cover_editor: VBoxContainer = %CoverEditor
@onready var choose_picture_button: Button = %ChoosePictureButton
@onready var remove_cover_button: Button = %RemoveCoverButton
@onready var cover_source_button: OptionButton = %CoverSourceButton
@onready var cover_id_field: LineEdit = %CoverIdField
@onready var use_source_button: Button = %UseSourceButton
@onready var cover_status: Label = %CoverStatus
@onready var close_cover_button: Button = %CloseCoverButton
@onready var picture_dialog: FileDialog = %PictureDialog
func _ready() -> void:
@@ -40,6 +60,13 @@ func _ready() -> void:
new_name_field.text_submitted.connect(func(_text: String) -> void: add_name())
save_names_button.pressed.connect(save_names)
cancel_names_button.pressed.connect(stop_editing)
change_cover_button.pressed.connect(change_cover)
choose_picture_button.pressed.connect(picture_dialog.popup_centered)
picture_dialog.file_selected.connect(upload_picture)
remove_cover_button.pressed.connect(remove_cover)
use_source_button.pressed.connect(use_cover_source)
cover_id_field.text_submitted.connect(func(_text: String) -> void: use_cover_source())
close_cover_button.pressed.connect(stop_editing)
## Shows [param shown] and opens the popup.
@@ -57,10 +84,7 @@ func show_soundtrack(shown: SoundtrackWithSongs) -> void:
_show_names()
cover_placeholder.text = SoundtrackCard.initials(shown.soundtrack_name)
_show_cover(null)
if covers != null:
covers.fetch_cover(shown.soundtrack_uuid, shown.cover_modified,
_show_cover_of.bind(shown.soundtrack_uuid))
_load_cover()
Settings.delete_children(song_list)
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(shown.songs)
@@ -76,18 +100,71 @@ func edit_names() -> void:
new_name_field.text = ""
editor_status.visible = false
_show_edited_names()
names_editor.visible = true
song_scroll.visible = false
edit_names_button.disabled = true
_show_editor(names_editor)
new_name_field.grab_focus()
## Closes the editor without saving.
## Opens the cover menu, in place of the song list.
func change_cover() -> void:
cover_id_field.text = ""
cover_status.visible = false
_set_cover_busy(false)
_show_editor(cover_editor)
## Closes the editors, without saving the names.
func stop_editing() -> void:
names_editor.visible = false
song_scroll.visible = true
edit_names_button.disabled = false
_show_editor(null)
_set_saving(false)
_set_cover_busy(false)
## Uploads the picture at [param path] as the soundtrack's cover.
func upload_picture(path: String) -> void:
var picture: PackedByteArray = FileAccess.get_file_as_bytes(path)
var content_type: String = picture_type(picture)
if picture.size() > MAX_COVER_BYTES:
_show_cover_status("The picture is larger than %d MB." % (MAX_COVER_BYTES >> 20))
return
if content_type.is_empty() || CharacterCard.image_from_bytes(picture) == null:
_show_cover_status("Only JPEG, PNG and WebP pictures can be used.")
return
_set_cover_busy(true, "Uploading the picture…")
MusicServerClient.upload_soundtrack_cover(soundtrack.soundtrack_uuid, picture,
content_type, _on_cover_saved.bind("The new cover is saved."), _on_cover_not_saved)
## Makes the cover of the game or anime with the ID in the field, on the site picked,
## the soundtrack's cover.
func use_cover_source() -> void:
var id: String = cover_id_field.text.strip_edges()
if id.is_empty() || use_source_button.disabled:
return
var source: String = MusicServerClient.COVER_SOURCES[cover_source_button.selected]
_set_cover_busy(true, "Getting the cover from %s…"
% cover_source_button.get_item_text(cover_source_button.selected))
MusicServerClient.set_soundtrack_cover_source(soundtrack.soundtrack_uuid, source, id,
_on_cover_saved.bind("The new cover is saved."), _on_cover_not_saved)
## Removes the soundtrack's cover, so the server looks for one again.
func remove_cover() -> void:
_set_cover_busy(true, "Removing the cover…")
MusicServerClient.delete_soundtrack_cover(soundtrack.soundtrack_uuid,
_on_cover_saved.bind("The cover is removed. The server will look for a new one."),
_on_cover_not_saved)
## The type of [param picture] to upload it as, e.g. [code]"image/png"[/code], or empty
## when it is not a JPEG, PNG or WebP picture.
static func picture_type(picture: PackedByteArray) -> String:
if picture.size() >= 4 && picture.slice(0, 4) == PackedByteArray([0x89, 0x50, 0x4E, 0x47]):
return "image/png"
if picture.size() >= 3 && picture.slice(0, 3) == PackedByteArray([0xFF, 0xD8, 0xFF]):
return "image/jpeg"
if picture.size() >= 12 && picture.slice(8, 12).get_string_from_ascii() == "WEBP":
return "image/webp"
return ""
## Adds the name in the field to the edited names, unless it is empty, the soundtrack's
@@ -162,6 +239,61 @@ func _show_edited_names() -> void:
editor_names.add_child(list_row)
# Shows [param editor] in place of the song list, or the song list when it is null
func _show_editor(editor: Control) -> void:
names_editor.visible = editor == names_editor
cover_editor.visible = editor == cover_editor
song_scroll.visible = editor == null
edit_names_button.disabled = editor != null
change_cover_button.disabled = editor != null
# The cover changed on the server; its new time tells whether it has one and fetches it
func _on_cover_saved(message: String) -> void:
var uuid: String = soundtrack.soundtrack_uuid
var with_time: Callable = func with_time(modified: int) -> void:
if soundtrack == null || soundtrack.soundtrack_uuid != uuid:
return
soundtrack.cover_modified = modified
_load_cover()
_set_cover_busy(false)
_show_cover_status(message)
cover_changed.emit(uuid, modified)
if covers != null:
covers.refresh(uuid, with_time)
else:
with_time.call(soundtrack.cover_modified)
func _on_cover_not_saved() -> void:
_set_cover_busy(false)
var reason: String = MusicServerClient.last_error
_show_cover_status("Could not change the cover: %s."
% (reason if !reason.is_empty() else "the server could not be reached"))
func _set_cover_busy(busy: bool, message: String = "") -> void:
for button: Button in [choose_picture_button, use_source_button, close_cover_button]:
button.disabled = busy
remove_cover_button.disabled = busy || soundtrack == null \
|| soundtrack.cover_modified <= 0
cover_source_button.disabled = busy
if busy:
_show_cover_status(message)
func _show_cover_status(message: String) -> void:
cover_status.text = message
cover_status.visible = true
func _load_cover() -> void:
_show_cover(null)
if covers != null:
covers.fetch_cover(soundtrack.soundtrack_uuid, soundtrack.cover_modified,
_show_cover_of.bind(soundtrack.soundtrack_uuid))
func _show_cover(cover: Image) -> void:
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
cover_placeholder.visible = cover == null