Files
MusicPlayer/popups/soundtrack_stats_popup.gd
SansanandClaude Opus 5.5 06421bb63e
Build / test (push) Successful in 1m50s
Build / build (push) Successful in 1m14s
Change soundtrack covers from the app and show the cover with the answer
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>
2026-10-14 23:32:09 +02:00

306 lines
12 KiB
GDScript

class_name SoundtrackStatsPopup
extends BasePopup
## Shows a soundtrack's cover, play statistics and alternative names, and every song in
## it, most played first. Opened by clicking a card in the statistics view.
##
## 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
## The soundtrack shown.
var soundtrack: SoundtrackWithSongs
# The names in the editor, saved only when Save is pressed
var _edited_names: Array[String] = []
@onready var title_label: Label = %TitleLabel
@onready var summary_label: Label = %SummaryLabel
@onready var cover_picture: TextureRect = %CoverPicture
@onready var cover_placeholder: Label = %CoverPlaceholder
@onready var names_label: Label = %NamesLabel
@onready var edit_names_button: Button = %EditNamesButton
@onready var song_scroll: ScrollContainer = %ScrollContainer
@onready var song_list: VBoxContainer = %SongList
@onready var names_editor: VBoxContainer = %NamesEditor
@onready var editor_names: VBoxContainer = %EditorNames
@onready var new_name_field: LineEdit = %NewNameField
@onready var add_name_button: Button = %AddNameButton
@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:
edit_names_button.pressed.connect(edit_names)
add_name_button.pressed.connect(add_name)
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.
func show_soundtrack(shown: SoundtrackWithSongs) -> void:
soundtrack = shown
stop_editing()
title_label.text = shown.soundtrack_name
var summary: PackedStringArray = [SoundtrackCard.played_text(shown.soundtrack_played)]
var last_played: String = SoundtrackCard.last_played_text(shown.soundtrack_last_played)
if !last_played.is_empty():
summary.append(last_played)
summary.append("%d %s" % [shown.songs.size(),
"song" if shown.songs.size() == 1 else "songs"])
summary_label.text = " · ".join(summary)
_show_names()
cover_placeholder.text = SoundtrackCard.initials(shown.soundtrack_name)
_load_cover()
Settings.delete_children(song_list)
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(shown.songs)
for row: int in songs.size():
song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row,
&"TextLarge"))
open()
## Opens the editor for the alternative names, in place of the song list.
func edit_names() -> void:
_edited_names.assign(soundtrack.alternative_names)
new_name_field.text = ""
editor_status.visible = false
_show_edited_names()
_show_editor(names_editor)
new_name_field.grab_focus()
## 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:
_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
## own name or already there.
func add_name() -> void:
var new_name: String = new_name_field.text.strip_edges()
if new_name.is_empty() || new_name.to_lower() == soundtrack.soundtrack_name.to_lower() \
|| _edited_names.any(func(name: String) -> bool:
return name.to_lower() == new_name.to_lower()):
return
_edited_names.append(new_name)
new_name_field.text = ""
_show_edited_names()
## Takes [param removed] out of the edited names.
func remove_name(removed: String) -> void:
_edited_names.erase(removed)
_show_edited_names()
## Sends the edited names to the server. The editor closes when they are saved.
func save_names() -> void:
# A name typed but not added yet is meant to be saved too
add_name()
_set_saving(true)
MusicServerClient.set_alternative_names(soundtrack.soundtrack_uuid, _edited_names,
_on_names_saved, _on_names_not_saved)
func _on_names_saved(updated: Soundtrack) -> void:
soundtrack.alternative_names = updated.alternative_names
_show_names()
stop_editing()
alternative_names_changed.emit(updated)
func _on_names_not_saved() -> void:
_set_saving(false)
editor_status.text = "Could not save the names. Try again."
editor_status.visible = true
func _set_saving(saving: bool) -> void:
save_names_button.disabled = saving
cancel_names_button.disabled = saving
save_names_button.text = "Saving…" if saving else "Save"
func _show_names() -> void:
var names: Array[String] = soundtrack.alternative_names
names_label.text = ", ".join(names) if !names.is_empty() else "No other names yet"
func _show_edited_names() -> void:
Settings.delete_children(editor_names)
for row: int in _edited_names.size():
var line: HBoxContainer = HBoxContainer.new()
var label: Label = Label.new()
label.text = _edited_names[row]
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
line.add_child(label)
var remove: Button = Button.new()
remove.text = "Remove"
remove.focus_mode = Control.FOCUS_NONE
remove.pressed.connect(remove_name.bind(_edited_names[row]))
line.add_child(remove)
var list_row: PanelContainer = PanelContainer.new()
list_row.theme_type_variation = &"ListRowOdd" if row % 2 != 0 else &"ListRowEven"
list_row.add_child(line)
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
# Covers arrive later; one for a soundtrack no longer shown is left out
func _show_cover_of(cover: Image, uuid: String) -> void:
if soundtrack != null && soundtrack.soundtrack_uuid == uuid:
_show_cover(cover)