Show soundtrack covers and edit alternative names (#49)
Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.
Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.
The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.
Closes #49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a735c515bf
commit
ef6ac8f1fd
37 files changed
+8029
-1763
No files matched your search
@@ -30,19 +30,78 @@ theme_type_variation = &"Title"
|
||||
horizontal_alignment = 1
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="SummaryLabel" type="Label" parent="StatsPanel" unique_id=1213000004]
|
||||
[node name="CoverFrame" type="Panel" parent="StatsPanel"]
|
||||
unique_name_in_owner = true
|
||||
clip_children = 2
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 64.0
|
||||
offset_right = 164.0
|
||||
offset_bottom = 264.0
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"SoundtrackCover"
|
||||
|
||||
[node name="CoverPlaceholder" type="Label" parent="StatsPanel/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
|
||||
theme_type_variation = &"TitleMedium"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="CoverPicture" type="TextureRect" parent="StatsPanel/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="Info" type="VBoxContainer" parent="StatsPanel"]
|
||||
layout_mode = 0
|
||||
offset_left = 180.0
|
||||
offset_top = 64.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 264.0
|
||||
|
||||
[node name="SummaryLabel" type="Label" parent="StatsPanel/Info" unique_id=1213000004]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="NamesTitle" type="Label" parent="StatsPanel/Info"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Also known as"
|
||||
|
||||
[node name="NamesLabel" type="Label" parent="StatsPanel/Info"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"TextMedium"
|
||||
autowrap_mode = 3
|
||||
text_overrun_behavior = 3
|
||||
max_lines_visible = 3
|
||||
|
||||
[node name="EditNamesButton" type="Button" parent="StatsPanel/Info"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
focus_mode = 0
|
||||
text = "Edit names"
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 54.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 80.0
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 92.0
|
||||
offset_top = 280.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 784.0
|
||||
horizontal_scroll_mode = 0
|
||||
@@ -51,3 +110,73 @@ horizontal_scroll_mode = 0
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="NamesEditor" 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="EditorTitle" type="Label" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Title"
|
||||
text = "Alternative names"
|
||||
|
||||
[node name="EditorHelp" type="Label" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
text = "Other names the soundtrack goes by, e.g. short names or the name in another language. Search finds it by any of them."
|
||||
|
||||
[node name="EditorScroll" type="ScrollContainer" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="EditorNames" type="VBoxContainer" parent="StatsPanel/NamesEditor/EditorScroll"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AddRow" type="HBoxContainer" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="NewNameField" type="LineEdit" parent="StatsPanel/NamesEditor/AddRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Another name"
|
||||
|
||||
[node name="AddNameButton" type="Button" parent="StatsPanel/NamesEditor/AddRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Add"
|
||||
|
||||
[node name="EditorStatus" type="Label" parent="StatsPanel/NamesEditor"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="EditorButtons" type="HBoxContainer" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 2
|
||||
|
||||
[node name="CancelNamesButton" type="Button" parent="StatsPanel/NamesEditor/EditorButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Cancel"
|
||||
|
||||
[node name="SaveNamesButton" type="Button" parent="StatsPanel/NamesEditor/EditorButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Save"
|
||||
+36
-11
@@ -1,7 +1,8 @@
|
||||
class_name SearchPopup
|
||||
extends BasePopup
|
||||
## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and
|
||||
## highlights the letters that matched.
|
||||
## highlights the letters that matched. Soundtracks are also found by their alternative
|
||||
## names; the alternative name that matched is shown after the real name.
|
||||
|
||||
## Every soundtrack on the server, sorted by name.
|
||||
var soundtracks: Array = []
|
||||
@@ -10,6 +11,8 @@ var _matcher: SoundtrackSearch = SoundtrackSearch.new()
|
||||
# One row per soundtrack, in the order of soundtracks. Rows are reused between searches.
|
||||
var _rows: Array[PanelContainer] = []
|
||||
var _index_of: Dictionary = {}
|
||||
# The other names of each soundtrack, by its real name
|
||||
var _alternatives: Dictionary = {}
|
||||
|
||||
@onready var search_panel: Panel = %SearchPanel
|
||||
@onready var search_field: LineEdit = %SearchField
|
||||
@@ -86,19 +89,25 @@ func get_list_of_soundtracks() -> void:
|
||||
if Settings.is_offline():
|
||||
set_soundtracks(Settings.music_folder.soundtrack_names())
|
||||
return
|
||||
var handle_soundtracks: Callable = func handle_soundtracks(array: Variant) -> void:
|
||||
if typeof(array) == TYPE_ARRAY:
|
||||
set_soundtracks(array)
|
||||
else:
|
||||
GameLog.warning("Unexpected data")
|
||||
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
|
||||
var with_names: Callable = func with_names(list: Array[Soundtrack]) -> void:
|
||||
var names: Array[String] = []
|
||||
var alternatives: Dictionary = {}
|
||||
for soundtrack: Soundtrack in list:
|
||||
names.append(soundtrack.name)
|
||||
alternatives[soundtrack.name] = soundtrack.alternative_names
|
||||
names.sort_custom(func(a: String, b: String) -> bool:
|
||||
return a.naturalnocasecmp_to(b) < 0)
|
||||
set_soundtracks(names, alternatives)
|
||||
MusicServerClient.fetch_soundtracks(with_names, _load_names_only)
|
||||
|
||||
|
||||
## Replaces the searchable soundtracks and shows them, keeping the current search.
|
||||
func set_soundtracks(list: Array) -> void:
|
||||
## [param alternatives] has the other names of a soundtrack, by its real name.
|
||||
func set_soundtracks(list: Array, alternatives: Dictionary = {}) -> void:
|
||||
soundtracks = []
|
||||
soundtracks.append_array(list)
|
||||
_matcher.set_names(soundtracks)
|
||||
_alternatives = alternatives
|
||||
_matcher.set_names(soundtracks, alternatives)
|
||||
_index_of.clear()
|
||||
for index: int in range(soundtracks.size() - 1, -1, -1):
|
||||
_index_of[soundtracks[index]] = index
|
||||
@@ -145,9 +154,25 @@ func _make_row() -> PanelContainer:
|
||||
return row
|
||||
|
||||
|
||||
# Servers without /soundtracks only list the names
|
||||
func _load_names_only() -> void:
|
||||
MusicServerClient.fetch_all_soundtracks_ordered(func(array: Variant) -> void:
|
||||
if typeof(array) == TYPE_ARRAY:
|
||||
set_soundtracks(array)
|
||||
else:
|
||||
GameLog.warning("Unexpected data"))
|
||||
|
||||
|
||||
func _show_name(row: PanelContainer, soundtrack_name: String, search_text: String) -> void:
|
||||
var text: String = highlighted_name(soundtrack_name, search_text,
|
||||
get_theme_color(&"font_color", &"HighlightLarge"))
|
||||
var highlight: Color = get_theme_color(&"font_color", &"HighlightLarge")
|
||||
var text: String = highlighted_name(soundtrack_name, search_text, highlight)
|
||||
# Found by another name: show which, dimmer, after the real name
|
||||
var alternative: String = _matcher.matched_alternative(soundtrack_name) \
|
||||
if !search_text.is_empty() else ""
|
||||
if !alternative.is_empty():
|
||||
text += " [color=#%s]%s[/color]" % [
|
||||
get_theme_color(&"default_color", &"SongStatsSecondary").to_html(false),
|
||||
highlighted_name(alternative, search_text, highlight)]
|
||||
var label: RichTextLabel = row.get_child(0)
|
||||
# Setting the same text again would make the label lay it out again for nothing
|
||||
if label.text != text:
|
||||
|
||||
@@ -1,27 +1,173 @@
|
||||
class_name SoundtrackStatsPopup
|
||||
extends BasePopup
|
||||
## Shows a soundtrack's play statistics and every song in it, most played first.
|
||||
## Opened by clicking a card in the statistics view.
|
||||
## 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.
|
||||
|
||||
## Emitted when the alternative names of [param soundtrack] have been saved.
|
||||
signal alternative_names_changed(soundtrack: Soundtrack)
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
## Shows [param soundtrack] and opens the popup.
|
||||
func show_soundtrack(soundtrack: SoundtrackWithSongs) -> void:
|
||||
title_label.text = soundtrack.soundtrack_name
|
||||
var summary: PackedStringArray = [SoundtrackCard.played_text(soundtrack.soundtrack_played)]
|
||||
var last_played: String = SoundtrackCard.last_played_text(soundtrack.soundtrack_last_played)
|
||||
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)
|
||||
|
||||
|
||||
## 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" % [soundtrack.songs.size(),
|
||||
"song" if soundtrack.songs.size() == 1 else "songs"])
|
||||
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)
|
||||
_show_cover(null)
|
||||
if covers != null:
|
||||
covers.fetch_cover(shown.soundtrack_uuid, shown.cover_modified,
|
||||
_show_cover_of.bind(shown.soundtrack_uuid))
|
||||
|
||||
Settings.delete_children(song_list)
|
||||
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(soundtrack.songs)
|
||||
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()
|
||||
names_editor.visible = true
|
||||
song_scroll.visible = false
|
||||
edit_names_button.disabled = true
|
||||
new_name_field.grab_focus()
|
||||
|
||||
|
||||
## Closes the editor without saving.
|
||||
func stop_editing() -> void:
|
||||
names_editor.visible = false
|
||||
song_scroll.visible = true
|
||||
edit_names_button.disabled = false
|
||||
_set_saving(false)
|
||||
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
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)
|
||||
Reference in new issue
Block a user