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

+57
View File
@@ -0,0 +1,57 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://statistics/soundtrack_card.gd" id="card_script"]
[node name="SoundtrackCard" type="PanelContainer" unique_id=1211000001]
custom_minimum_size = Vector2(290, 340)
mouse_default_cursor_shape = 2
theme_type_variation = &"StatisticsCard"
script = ExtResource("card_script")
[node name="VBox" type="VBoxContainer" parent="." unique_id=1211000002]
layout_mode = 2
mouse_filter = 2
[node name="NameLabel" type="Label" parent="VBox" unique_id=1211000003]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 62)
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"TextLarge"
autowrap_mode = 3
text_overrun_behavior = 3
max_lines_visible = 2
[node name="PlayedLabel" type="Label" parent="VBox" unique_id=1211000004]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
[node name="LastPlayedLabel" type="Label" parent="VBox" unique_id=1211000005]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
[node name="MostPlayedHeader" type="Label" parent="VBox" unique_id=1211000006]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"HighlightMedium"
text = "Most played"
[node name="MostPlayedList" type="VBoxContainer" parent="VBox" unique_id=1211000007]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
[node name="LeastPlayedHeader" type="Label" parent="VBox" unique_id=1211000008]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"HighlightMedium"
text = "Least played"
[node name="LeastPlayedList" type="VBoxContainer" parent="VBox" unique_id=1211000009]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
+132
View File
@@ -0,0 +1,132 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://statistics/statistics_view.gd" id="view_script"]
[node name="StatisticsView" type="Panel" unique_id=1212000001]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 56.0
grow_horizontal = 2
grow_vertical = 2
theme_type_variation = &"WindowBackground"
script = ExtResource("view_script")
[node name="Toolbar" type="HBoxContainer" parent="." unique_id=1212000002]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_left = 32.0
offset_top = 16.0
offset_right = -32.0
offset_bottom = 56.0
grow_horizontal = 2
theme_override_constants/separation = 12
[node name="SearchField" type="LineEdit" parent="Toolbar" unique_id=1212000003]
unique_name_in_owner = true
custom_minimum_size = Vector2(480, 0)
layout_mode = 2
placeholder_text = "Search soundtracks"
clear_button_enabled = true
[node name="ClearButton" type="Button" parent="Toolbar" unique_id=1212000004]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Clear"
[node name="CountLabel" type="Label" parent="Toolbar" unique_id=1212000005]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
horizontal_alignment = 1
[node name="ShowLabel" type="Label" parent="Toolbar" unique_id=1212000006]
layout_mode = 2
text = "Show"
[node name="ShowOption" type="OptionButton" parent="Toolbar" unique_id=1212000007]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 0)
layout_mode = 2
focus_mode = 0
selected = 0
item_count = 3
popup/item_0/text = "All"
popup/item_0/id = 0
popup/item_1/text = "Played"
popup/item_1/id = 1
popup/item_2/text = "Never played"
popup/item_2/id = 2
[node name="SortLabel" type="Label" parent="Toolbar" unique_id=1212000008]
layout_mode = 2
text = "Sort"
[node name="SortOption" type="OptionButton" parent="Toolbar" unique_id=1212000009]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 0)
layout_mode = 2
focus_mode = 0
selected = 0
item_count = 4
popup/item_0/text = "A-Z"
popup/item_0/id = 0
popup/item_1/text = "Z-A"
popup/item_1/id = 1
popup/item_2/text = "Most played"
popup/item_2/id = 2
popup/item_3/text = "Least played"
popup/item_3/id = 3
[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=1212000010]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 72.0
offset_right = -32.0
offset_bottom = -16.0
grow_horizontal = 2
grow_vertical = 2
horizontal_scroll_mode = 0
[node name="CardGrid" type="GridContainer" parent="ScrollContainer" unique_id=1212000011]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 0
theme_type_variation = &"StatisticsGrid"
columns = 6
[node name="StatusLabel" type="Label" parent="." unique_id=1212000012]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -40.0
offset_bottom = -4.0
grow_horizontal = 2
grow_vertical = 2
theme_type_variation = &"Title"
horizontal_alignment = 1
[node name="RetryButton" type="Button" parent="." unique_id=1212000013]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -50.0
offset_top = 8.0
offset_right = 50.0
offset_bottom = 48.0
grow_horizontal = 2
grow_vertical = 2
focus_mode = 0
text = "Retry"
+104
View File
@@ -0,0 +1,104 @@
class_name SoundtrackCard
extends PanelContainer
## One soundtrack in the statistics view: its name, how often and when it was last
## played, and its most and least played songs. Clicking it asks for all its songs.
## Emitted when the card is clicked.
signal pressed(soundtrack: SoundtrackWithSongs)
## How many of the most and least played songs the card lists.
const SONGS_SHOWN: int = 3
## The soundtrack the card shows.
var soundtrack: SoundtrackWithSongs
@onready var name_label: Label = %NameLabel
@onready var played_label: Label = %PlayedLabel
@onready var last_played_label: Label = %LastPlayedLabel
@onready var most_played_header: Label = %MostPlayedHeader
@onready var most_played_list: VBoxContainer = %MostPlayedList
@onready var least_played_header: Label = %LeastPlayedHeader
@onready var least_played_list: VBoxContainer = %LeastPlayedList
func _ready() -> void:
mouse_entered.connect(_set_hovered.bind(true))
mouse_exited.connect(_set_hovered.bind(false))
gui_input.connect(_on_gui_input)
## Shows [param data] on the card.
func show_soundtrack(data: SoundtrackWithSongs) -> void:
soundtrack = data
name_label.text = data.soundtrack_name
tooltip_text = data.soundtrack_name
played_label.text = played_text(data.soundtrack_played)
last_played_label.text = last_played_text(data.soundtrack_last_played)
last_played_label.visible = !data.soundtrack_last_played.is_empty()
var most_played: Array[SongInfoForStats] = songs_by_plays(data.songs).slice(0, SONGS_SHOWN)
# Without the songs already shown as most played
var least_played: Array[SongInfoForStats] = songs_by_plays(data.songs, true).filter(
func(song: SongInfoForStats) -> bool: return !most_played.has(song)
).slice(0, SONGS_SHOWN)
most_played_header.text = "Most played" if !data.songs.is_empty() else "No songs"
_fill_song_list(most_played_list, most_played)
least_played_header.visible = !least_played.is_empty()
_fill_song_list(least_played_list, least_played)
## "Played 3 times", or "Never played" for [code]0[/code].
static func played_text(times_played: int) -> String:
if times_played == 0:
return "Never played"
return "Played %d %s" % [times_played, "time" if times_played == 1 else "times"]
## "Last played 2026-10-01" from the server's date and time, or an empty String when
## there is none.
static func last_played_text(last_played: String) -> String:
if last_played.is_empty():
return ""
return "Last played " + last_played.substr(0, 10)
## One line of a song list: how often the song was played, then its name.
static func song_text(song: SongInfoForStats) -> String:
return "%d × %s" % [song.times_played, song.song_name]
## [param songs] sorted with the most played first, or the least played first with
## [param least_first], and by name when played as often.
static func songs_by_plays(
songs: Array[SongInfoForStats], least_first: bool = false) -> Array[SongInfoForStats]:
var sorted: Array[SongInfoForStats] = songs.duplicate()
sorted.sort_custom(func(a: SongInfoForStats, b: SongInfoForStats) -> bool:
if a.times_played != b.times_played:
return (a.times_played < b.times_played) == least_first
return a.song_name.naturalnocasecmp_to(b.song_name) < 0
)
return sorted
func _fill_song_list(list: VBoxContainer, songs: Array[SongInfoForStats]) -> void:
while list.get_child_count() < songs.size():
var label: Label = Label.new()
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
list.add_child(label)
for i: int in list.get_child_count():
var label: Label = list.get_child(i)
label.visible = i < songs.size()
if label.visible:
label.text = song_text(songs[i])
func _set_hovered(hovered: bool) -> void:
theme_type_variation = &"StatisticsCardHover" if hovered else &"StatisticsCard"
func _on_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton && event.pressed \
&& event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
pressed.emit(soundtrack)
+1
View File
@@ -0,0 +1 @@
uid://beeibc7xpftq1
+139
View File
@@ -0,0 +1,139 @@
class_name StatisticsView
extends Panel
## Shows every soundtrack on the server as a card with its play statistics. The cards
## can be searched, filtered on whether they have been played, and sorted.
##
## Shown instead of the quiz when the Statistics tab is chosen.
## Emitted when a card is clicked, to show all songs of [param soundtrack].
signal soundtrack_selected(soundtrack: SoundtrackWithSongs)
## The orders the cards can be sorted in, in the order of the sort menu.
enum SortOrder { A_TO_Z, Z_TO_A, MOST_PLAYED, LEAST_PLAYED }
## Which soundtracks are shown, in the order of the show menu.
enum ShowFilter { ALL, PLAYED, NEVER_PLAYED }
const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn")
## The cards in the order they are shown, hidden ones included.
var cards: Array[SoundtrackCard] = []
var _search: SoundtrackSearch = SoundtrackSearch.new()
var _loading: bool = false
@onready var search_field: LineEdit = %SearchField
@onready var clear_button: Button = %ClearButton
@onready var show_option: OptionButton = %ShowOption
@onready var sort_option: OptionButton = %SortOption
@onready var count_label: Label = %CountLabel
@onready var status_label: Label = %StatusLabel
@onready var retry_button: Button = %RetryButton
@onready var card_grid: GridContainer = %CardGrid
func _ready() -> void:
search_field.text_changed.connect(func(_text: String) -> void: apply_filter())
clear_button.pressed.connect(clear_search)
show_option.item_selected.connect(func(_index: int) -> void: apply_filter())
sort_option.item_selected.connect(func(_index: int) -> void: apply_sort())
retry_button.pressed.connect(refresh)
retry_button.visible = false
## Loads the statistics from the server. The cards already shown stay until the new
## ones arrive.
func refresh() -> void:
if _loading:
return
_loading = true
retry_button.visible = false
if cards.is_empty():
status_label.text = "Loading statistics…"
MusicServerClient.fetch_soundtrack_statistics(_on_loaded, _on_load_failed)
## Shows every card again, keeping the show filter and the sort order.
func clear_search() -> void:
search_field.text = ""
apply_filter()
## Puts the cards in the order chosen in the sort menu.
func apply_sort() -> void:
var order: SortOrder = sort_option.selected as SortOrder
cards.sort_custom(func(a: SoundtrackCard, b: SoundtrackCard) -> bool:
return _comes_before(a.soundtrack, b.soundtrack, order))
for i: int in cards.size():
card_grid.move_child(cards[i], i)
## Shows only the cards that match the search text and the show menu.
func apply_filter() -> void:
var search_text: String = search_field.text.strip_edges()
var matching: Dictionary = {}
for soundtrack_name: String in _search.find_matches(search_text) \
if !search_text.is_empty() else []:
matching[soundtrack_name] = true
var filter: ShowFilter = show_option.selected as ShowFilter
var shown: int = 0
for card: SoundtrackCard in cards:
card.visible = (search_text.is_empty() || matching.has(card.soundtrack.soundtrack_name)) \
&& _passes_filter(card.soundtrack, filter)
if card.visible:
shown += 1
if shown == cards.size():
count_label.text = "%d soundtracks" % cards.size()
else:
count_label.text = "%d of %d soundtracks" % [shown, cards.size()]
func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
_loading = false
status_label.text = ""
# Cards are reused, so opening the tab again only updates their text
while cards.size() < soundtracks.size():
var card: SoundtrackCard = CARD_SCENE.instantiate()
card_grid.add_child(card)
card.pressed.connect(soundtrack_selected.emit)
cards.append(card)
while cards.size() > soundtracks.size():
cards.pop_back().free()
var names: Array[String] = []
for i: int in soundtracks.size():
cards[i].show_soundtrack(soundtracks[i])
names.append(soundtracks[i].soundtrack_name)
_search.set_names(names)
apply_sort()
apply_filter()
func _on_load_failed() -> void:
_loading = false
if cards.is_empty():
status_label.text = "Could not load statistics"
retry_button.visible = true
static func _passes_filter(soundtrack: SoundtrackWithSongs, filter: ShowFilter) -> bool:
match filter:
ShowFilter.PLAYED:
return soundtrack.soundtrack_played > 0
ShowFilter.NEVER_PLAYED:
return soundtrack.soundtrack_played == 0
return true
static func _comes_before(
a: SoundtrackWithSongs, b: SoundtrackWithSongs, order: SortOrder) -> bool:
var by_name: int = a.soundtrack_name.naturalnocasecmp_to(b.soundtrack_name)
match order:
SortOrder.Z_TO_A:
return by_name > 0
SortOrder.MOST_PLAYED:
if a.soundtrack_played != b.soundtrack_played:
return a.soundtrack_played > b.soundtrack_played
SortOrder.LEAST_PLAYED:
if a.soundtrack_played != b.soundtrack_played:
return a.soundtrack_played < b.soundtrack_played
return by_name < 0
+1
View File
@@ -0,0 +1 @@
uid://bdh0vdom0jenq