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>
189 lines
6.6 KiB
GDScript
189 lines
6.6 KiB
GDScript
class_name SearchPopup
|
|
extends BasePopup
|
|
## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and
|
|
## 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 = []
|
|
|
|
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
|
|
@onready var clear_button: Button = %ClearButton
|
|
@onready var count_label: Label = %CountLabel
|
|
@onready var empty_label: Label = %EmptyLabel
|
|
@onready var search_results: VBoxContainer = %SearchResults
|
|
|
|
|
|
func _ready() -> void:
|
|
clear_button.pressed.connect(clear)
|
|
search_field.text_changed.connect(func(_text: String) -> void: search())
|
|
visibility_changed.connect(_on_visibility_changed)
|
|
|
|
|
|
func close() -> void:
|
|
clear()
|
|
super()
|
|
|
|
|
|
## Empties the search field and shows every soundtrack.
|
|
func clear() -> void:
|
|
search_field.text = ""
|
|
search()
|
|
search_field.grab_focus()
|
|
|
|
|
|
## Shows the soundtracks matching the search field, best matches first, or all of them
|
|
## when it is empty.
|
|
func search() -> void:
|
|
var search_text: String = search_field.text.strip_edges()
|
|
var shown: Array[int] = []
|
|
if search_text.is_empty():
|
|
shown.assign(range(soundtracks.size()))
|
|
else:
|
|
for soundtrack: String in _matcher.find_matches(search_text):
|
|
shown.append(_index_of[soundtrack])
|
|
|
|
var is_shown: Array[bool] = []
|
|
is_shown.resize(_rows.size())
|
|
for position: int in shown.size():
|
|
var row: PanelContainer = _rows[shown[position]]
|
|
if row.get_index() != position:
|
|
search_results.move_child(row, position)
|
|
# Rows that come back with the search fade in
|
|
if !row.visible:
|
|
Anim.fade_in(row, Anim.FAST)
|
|
row.visible = true
|
|
row.theme_type_variation = &"ListRowOdd" if position % 2 != 0 else &"ListRowEven"
|
|
_show_name(row, soundtracks[shown[position]], search_text)
|
|
is_shown[shown[position]] = true
|
|
for index: int in _rows.size():
|
|
if !is_shown[index]:
|
|
_rows[index].visible = false
|
|
|
|
empty_label.visible = shown.is_empty() && !soundtracks.is_empty()
|
|
if search_text.is_empty():
|
|
count_label.text = "%d soundtracks" % soundtracks.size()
|
|
else:
|
|
count_label.text = "%d of %d soundtracks" % [shown.size(), soundtracks.size()]
|
|
|
|
|
|
## The names of the soundtracks shown, in the order they are shown.
|
|
func shown_names() -> Array[String]:
|
|
var names: Array[String] = []
|
|
for row: PanelContainer in search_results.get_children():
|
|
if row.visible:
|
|
names.append(row.get_meta(&"soundtrack"))
|
|
return names
|
|
|
|
|
|
func get_list_of_soundtracks() -> void:
|
|
GameLog.debug("get_list_of_soundtracks")
|
|
if Settings.is_offline():
|
|
set_soundtracks(Settings.music_folder.soundtrack_names())
|
|
return
|
|
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)
|
|
soundtracks_loaded.emit(list)
|
|
MusicServerClient.fetch_soundtracks(with_names, _load_names_only)
|
|
|
|
|
|
## Replaces the searchable soundtracks and shows them, keeping the current search.
|
|
## [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)
|
|
_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
|
|
while _rows.size() < soundtracks.size():
|
|
var row: PanelContainer = _make_row()
|
|
search_results.add_child(row)
|
|
_rows.append(row)
|
|
while _rows.size() > soundtracks.size():
|
|
_rows.pop_back().free()
|
|
for index: int in soundtracks.size():
|
|
_rows[index].set_meta(&"soundtrack", soundtracks[index])
|
|
search()
|
|
|
|
|
|
## [param soundtrack_name] as BBCode, with the letters [param search_text] matched in
|
|
## [param highlight_color].
|
|
static func highlighted_name(
|
|
soundtrack_name: String, search_text: String, highlight_color: Color) -> String:
|
|
var matched: Array[int] = SoundtrackSearch.matched_letters(soundtrack_name, search_text)
|
|
var text: String = ""
|
|
var in_highlight: bool = false
|
|
for i: int in soundtrack_name.length():
|
|
var highlight: bool = matched.has(i)
|
|
if highlight != in_highlight:
|
|
text += "[color=#%s]" % highlight_color.to_html(false) if highlight else "[/color]"
|
|
in_highlight = highlight
|
|
var letter: String = soundtrack_name[i]
|
|
text += "[lb]" if letter == "[" else letter
|
|
if in_highlight:
|
|
text += "[/color]"
|
|
return text
|
|
|
|
|
|
func _make_row() -> PanelContainer:
|
|
var row: PanelContainer = PanelContainer.new()
|
|
var label: RichTextLabel = RichTextLabel.new()
|
|
label.theme_type_variation = &"SearchResult"
|
|
label.bbcode_enabled = true
|
|
label.fit_content = true
|
|
label.scroll_active = false
|
|
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
row.add_child(label)
|
|
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 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:
|
|
label.text = text
|
|
|
|
|
|
func _on_visibility_changed() -> void:
|
|
if visible:
|
|
clear()
|