Add a Statistics tab with a card for every soundtrack
Build / test (push) Successful in 40s
Build / build (push) Successful in 1m16s

Part of #41. The top bar has Quiz and Statistics tabs instead of the
Statistics button. The Statistics tab replaces the quiz below the top bar
(the song keeps playing) and shows every soundtrack as a card with its
plays, last played date and most and least played songs, loaded from the
new GET /api/v1/statistics/soundtracks. The cards can be searched (with
the Search popup's matching, now in SoundtrackSearch), filtered on played
or never played, and sorted A-Z, Z-A, most or least played. Clicking a
card opens a popup with all its songs.

Screenshots of the other states only differ in the top bar, and the
layout baseline is taken again with the new statistics states.
This commit is contained in:
Sansan committed 2026-10-06 23:33:00 +02:00
1 parent f7619baf34
commit a21b6e9b22
30 files changed
+25379 -2306

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://popups/soundtrack_stats_popup.gd" id="popup_script"]
[node name="SoundtrackStatsPopup" type="Control" unique_id=1213000001 node_paths=PackedStringArray("panel")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("popup_script")
panel = NodePath("StatsPanel")
[node name="StatsPanel" type="Panel" parent="." unique_id=1213000002]
unique_name_in_owner = true
layout_mode = 0
offset_right = 700.0
offset_bottom = 800.0
theme_type_variation = &"PopupBackground"
[node name="TitleLabel" type="Label" parent="StatsPanel" unique_id=1213000003]
unique_name_in_owner = true
layout_mode = 0
offset_left = 20.0
offset_top = 12.0
offset_right = 680.0
offset_bottom = 50.0
theme_type_variation = &"Title"
horizontal_alignment = 1
text_overrun_behavior = 3
[node name="SummaryLabel" type="Label" parent="StatsPanel" unique_id=1213000004]
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_right = 680.0
offset_bottom = 784.0
horizontal_scroll_mode = 0
[node name="SongList" type="VBoxContainer" parent="StatsPanel/ScrollContainer" unique_id=1213000006]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
+6 -78
View File
@@ -1,18 +1,12 @@
class_name SearchPopup
extends BasePopup
## Searches every soundtrack on the server as you type. Matches ignore case, spaces and
## special characters, and also find the letters in order with others in between.
## Searches every soundtrack on the server as you type, with [SoundtrackSearch].
var soundtracks: Array = []
## Each soundtrack as a Dictionary with its original, lowercase and cleaned name.
var precomputed_soundtracks: Array = []
## Compiled patterns for the letters-in-order search, by cleaned search term.
var regex_cache: Dictionary = {}
var label_pool: Array[Label] = []
var _matcher: SoundtrackSearch = SoundtrackSearch.new()
@onready var search_list: VBoxContainer = %SearchResults
@onready var search_panel: Panel = %SearchPanel
@onready var clear_button: Button = $ClearButton
@@ -67,70 +61,10 @@ func perform_search(search_text: String) -> void:
child.visible = false
label_pool.append(child)
for soundtrack: String in find_matches(search_text):
for soundtrack: String in _matcher.find_matches(search_text):
add_soundtrack_reuse(soundtrack)
## Returns the soundtracks matching [param search_text], best matches first: plain
## case-insensitive matches, then matches ignoring spaces and special characters,
## then matches with the letters in order but not next to each other.
func find_matches(search_text: String) -> Array[String]:
var seen: Dictionary = {}
var search_text_lower: String = search_text.to_lower()
var clean_search_text: String = clean_term(search_text)
var matches: Array[String] = []
# Pass 1: Exact case-insensitive contains match on lowercase versions
for i: int in range(precomputed_soundtracks.size()):
var st_data: Dictionary = precomputed_soundtracks[i]
if st_data["lowercase"].contains(search_text_lower):
if not st_data["original"] in seen:
seen[st_data["original"]] = true
matches.append(st_data["original"])
# Pass 2: Contains match on cleaned terms
for i: int in range(precomputed_soundtracks.size()):
var st_data: Dictionary = precomputed_soundtracks[i]
if st_data["cleaned"].contains(clean_search_text):
if not st_data["original"] in seen:
seen[st_data["original"]] = true
matches.append(st_data["original"])
# Pass 3: Regex match on cleaned terms
var regex: RegEx = compile_regex_cached(clean_search_text)
for i: int in range(precomputed_soundtracks.size()):
var st_data: Dictionary = precomputed_soundtracks[i]
if regex.search(st_data["cleaned"]):
if not st_data["original"] in seen:
seen[st_data["original"]] = true
matches.append(st_data["original"])
return matches
## Cleans a term by removing spaces and replacing special characters.
## Matches MusicServer's clean_term() function.
func clean_term(term: String) -> String:
return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and") \
.replace("'n", "and").to_lower()
## Compiles the letters-in-order pattern for [param search_term], once per term.
func compile_regex_cached(search_term: String) -> RegEx:
if not search_term in regex_cache:
var regex: RegEx = RegEx.new()
var regex_text: String = ".*"
for letter: String in search_term:
# Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work
if "\\^$.|?*+()[]{}".contains(letter):
regex_text += "\\"
regex_text += letter + ".*"
regex.compile(regex_text)
regex_cache[search_term] = regex
return regex_cache[search_term]
func add_soundtrack(soundtrack: String) -> void:
# Backward compatibility - delegates to reuse version
add_soundtrack_reuse(soundtrack)
@@ -177,17 +111,11 @@ func get_list_of_soundtracks() -> void:
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
## Replaces the searchable soundtracks and precomputes their search forms.
## Replaces the searchable soundtracks.
func set_soundtracks(list: Array) -> void:
soundtracks = []
precomputed_soundtracks = []
soundtracks.append_array(list)
for soundtrack: String in soundtracks:
precomputed_soundtracks.append({
"original": soundtrack,
"lowercase": soundtrack.to_lower(),
"cleaned": clean_term(soundtrack)
})
_matcher.set_names(soundtracks)
func clear() -> void:
+27
View File
@@ -0,0 +1,27 @@
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.
@onready var title_label: Label = %TitleLabel
@onready var summary_label: Label = %SummaryLabel
@onready var song_list: VBoxContainer = %SongList
## 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)
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_label.text = " · ".join(summary)
Settings.delete_children(song_list)
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(soundtrack.songs)
for row: int in songs.size():
song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row,
&"TextLarge"))
open()
+1
View File
@@ -0,0 +1 @@
uid://sjteu2abyr0f