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

+49
View File
@@ -6,6 +6,13 @@ extends RefCounted
##
## Covers are downloaded a few at a time, in the order they are asked for, so opening the
## statistics doesn't send a request for every soundtrack at once.
##
## It also remembers each soundtrack's cover time from the soundtrack lists it is given,
## so [method fetch_cover_of] finds a cover by the soundtrack alone, e.g. in the quiz.
## Emitted when the cover time of a soundtrack is new or changed, e.g. once the soundtrack
## list has loaded or a cover was changed.
signal cover_times_changed
const FOLDER_NAME: String = "covers"
## How many covers are downloaded at the same time.
@@ -18,6 +25,8 @@ var _waiting: Array[Array] = []
# Who is waiting for each cover that is asked for or downloading: uuid -> Array[Callable]
var _callbacks: Dictionary = {}
var _downloading: int = 0
# The server's cover time of each soundtrack seen: uuid -> int, 0 for none
var _times: Dictionary = {}
func _init() -> void:
@@ -32,6 +41,7 @@ static func folder() -> String:
## Deletes the covers of soundtracks that are not in [param covers] (UUID -> cover
## time), and those whose soundtrack no longer has a cover.
func keep_only(covers: Dictionary) -> void:
set_cover_times(covers)
var kept: Dictionary = {}
for uuid: String in covers:
if int(covers[uuid]) > 0:
@@ -39,6 +49,45 @@ func keep_only(covers: Dictionary) -> void:
_files.keep_only(kept)
## Remembers the cover times in [param covers] (UUID -> cover time, 0 for no cover) and
## emits [signal cover_times_changed] when any is new or changed.
func set_cover_times(covers: Dictionary) -> void:
var changed: bool = false
for uuid: String in covers:
var modified: int = int(covers[uuid])
if _times.get(uuid, -1) != modified:
_times[uuid] = modified
changed = true
if changed:
cover_times_changed.emit()
## The cover time of the soundtrack [param uuid], 0 when it has no cover or none is known.
func cover_time(uuid: String) -> int:
return _times.get(uuid, 0)
## Like [method fetch_cover], with the cover time remembered for the soundtrack.
func fetch_cover_of(uuid: String, on_done: Callable) -> void:
fetch_cover(uuid, cover_time(uuid), on_done)
## Asks the server for the cover time of the soundtrack [param uuid] again, e.g. after
## its cover was changed, then calls [param on_done] with the new time, 0 for no cover.
## A removed cover is deleted from disk too. When the server doesn't answer, it is called
## with the time it had.
func refresh(uuid: String, on_done: Callable) -> void:
var with_soundtrack: Callable = func with_soundtrack(
soundtrack: SoundtrackWithSongList) -> void:
if soundtrack.cover_modified <= 0:
_files.remove(uuid)
set_cover_times({uuid: soundtrack.cover_modified})
on_done.call(soundtrack.cover_modified)
# Without an answer the cover is left as it was
MusicServerClient.fetch_soundtrack(uuid, with_soundtrack,
func() -> void: on_done.call(cover_time(uuid)))
## Calls [param on_done] with the cover of the soundtrack [param uuid] as an [Image]:
## from disk when the cached one has the server's time [param modified], otherwise once
## it has been downloaded. Nothing is called when the soundtrack has no cover
+4
View File
@@ -108,6 +108,10 @@ func get_current_soundtrack_title() -> String:
return song_object_array[currently_playing_song].get_soundtrack_title()
func get_current_soundtrack_uuid() -> String:
return song_object_array[currently_playing_song].get_soundtrack_uuid()
func get_current_song_title() -> String:
return song_object_array[currently_playing_song].get_song_title()
+9
View File
@@ -5,6 +5,8 @@ class_name SongObject
var song_number: int
var song_title: String
var soundtrack_title: String
## The UUID of the song's soundtrack on the server, empty for songs from a music folder.
var soundtrack_uuid: String = ""
## Ids of the players who got a point for this song.
var players_given_point: Array[String] = []
@@ -36,6 +38,11 @@ func get_song_title() -> String:
return "??????"
## The soundtrack's UUID once the answer is shown, otherwise empty.
func get_soundtrack_uuid() -> String:
return soundtrack_uuid if is_answered else ""
func get_song_info() -> String:
if Settings.is_debug:
return "%d. %s | played: %s | answered: %s" % [(song_number + 1), _full_title(),
@@ -71,6 +78,7 @@ func to_dict() -> Dictionary:
"number": song_number, "soundtrack": soundtrack_title, "song": song_title,
"points": players_given_point.duplicate(), "answered": is_answered,
"played": has_played, "playing": is_playing, "path": song_path,
"soundtrack_uuid": soundtrack_uuid,
}
@@ -83,6 +91,7 @@ static func from_dict(data: Variant) -> SongObject:
song_object.song_number = int(data.number)
song_object.soundtrack_title = str(data.get("soundtrack", ""))
song_object.song_title = str(data.get("song", ""))
song_object.soundtrack_uuid = str(data.get("soundtrack_uuid", ""))
if data.get("points") is Array:
for player_id: Variant in data.points:
song_object.add_point(str(player_id))
+11
View File
@@ -48,11 +48,14 @@ func _ready() -> void:
quiz.event_logged.connect(log_popup.add_log_row)
statistics_view.soundtrack_selected.connect(soundtrack_stats_popup.show_soundtrack)
soundtrack_stats_popup.covers = statistics_view.covers
quiz.now_playing.covers = statistics_view.covers
search_popup.soundtracks_loaded.connect(_remember_cover_times)
# New alternative names are found by the statistics search and the Search popup
soundtrack_stats_popup.alternative_names_changed.connect(
statistics_view.update_alternative_names)
soundtrack_stats_popup.alternative_names_changed.connect(
func(_soundtrack: Soundtrack) -> void: search_popup.get_list_of_soundtracks())
soundtrack_stats_popup.cover_changed.connect(statistics_view.update_cover)
# A saved quiz may have been played from a music folder
top_bar.update_offline_state()
@@ -60,6 +63,14 @@ func _ready() -> void:
show_about_if_new_version()
# The quiz finds a soundtrack's cover by the cover times in the soundtrack list
func _remember_cover_times(soundtracks: Array[Soundtrack]) -> void:
var times: Dictionary = {}
for soundtrack: Soundtrack in soundtracks:
times[soundtrack.uuid] = soundtrack.cover_modified
statistics_view.covers.set_cover_times(times)
# After another server or a music folder was picked. Statistics need the server.
func _on_music_source_changed() -> void:
top_bar.update_offline_state()
+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
+38 -3
View File
@@ -2,22 +2,57 @@
[ext_resource type="Script" path="res://quiz/now_playing.gd" id="part_script"]
[node name="NowPlaying" type="VBoxContainer" unique_id=42468498]
[node name="NowPlaying" type="HBoxContainer" unique_id=42468498]
layout_mode = 3
offset_right = 1088.0
offset_bottom = 320.0
theme_override_constants/separation = 32
alignment = 1
script = ExtResource("part_script")
[node name="SoundtrackLabel" type="Label" parent="." unique_id=173147793]
[node name="CoverFrame" type="Panel" parent="."]
unique_name_in_owner = true
visible = false
clip_children = 2
custom_minimum_size = Vector2(180, 270)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
theme_type_variation = &"SoundtrackCover"
[node name="CoverPicture" type="TextureRect" parent="CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="Titles" type="VBoxContainer" parent="."]
unique_name_in_owner = true
layout_mode = 2
alignment = 1
[node name="SoundtrackLabel" type="Label" parent="Titles" unique_id=173147793]
unique_name_in_owner = true
custom_minimum_size = Vector2(1, 1)
layout_mode = 2
theme_type_variation = &"TitleLarge"
horizontal_alignment = 1
autowrap_mode = 2
text_overrun_behavior = 3
max_lines_visible = 2
[node name="SongLabel" type="Label" parent="." unique_id=1687490027]
[node name="SongLabel" type="Label" parent="Titles" unique_id=1687490027]
unique_name_in_owner = true
custom_minimum_size = Vector2(1, 1)
layout_mode = 2
theme_type_variation = &"TitleMedium"
horizontal_alignment = 1
autowrap_mode = 2
text_overrun_behavior = 3
max_lines_visible = 2
+1 -1
View File
@@ -41,7 +41,7 @@ offset_bottom = 512.0
[node name="NowPlaying" parent="." unique_id=42468498 instance=ExtResource("scene_NowPlaying")]
layout_mode = 0
offset_left = 64.0
offset_top = 64.0
offset_top = 56.0
offset_right = 1152.0
offset_bottom = 384.0
+97 -8
View File
@@ -1,16 +1,105 @@
class_name NowPlaying
extends VBoxContainer
extends HBoxContainer
## The soundtrack and song that are playing, in large text at the top of the quiz. They
## fade in when they change.
## fade in when they change. Once the answer is shown, the soundtrack's cover shows on
## their left, when it has one, and the cover and titles are centered together. Both are
## centered up and down too, so the titles only move sideways when a cover comes. Each
## title has at most two lines.
## The covers on disk. No cover is shown without it.
var covers: CoverCache:
set(value):
covers = value
if covers != null:
covers.cover_times_changed.connect(_load_cover)
_load_cover()
# The soundtrack whose cover is shown or on its way, and the cover time asked for
var _cover_uuid: String = ""
var _cover_time: int = 0
@onready var soundtrack_label: Label = %SoundtrackLabel
@onready var song_label: Label = %SongLabel
@onready var cover_frame: Panel = %CoverFrame
@onready var cover_picture: TextureRect = %CoverPicture
@onready var titles: VBoxContainer = %Titles
func _ready() -> void:
resized.connect(_fit_titles)
_fit_titles()
## Shows [param soundtrack_title] and [param song_title]. The playlist hides them as
## question marks until the answer is shown.
func show_song(soundtrack_title: String, song_title: String) -> void:
var changed: bool = $SoundtrackLabel.text != soundtrack_title \
|| $SongLabel.text != song_title
$SoundtrackLabel.text = soundtrack_title
$SongLabel.text = song_title
## question marks until the answer is shown, and only then gives the
## [param soundtrack_uuid] whose cover is shown.
func show_song(soundtrack_title: String, song_title: String,
soundtrack_uuid: String = "") -> void:
var changed: bool = soundtrack_label.text != soundtrack_title \
|| song_label.text != song_title
soundtrack_label.text = soundtrack_title
song_label.text = song_title
_fit_titles()
if soundtrack_uuid != _cover_uuid:
_cover_uuid = soundtrack_uuid
_cover_time = 0
_show_cover(null)
_load_cover()
# A new song, or the answer being shown, fades in
if changed:
Anim.fade_in(self, Anim.SLOW)
## Whether a cover is shown.
func has_cover() -> bool:
return cover_frame.visible
# Asks for the cover, again when its time changed, e.g. once the soundtrack list loaded
func _load_cover() -> void:
if covers == null || _cover_uuid.is_empty() || !is_node_ready():
return
var time: int = covers.cover_time(_cover_uuid)
if time == _cover_time:
return
_cover_time = time
if time <= 0:
_show_cover(null)
return
covers.fetch_cover(_cover_uuid, time, _show_cover_of.bind(_cover_uuid, time))
# Covers arrive later; one for a song no longer shown is left out
func _show_cover_of(cover: Image, uuid: String, time: int) -> void:
if uuid == _cover_uuid && time == _cover_time:
_show_cover(cover)
func _show_cover(cover: Image) -> void:
var was_shown: bool = cover_frame.visible
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
cover_frame.visible = cover != null
_fit_titles()
if cover_frame.visible && !was_shown:
Anim.fade_in(cover_frame, Anim.SLOW)
# The titles take the width of the longer one, so they stay next to the cover, but no
# more than there is room for; longer titles wrap. Beside a cover they line up with it
# on the left.
func _fit_titles() -> void:
var room: float = size.x
if cover_frame.visible:
room -= cover_frame.custom_minimum_size.x + get_theme_constant(&"separation")
var widest: float = maxf(_text_width(soundtrack_label), _text_width(song_label))
titles.custom_minimum_size.x = clampf(ceilf(widest), 1.0, maxf(room, 1.0))
var alignment: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT if cover_frame.visible \
else HORIZONTAL_ALIGNMENT_CENTER
soundtrack_label.horizontal_alignment = alignment
song_label.horizontal_alignment = alignment
static func _text_width(label: Label) -> float:
var font: Font = label.get_theme_font(&"font")
return font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1,
label.get_theme_font_size(&"font_size")).x
+1 -1
View File
@@ -171,7 +171,7 @@ func update_song_list() -> void:
GameLog.debug("update_song_list")
if Playlist.currently_playing_song >= 0:
now_playing.show_song(Playlist.get_current_soundtrack_title(),
Playlist.get_current_song_title())
Playlist.get_current_song_title(), Playlist.get_current_soundtrack_uuid())
music_list_panel.refresh()
save_soon()
+2
View File
@@ -52,6 +52,7 @@ func load_song_list(saved: QuizSave = null) -> void:
new_song_object.song_number = info.song_no
new_song_object.soundtrack_title = info.soundtrack
new_song_object.song_title = info.song
new_song_object.soundtrack_uuid = info.soundtrack_uuid
new_song_object.is_answered = true
new_song_object.has_played = true
Playlist.add_to_playlist(new_song_object)
@@ -139,6 +140,7 @@ func _fetch_song_info(new_song_object: SongObject) -> void:
new_song_object.song_number = info.song_no
new_song_object.soundtrack_title = info.soundtrack
new_song_object.song_title = info.song
new_song_object.soundtrack_uuid = info.soundtrack_uuid
GameLog.debug("new_song_object: ", new_song_object.to_string())
Playlist.add_to_playlist(new_song_object)
if Playlist.should_preload_more_songs():
+4 -7
View File
@@ -18,14 +18,14 @@ func _init(owner: Node) -> void:
## Requests [param url] and calls [param on_complete] with
## [code](response_code: int, body: PackedByteArray, headers: PackedStringArray)[/code],
## the headers as [code]"Name: value"[/code] lines. Connection errors and timeouts are
## reported with response code 0 and no headers. [param headers] are sent as-is; a
## [param body] is sent as JSON.
## reported with response code 0 and no headers. [param headers], which name the
## [param body]'s type, are sent as-is.
func request(
url: String,
method: int,
on_complete: Callable,
headers: PackedStringArray = PackedStringArray(),
body: String = "",
body: PackedByteArray = PackedByteArray(),
) -> void:
var http_request: HTTPRequest = HTTPRequest.new()
http_request.timeout = timeout_seconds
@@ -43,10 +43,7 @@ func request(
return
on_complete.call(response_code, response_body, response_headers)
)
var all_headers: PackedStringArray = headers.duplicate()
if method == HTTPClient.METHOD_PUT || !body.is_empty():
all_headers.append("Content-Type: application/json")
var error: int = http_request.request(url, all_headers, method, body)
var error: int = http_request.request_raw(url, headers, method, body)
if error != OK:
http_request.queue_free()
push_error("Could not send request to %s (error %d)" % [url, error])
+68 -12
View File
@@ -19,7 +19,7 @@ extends Node
## JSON objects are handed to [code]on_done[/code] as the classes generated from the
## server's OpenAPI spec (see [code]server/generated/[/code]), lists of them as typed
## Arrays. [code]on_failed[/code] is called with no arguments when a request fails,
## including when no token could be had.
## including when no token could be had; [member last_error] then has the server's reason.
## Path of the API on the server, added between [code]Settings.default_path[/code] and
## each endpoint.
@@ -28,9 +28,17 @@ const API_PATH: String = "/api/v1"
const CLIENT_TYPE: String = "godot"
## Header with the ID of the client's play queue, in requests and responses.
const QUEUE_HEADER: String = "X-Queue-Id"
## The type of the JSON bodies sent.
const JSON_TYPE: String = "application/json"
## Where the cover of a soundtrack can be picked from, by the game's or anime's ID there.
const COVER_SOURCES: Array[String] = ["steamgriddb", "anilist"]
## Sends the actual HTTP requests. Swap it out in tests.
var transport: HttpTransport
## The server's message for the request that failed last, e.g. [code]"covers are
## disabled"[/code], or empty when it gave none. Read it in an [code]on_failed[/code]
## callback, before another request can fail.
var last_error: String = ""
var _token: String = ""
# The server the token is for; switching server in the settings needs a new token
@@ -135,7 +143,37 @@ func fetch_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> v
## PNG). The server answers 404 while it has no cover for it.
func fetch_soundtrack_cover(
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/soundtracks/%s/cover" % uuid.uri_encode(), on_done, on_failed)
get_bytes(_cover_path(uuid), on_done, on_failed)
## Stores [param image], a JPEG, PNG or WebP picture of [param content_type] such as
## [code]"image/png"[/code], as the cover of the soundtrack with [param uuid]. The
## server's automatic search never replaces it.
func upload_soundtrack_cover(uuid: String, image: PackedByteArray, content_type: String,
on_done: Callable, on_failed: Callable = Callable()) -> void:
_request(_cover_path(uuid), HTTPClient.METHOD_PUT, _call_ignoring_body.bind(on_done),
on_failed, true, image, content_type)
## Makes the cover of the game or anime with [param id] on [param source], one of
## [constant COVER_SOURCES], the cover of the soundtrack with [param uuid]. The server
## downloads it before answering.
func set_soundtrack_cover_source(uuid: String, source: String, id: String,
on_done: Callable, on_failed: Callable = Callable()) -> void:
var request: CoverSource = CoverSource.new()
request.source = source
request.id = id
_request(_cover_path(uuid) + "/source", HTTPClient.METHOD_PUT,
_call_ignoring_body.bind(on_done), on_failed, true,
JSON.stringify(request.to_dict()).to_utf8_buffer())
## Deletes the cover of the soundtrack with [param uuid], also one set by hand, so the
## server's automatic search looks for a new one.
func delete_soundtrack_cover(
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
_request(_cover_path(uuid), HTTPClient.METHOD_DELETE, _call_ignoring_body.bind(on_done),
on_failed)
## Replaces the alternative names of the soundtrack with [param uuid] with
@@ -153,7 +191,7 @@ func set_alternative_names(uuid: String, names: Array[String], on_done: Callable
return
on_done.call(Soundtrack.from_dict(data))
_request(path, HTTPClient.METHOD_PUT, _parse_json.bind(path, saved, on_failed), on_failed,
true, JSON.stringify(request.to_dict()))
true, JSON.stringify(request.to_dict()).to_utf8_buffer())
## Fetches a soundtrack and its songs as a [SoundtrackWithSongList].
@@ -444,25 +482,26 @@ func _parse_json(
# Sends the request with the token, and once more with a new token if the server
# answers 401. A [param json_body] is sent as the request's body.
# answers 401. A [param body] of the type [param content_type] is sent with it.
func _request(
path: String,
method: int,
on_success: Callable,
on_failed: Callable,
may_retry: bool = true,
json_body: String = "",
body: PackedByteArray = PackedByteArray(),
content_type: String = JSON_TYPE,
) -> void:
_with_token(func(token: String) -> void:
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
_send(path, method, token, func(response_code: int, response: PackedByteArray) -> void:
if response_code == 401 && may_retry:
GameLog.debug("Token refused by the server, getting a new one")
if _token == token:
clear_token()
_request(path, method, on_success, on_failed, false, json_body)
_request(path, method, on_success, on_failed, false, body, content_type)
return
_handle_response(path, response_code, body, on_success, on_failed)
, json_body)
_handle_response(path, response_code, response, on_success, on_failed)
, body, content_type)
, on_failed)
@@ -476,12 +515,19 @@ func _send_once(
func _send(
path: String, method: int, token: String, on_complete: Callable, body: String = "",
path: String,
method: int,
token: String,
on_complete: Callable,
body: PackedByteArray = PackedByteArray(),
content_type: String = JSON_TYPE,
) -> void:
var server: String = Settings.default_path
var headers: PackedStringArray = []
if !token.is_empty():
headers.append("Authorization: Bearer " + token)
if !body.is_empty() || method == HTTPClient.METHOD_PUT:
headers.append("Content-Type: " + content_type)
var queue: String = Settings.queue_ids.get(server, "")
if !queue.is_empty():
headers.append("%s: %s" % [QUEUE_HEADER, queue])
@@ -500,7 +546,9 @@ func _handle_response(
on_success: Callable,
on_failed: Callable,
) -> void:
if response_code != 200:
# Requests that change something may answer 204 No Content
if response_code < 200 || response_code >= 300:
last_error = _error_message(body).trim_prefix(": ")
push_warning("Request to %s failed with response code %d%s"
% [path, response_code, _error_message(body)])
_call_if_valid(on_failed)
@@ -539,7 +587,7 @@ func _request_token() -> void:
_token_server = server
for waiter: Array in waiters:
waiter[0].call(token)
, JSON.stringify(token_request.to_dict()))
, JSON.stringify(token_request.to_dict()).to_utf8_buffer())
func _read_token(response_code: int, body: PackedByteArray) -> String:
@@ -568,6 +616,14 @@ static func _parse_or_null(body: PackedByteArray) -> Variant:
return json.data if json.parse(body.get_string_from_utf8()) == OK else null
static func _cover_path(uuid: String) -> String:
return "/soundtracks/%s/cover" % uuid.uri_encode()
static func _call_ignoring_body(_body: PackedByteArray, callback: Callable) -> void:
_call_if_valid(callback)
static func _call_if_valid(callback: Callable) -> void:
if callback.is_valid():
callback.call()
+1 -1
View File
@@ -20,7 +20,7 @@ theme_override_constants/separation = 10
[node name="CoverFrame" type="Panel" parent="VBox/Header"]
unique_name_in_owner = true
clip_children = 2
custom_minimum_size = Vector2(72, 100)
custom_minimum_size = Vector2(68, 102)
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"SoundtrackCover"
+8
View File
@@ -80,6 +80,14 @@ func load_cover(covers: CoverCache) -> void:
covers.fetch_cover(soundtrack.soundtrack_uuid, soundtrack.cover_modified, show_cover)
## Takes the soundtrack's new cover time [param modified], 0 when its cover was removed,
## and shows the initials until [method load_cover] fetches the new cover.
func set_cover_time(modified: int) -> void:
soundtrack.cover_modified = modified
show_cover(null)
_cover_asked = 0
## Shows [param cover], or the initials when it is null.
func show_cover(cover: Image) -> void:
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
+9
View File
@@ -200,6 +200,15 @@ func update_alternative_names(updated: Soundtrack) -> void:
apply_filter()
## Shows the new cover of the soundtrack [param uuid], with the cover time
## [param modified], or its initials when [param modified] is 0.
func update_cover(uuid: String, modified: int) -> void:
for card: SoundtrackCard in cards:
if card.soundtrack.soundtrack_uuid == uuid:
card.set_cover_time(modified)
load_covers_on_screen()
func _update_search_names() -> void:
var names: Array[String] = []
var alternatives: Dictionary = {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because it is too large. Load diff
+10 -5
View File
@@ -25,9 +25,12 @@ var requested_paths: Array[String] = []
var requested_methods: Array[int] = []
## Headers of each request, in the same order as [member requested_paths].
var requested_headers: Array[PackedStringArray] = []
## The JSON body of each request, empty for none, in the same order as
## The body of each request as text, e.g. its JSON, empty for none, in the same order as
## [member requested_paths].
var requested_bodies: Array[String] = []
## The body of each request as bytes, e.g. an uploaded picture, in the same order as
## [member requested_paths].
var requested_raw_bodies: Array[PackedByteArray] = []
## How many tokens have been asked for.
var token_requests: int = 0
## The JSON body of the last token request.
@@ -110,20 +113,22 @@ func request(
method: int,
on_complete: Callable,
headers: PackedStringArray = PackedStringArray(),
body: String = "",
body: PackedByteArray = PackedByteArray(),
) -> void:
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
if path == "/token" && method == HTTPClient.METHOD_POST:
token_requests += 1
if _held_paths.has(path):
_held_paths[path].append(_answer_token_request.bind(body, on_complete))
_held_paths[path].append(
_answer_token_request.bind(body.get_string_from_utf8(), on_complete))
return
_answer_token_request(body, on_complete)
_answer_token_request(body.get_string_from_utf8(), on_complete)
return
requested_paths.append(path)
requested_methods.append(method)
requested_headers.append(headers)
requested_bodies.append(body)
requested_bodies.append(body.get_string_from_utf8())
requested_raw_bodies.append(body)
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
PackedStringArray())
+19 -6
View File
@@ -15,7 +15,8 @@ const SOUNDTRACKS: Array[String] = [
"Donkey Kong Country 2", "Lufia II", "Illusion of Gaia",
]
const SONG_INFO: Dictionary = {
"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time",
"SongNo": 0, "Soundtrack": "Chrono Trigger", "SoundtrackUuid": "soundtrack-0",
"Song": "Corridors of Time",
}
const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00:00:02"}
const SYNC_RESULT: Dictionary = {
@@ -48,6 +49,8 @@ const JAPANESE_SONGS: Array[String] = [
## The character pictures the fake server hands out.
const CHARACTERS_DIR: String = "res://characters"
## Small copies of covers from the MusicServer, 2:3 like all its covers.
const COVERS_DIR: String = "res://tests/data/covers"
## Popups in the order the tour opens them: the state name, then the popup's node. The
## state names are what the popups were first called, so screenshots taken before they
@@ -91,12 +94,18 @@ static func make_server() -> FakeHttpTransport:
server.respond_bytes("/sync", PackedByteArray())
server.respond_json("/sync/progress", SYNC_PROGRESS)
server.respond_json("/statistics/soundtracks", soundtrack_statistics())
# The character pictures stand in for covers
var pictures: Array[String] = character_names()
for i: int in SOUNDTRACKS.size():
# Real covers from the server, made smaller, for every other soundtrack
for i: int in range(0, SOUNDTRACKS.size(), 2):
server.respond_bytes("/soundtracks/soundtrack-%d/cover" % i,
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(
pictures[i % pictures.size()])))
FileAccess.get_file_as_bytes(COVERS_DIR.path_join("cover_%d.jpg" % (i >> 1))))
# The soundtrack list tells the quiz which soundtracks have covers
var soundtracks: Array[Dictionary] = []
for soundtrack: Dictionary in soundtrack_statistics():
soundtracks.append({"uuid": soundtrack.soundtrack_uuid,
"name": soundtrack.soundtrack_name,
"cover_modified": soundtrack.get("cover_modified", 0),
"alternative_names": soundtrack.get("alternative_names", [])})
server.respond_json("/soundtracks", soundtracks)
server.respond_json("/version/history", SERVER_HISTORY)
return server
@@ -307,6 +316,10 @@ func run(on_state: Callable) -> void:
stats_popup.new_name_field.text = "Chrono"
await _settle()
await on_state.call("popup_SoundtrackStats_names", stats_popup)
stats_popup.change_cover()
stats_popup.cover_id_field.text = "5248061"
await _settle()
await on_state.call("popup_SoundtrackStats_cover", stats_popup)
stats_popup.stop_editing()
stats_popup.close()
_main.show_tab(&"quiz")
+1 -1
View File
@@ -206,7 +206,7 @@ func test_every_button_bounces() -> void:
func test_helpers_do_nothing_with_reduced_animations() -> void:
Settings.reduce_animations = true
var label: Label = _quiz.get_node("NowPlaying/SongLabel")
var label: Label = _quiz.get_node("NowPlaying/Titles/SongLabel")
Anim.pop(label)
Anim.fade_in(label)
+2 -2
View File
@@ -55,8 +55,8 @@ func _play_a_little() -> Array[PlayerObject]:
func _now_playing() -> Array[String]:
var now_playing: Node = _quiz.get_node("NowPlaying")
return [(now_playing.get_node("SoundtrackLabel") as Label).text,
(now_playing.get_node("SongLabel") as Label).text]
return [(now_playing.get_node("Titles/SoundtrackLabel") as Label).text,
(now_playing.get_node("Titles/SongLabel") as Label).text]
# Saving
+207 -2
View File
@@ -1,6 +1,7 @@
extends MainWindowTestSuite
## Soundtrack covers on the statistics cards and in the popup, loaded for the cards on
## screen, and editing a soundtrack's alternative names, which search then finds it by.
## Soundtrack covers on the statistics cards, in the popup and next to the answer in the
## quiz, loaded for the cards on screen; changing a cover from the popup; and editing a
## soundtrack's alternative names, which search then finds it by.
const STATISTICS: Array[Dictionary] = [
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", "soundtrack_played": 5,
@@ -21,6 +22,13 @@ func _before_window_starts() -> void:
var image: Image = Image.create(6, 8, false, Image.FORMAT_RGB8)
_png = image.save_png_to_buffer()
_server.respond_bytes("/soundtracks/st-1/cover", _png)
# The soundtrack list tells the quiz which soundtracks have covers
_server.respond_json("/soundtracks", [
{"uuid": "st-1", "name": "Chrono Trigger", "cover_modified": 100},
{"uuid": "st-2", "name": "Terranigma"},
])
_next_song_is({"SongNo": 0, "Soundtrack": "Chrono Trigger", "SoundtrackUuid": "st-1",
"Song": "Corridors of Time"})
func before_test() -> void:
@@ -185,3 +193,200 @@ func test_search_popup_finds_soundtracks_by_alternative_names() -> void:
assert_array(search.shown_names()).contains(["Chrono Trigger"])
var row: PanelContainer = search.search_results.get_child(0)
assert_str((row.get_child(0) as RichTextLabel).get_parsed_text()).contains("CT")
func _open_cover_menu() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.change_cover()
# The server's answer for the soundtrack once its cover changed
func _cover_time_becomes(modified: int) -> void:
_server.respond_json("/soundtracks/st-1", {"uuid": "st-1", "name": "Chrono Trigger",
"cover_modified": modified, "songs": []})
func _picture_file(bytes: PackedByteArray) -> String:
var path: String = create_temp_dir("pictures_%d" % randi()).path_join("cover.jpg")
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
file.store_buffer(bytes)
file.close()
return path
func _downloads(path: String) -> int:
var count: int = 0
for i: int in _server.requested_paths.size():
if _server.requested_paths[i] == path \
&& _server.requested_methods[i] == HTTPClient.METHOD_GET:
count += 1
return count
func _now_playing() -> NowPlaying:
return _quiz.get_node("NowPlaying")
func test_cover_menu_opens_in_place_of_the_songs() -> void:
await _open_cover_menu()
assert_bool(_popup.cover_editor.visible).is_true()
assert_bool(_popup.song_scroll.visible).is_false()
assert_bool(_popup.edit_names_button.disabled).is_true()
assert_bool(_popup.remove_cover_button.disabled).is_false()
_popup.stop_editing()
assert_bool(_popup.cover_editor.visible).is_false()
assert_bool(_popup.song_scroll.visible).is_true()
func test_soundtrack_without_a_cover_has_nothing_to_remove() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Terranigma").soundtrack)
_popup.change_cover()
assert_bool(_popup.remove_cover_button.disabled).is_true()
func test_uploaded_picture_becomes_the_cover() -> void:
var bytes: PackedByteArray = FileAccess.get_file_as_bytes(
"res://tests/data/covers/cover_0.jpg")
_cover_time_becomes(200)
await _open_cover_menu()
_popup.upload_picture(_picture_file(bytes))
var index: int = _server.requested_methods.find(HTTPClient.METHOD_PUT)
assert_str(_server.requested_paths[index]).is_equal("/soundtracks/st-1/cover")
assert_array(_server.requested_raw_bodies[index]).is_equal(bytes)
assert_array(_server.requested_headers[index]).contains(["Content-Type: image/jpeg"])
# The new cover is downloaded again for the popup and the card
assert_int(_card("Chrono Trigger").soundtrack.cover_modified).is_equal(200)
await _runner.simulate_frames(2)
# Downloaded once more, for the popup and the card together
assert_int(_downloads("/soundtracks/st-1/cover")).is_equal(2)
assert_object(_card("Chrono Trigger").cover_picture.texture).is_not_null()
assert_str(_popup.cover_status.text).is_equal("The new cover is saved.")
func test_file_that_is_not_a_picture_is_not_uploaded() -> void:
await _open_cover_menu()
_popup.upload_picture(_picture_file("not a picture".to_utf8_buffer()))
assert_int(_server.requested_methods.count(HTTPClient.METHOD_PUT)).is_equal(0)
assert_str(_popup.cover_status.text).contains("JPEG, PNG and WebP")
func test_cover_picked_by_id() -> void:
_server.respond_bytes("/soundtracks/st-1/cover/source", PackedByteArray(), 204)
_cover_time_becomes(300)
await _open_cover_menu()
_popup.cover_source_button.select(1)
_popup.cover_id_field.text = " 21 "
_popup.use_cover_source()
var index: int = _server.requested_paths.find("/soundtracks/st-1/cover/source")
assert_int(_server.requested_methods[index]).is_equal(HTTPClient.METHOD_PUT)
var body: Dictionary = JSON.parse_string(_server.requested_bodies[index])
assert_str(body.source).is_equal("anilist")
assert_str(body.id).is_equal("21")
assert_int(_popup.soundtrack.cover_modified).is_equal(300)
func test_no_id_asks_nothing() -> void:
await _open_cover_menu()
_popup.use_cover_source()
assert_int(_server.count("/soundtracks/st-1/cover/source")).is_equal(0)
func test_removed_cover_shows_the_initials() -> void:
_cover_time_becomes(0)
await _open_cover_menu()
_popup.remove_cover()
var index: int = _server.requested_methods.find(HTTPClient.METHOD_DELETE)
assert_str(_server.requested_paths[index]).is_equal("/soundtracks/st-1/cover")
assert_bool(_popup.cover_placeholder.visible).is_true()
assert_bool(_card("Chrono Trigger").cover_placeholder.visible).is_true()
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_false()
assert_bool(_popup.remove_cover_button.disabled).is_true()
func test_refused_change_shows_the_servers_reason() -> void:
_server.respond_json("/soundtracks/st-1/cover/source",
{"error": "covers are not enabled on this server"}, 503)
await _open_cover_menu()
_popup.cover_id_field.text = "21"
_popup.use_cover_source()
assert_str(_popup.cover_status.text).is_equal(
"Could not change the cover: covers are not enabled on this server.")
assert_bool(_popup.use_source_button.disabled).is_false()
func test_answer_shows_the_soundtracks_cover() -> void:
await _play_first_song()
assert_bool(_now_playing().has_cover()).is_false()
_press("SongControls/ShowAnswerButton")
assert_bool(_now_playing().has_cover()).is_true()
await _runner.simulate_frames(1)
# The cover sits just left of the titles, and the two are centered together
var area: Rect2 = _now_playing().get_global_rect()
var cover: Rect2 = _now_playing().cover_frame.get_global_rect()
var titles: Rect2 = _now_playing().titles.get_global_rect()
assert_float(titles.position.x - cover.end.x).is_equal(32.0)
assert_float(absf((cover.position.x - area.position.x) - (area.end.x - titles.end.x))) \
.is_less_equal(1.0)
# Up and down, the cover is centered between the top bar and the song list, and the
# titles on the cover
assert_float(absf((cover.position.y - area.position.y) - (area.end.y - cover.end.y))) \
.is_less_equal(1.0)
var text_middle: float = (_now_playing().soundtrack_label.get_global_rect().position.y
+ _now_playing().song_label.get_global_rect().end.y) / 2.0
assert_float(absf(text_middle - cover.get_center().y)).is_less_equal(1.0)
func test_answer_without_a_cover_shows_only_the_titles() -> void:
_view.covers.set_cover_times({"st-1": 0})
await _play_first_song()
_press("SongControls/ShowAnswerButton")
assert_bool(_now_playing().has_cover()).is_false()
assert_int(_server.count("/soundtracks/st-1/cover")).is_equal(0)
func test_next_song_hides_the_cover_until_its_answer() -> void:
await _play_first_song()
_press("SongControls/ShowAnswerButton")
_press("SongControls/NextButton")
# The playing song is stopped first, which takes half a second
await await_millis(700)
assert_int(Playlist.currently_playing_song).is_equal(1)
assert_bool(_now_playing().has_cover()).is_false()
func test_changed_cover_shows_in_the_quiz_too() -> void:
await _play_first_song()
_press("SongControls/ShowAnswerButton")
_cover_time_becomes(0)
_popup.show_soundtrack(SoundtrackWithSongs.from_dict(STATISTICS[0]))
_popup.change_cover()
_popup.remove_cover()
assert_bool(_now_playing().has_cover()).is_false()
+2 -2
View File
@@ -33,7 +33,7 @@ func test_next_plays_preloaded_song_and_fetches_another() -> void:
func test_title_is_hidden_until_answer_is_shown() -> void:
var soundtrack_label: Label = _quiz.get_node("NowPlaying/SoundtrackLabel")
var soundtrack_label: Label = _quiz.get_node("NowPlaying/Titles/SoundtrackLabel")
_press("SongControls/NextButton")
await _runner.simulate_frames(2)
@@ -224,7 +224,7 @@ func test_show_answer_before_first_song_does_not_reveal_next_song() -> void:
await _play_first_song()
assert_str((_quiz.get_node("NowPlaying/SoundtrackLabel") as Label).text) \
assert_str((_quiz.get_node("NowPlaying/Titles/SoundtrackLabel") as Label).text) \
.is_equal("????????")
+14 -1
View File
@@ -11,7 +11,7 @@ func before_test() -> void:
func _label(label_name: String) -> Label:
return _now_playing.get_node(label_name)
return _now_playing.get_node("Titles/" + label_name)
func test_starts_empty() -> void:
@@ -32,3 +32,16 @@ func test_soundtrack_is_above_song() -> void:
assert_float(_label("SoundtrackLabel").get_global_rect().end.y) \
.is_less_equal(_label("SongLabel").get_global_rect().position.y)
func test_long_titles_keep_to_two_lines_each() -> void:
_now_playing.size = Vector2(1088, 328)
_now_playing.show_song("Final Fantasy Dissidia 012 Duodecim (Sista Fantasin "
+ "Meningsskiljaktigheter 012 Tolv) Final Mix Special Edition",
"Cosmos (Arrangement) - Main Theme of the Warriors of Light, Long Version")
await _runner.simulate_frames(2)
for label_name: String in ["SoundtrackLabel", "SongLabel"]:
assert_int(_label(label_name).get_visible_line_count()).is_less_equal(2)
assert_float(_label("SongLabel").get_global_rect().end.y) \
.is_less_equal(_now_playing.get_global_rect().end.y)
+2 -2
View File
@@ -54,8 +54,8 @@ func _next() -> void:
func _now_playing() -> Array[String]:
var now_playing: Node = _quiz.get_node("NowPlaying")
return [(now_playing.get_node("SoundtrackLabel") as Label).text,
(now_playing.get_node("SongLabel") as Label).text]
return [(now_playing.get_node("Titles/SoundtrackLabel") as Label).text,
(now_playing.get_node("Titles/SongLabel") as Label).text]
func test_picking_a_folder_makes_no_server_requests() -> void:
+2 -2
View File
@@ -121,12 +121,12 @@ func test_clicking_quiz_buttons_plays_and_shows_answer() -> void:
await _click_at(_center(_quiz.get_node("SongControls/NextButton")))
await _runner.simulate_frames(2)
assert_int(Playlist.currently_playing_song).is_equal(0)
assert_str((_quiz.get_node("NowPlaying/SoundtrackLabel") as Label).text) \
assert_str((_quiz.get_node("NowPlaying/Titles/SoundtrackLabel") as Label).text) \
.is_not_equal("Chrono Trigger")
await _click_at(_center(_quiz.get_node("SongControls/ShowAnswerButton")))
assert_str((_quiz.get_node("NowPlaying/SoundtrackLabel") as Label).text) \
assert_str((_quiz.get_node("NowPlaying/Titles/SoundtrackLabel") as Label).text) \
.is_equal("Chrono Trigger")
+2 -2
View File
@@ -639,7 +639,7 @@ class _HeaderTransport:
_method: int,
on_complete: Callable,
_request_headers: PackedStringArray = PackedStringArray(),
_body: String = "",
_body: PackedByteArray = PackedByteArray(),
) -> void:
on_complete.call(200, JSON.stringify({"token": "t"} if _url.ends_with("/token") else [])
.to_utf8_buffer(), _headers)
@@ -659,7 +659,7 @@ class _RecordingTransport:
_method: int,
on_complete: Callable,
_headers: PackedStringArray = PackedStringArray(),
_body: String = "",
_body: PackedByteArray = PackedByteArray(),
) -> void:
_urls.append(url)
if url.ends_with("/token"):