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>
This commit is contained in:
1 parent
ef6ac8f1fd
commit
06421bb63e
33 files changed
+3966
-920
No files matched your search
@@ -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,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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user