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>
306 lines
12 KiB
GDScript
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)
|