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)