Show every song in the statistics, as an alternative to the cards
A Soundtracks / Songs switch in the statistics toolbar shows a list of every song with its soundtrack and how often it was played. It is searched on both song and soundtrack names, filtered and sorted like the cards, and clicking a song shows its soundtrack's popup. The server has tens of thousands of songs, so the list only makes the rows on screen and reuses them while scrolling, songs are sorted once per load, and searching waits for a pause in typing. Closes #47
This commit is contained in:
1 parent
cd4331881c
commit
a4e6da4c28
14 files changed
+10057
-1669
No files matched your search
@@ -0,0 +1,68 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://statistics/song_stats_list.gd" id="list_script"]
|
||||
|
||||
[node name="SongStatsList" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("list_script")
|
||||
|
||||
[node name="Layout" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Columns" type="VBoxContainer" parent="Layout"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Header" type="PanelContainer" parent="Layout/Columns"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"SongStatsHeader"
|
||||
|
||||
[node name="Titles" type="HBoxContainer" parent="Layout/Columns/Header"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="SongTitle" type="Label" parent="Layout/Columns/Header/Titles"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_stretch_ratio = 3.0
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Song"
|
||||
|
||||
[node name="SoundtrackTitle" type="Label" parent="Layout/Columns/Header/Titles"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_stretch_ratio = 2.0
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Soundtrack"
|
||||
|
||||
[node name="PlayedTitle" type="Label" parent="Layout/Columns/Header/Titles"]
|
||||
custom_minimum_size = Vector2(100, 0)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Played"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="RowsArea" type="Control" parent="Layout/Columns"]
|
||||
unique_name_in_owner = true
|
||||
clip_contents = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="ScrollBar" type="VScrollBar" parent="Layout"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
@@ -0,0 +1,50 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://statistics/song_stats_row.gd" id="row_script"]
|
||||
|
||||
[node name="SongStatsRow" type="PanelContainer"]
|
||||
mouse_filter = 1
|
||||
theme_type_variation = &"ListRowEven"
|
||||
script = ExtResource("row_script")
|
||||
|
||||
[node name="Columns" type="HBoxContainer" parent="."]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="SongLabel" type="RichTextLabel" parent="Columns"]
|
||||
unique_name_in_owner = true
|
||||
clip_contents = true
|
||||
custom_minimum_size = Vector2(0, 28)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_stretch_ratio = 3.0
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"SongStatsText"
|
||||
bbcode_enabled = true
|
||||
scroll_active = false
|
||||
autowrap_mode = 0
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="SoundtrackLabel" type="RichTextLabel" parent="Columns"]
|
||||
unique_name_in_owner = true
|
||||
clip_contents = true
|
||||
custom_minimum_size = Vector2(0, 28)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_stretch_ratio = 2.0
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"SongStatsSecondary"
|
||||
bbcode_enabled = true
|
||||
scroll_active = false
|
||||
autowrap_mode = 0
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="PlayedLabel" type="Label" parent="Columns"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(100, 0)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"TextMedium"
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 1
|
||||
@@ -1,6 +1,9 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://statistics/statistics_view.gd" id="view_script"]
|
||||
[ext_resource type="PackedScene" path="res://statistics/SongStatsList.tscn" id="song_list_scene"]
|
||||
|
||||
[sub_resource type="ButtonGroup" id="ButtonGroup_mode"]
|
||||
|
||||
[node name="StatisticsView" type="Panel" unique_id=1212000001]
|
||||
anchors_preset = 15
|
||||
@@ -23,6 +26,30 @@ offset_bottom = 56.0
|
||||
grow_horizontal = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="SoundtracksModeButton" type="Button" parent="Toolbar"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(120, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
theme_type_variation = &"TabButton"
|
||||
toggle_mode = true
|
||||
button_pressed = true
|
||||
button_group = SubResource("ButtonGroup_mode")
|
||||
text = "Soundtracks"
|
||||
|
||||
[node name="SongsModeButton" type="Button" parent="Toolbar"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(120, 0)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
theme_type_variation = &"TabButton"
|
||||
toggle_mode = true
|
||||
button_group = SubResource("ButtonGroup_mode")
|
||||
text = "Songs"
|
||||
|
||||
[node name="ModeSeparator" type="VSeparator" parent="Toolbar"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SearchField" type="LineEdit" parent="Toolbar" unique_id=1212000003]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(480, 0)
|
||||
@@ -100,6 +127,20 @@ size_flags_horizontal = 0
|
||||
theme_type_variation = &"StatisticsGrid"
|
||||
columns = 6
|
||||
|
||||
[node name="SongList" parent="." instance=ExtResource("song_list_scene")]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
offset_left = 32.0
|
||||
offset_top = 72.0
|
||||
offset_right = -32.0
|
||||
offset_bottom = -16.0
|
||||
|
||||
[node name="SearchDelay" type="Timer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
wait_time = 0.15
|
||||
one_shot = true
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="." unique_id=1212000012]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
class_name SongStatsList
|
||||
extends Control
|
||||
## A scrollable list of songs with their soundtrack and how often they were played, under
|
||||
## a header naming the columns. Shown in the statistics view instead of the soundtrack
|
||||
## cards when Songs is chosen.
|
||||
##
|
||||
## The server has tens of thousands of songs, so only the rows on screen exist. They are
|
||||
## reused for other songs as the list scrolls.
|
||||
|
||||
## Emitted when a song is clicked.
|
||||
signal song_pressed(song: SongInfoForStats)
|
||||
|
||||
const ROW_SCENE: PackedScene = preload("res://statistics/SongStatsRow.tscn")
|
||||
## How many rows one turn of the mouse wheel scrolls.
|
||||
const ROWS_PER_WHEEL_STEP: int = 3
|
||||
|
||||
## The songs in the list, in the order shown.
|
||||
var songs: Array[SongInfoForStats] = []
|
||||
|
||||
# The rows that fit on screen, top to bottom
|
||||
var _rows: Array[SongStatsRow] = []
|
||||
var _row_height: float = 0.0
|
||||
var _search_text: String = ""
|
||||
|
||||
@onready var rows_area: Control = %RowsArea
|
||||
@onready var scroll_bar: VScrollBar = %ScrollBar
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var measured: SongStatsRow = ROW_SCENE.instantiate()
|
||||
add_child(measured)
|
||||
_row_height = measured.get_combined_minimum_size().y
|
||||
measured.free()
|
||||
rows_area.resized.connect(_fit_rows)
|
||||
scroll_bar.value_changed.connect(func(_value: float) -> void: _show_rows())
|
||||
rows_area.gui_input.connect(_on_gui_input)
|
||||
_fit_rows()
|
||||
|
||||
|
||||
## Shows [param new_songs] from the top, with the letters [param search_text] matched
|
||||
## highlighted.
|
||||
func show_songs(new_songs: Array[SongInfoForStats], search_text: String = "") -> void:
|
||||
songs = new_songs
|
||||
_search_text = search_text
|
||||
_update_scroll_bar()
|
||||
scroll_bar.value = 0
|
||||
_show_rows()
|
||||
|
||||
|
||||
## The rows showing a song now, top to bottom.
|
||||
func shown_rows() -> Array[SongStatsRow]:
|
||||
var shown: Array[SongStatsRow] = []
|
||||
for row: SongStatsRow in _rows:
|
||||
if row.visible:
|
||||
shown.append(row)
|
||||
return shown
|
||||
|
||||
|
||||
## Scrolls so song number [param index] is at the top, as far as the list goes.
|
||||
func scroll_to(index: int) -> void:
|
||||
scroll_bar.value = index * _row_height
|
||||
|
||||
|
||||
# Makes as many rows as fit on screen, plus one for the row partly scrolled out
|
||||
func _fit_rows() -> void:
|
||||
var needed: int = ceili(rows_area.size.y / _row_height) + 1
|
||||
while _rows.size() < needed:
|
||||
var row: SongStatsRow = ROW_SCENE.instantiate()
|
||||
rows_area.add_child(row)
|
||||
row.pressed.connect(song_pressed.emit)
|
||||
_rows.append(row)
|
||||
while _rows.size() > needed:
|
||||
_rows.pop_back().free()
|
||||
for row: SongStatsRow in _rows:
|
||||
row.size = Vector2(rows_area.size.x, _row_height)
|
||||
_update_scroll_bar()
|
||||
_show_rows()
|
||||
|
||||
|
||||
func _update_scroll_bar() -> void:
|
||||
scroll_bar.max_value = songs.size() * _row_height
|
||||
scroll_bar.page = rows_area.size.y
|
||||
scroll_bar.step = 0
|
||||
scroll_bar.visible = scroll_bar.max_value > scroll_bar.page
|
||||
|
||||
|
||||
func _show_rows() -> void:
|
||||
var first: int = floori(scroll_bar.value / _row_height)
|
||||
var offset: float = scroll_bar.value - first * _row_height
|
||||
for i: int in _rows.size():
|
||||
var row: SongStatsRow = _rows[i]
|
||||
var index: int = first + i
|
||||
row.visible = index < songs.size()
|
||||
if row.visible:
|
||||
row.position = Vector2(0, i * _row_height - offset)
|
||||
row.show_song(songs[index], index, _search_text)
|
||||
|
||||
|
||||
func _on_gui_input(event: InputEvent) -> void:
|
||||
if !(event is InputEventMouseButton) || !event.pressed:
|
||||
return
|
||||
var step: float = _row_height * ROWS_PER_WHEEL_STEP
|
||||
match event.button_index:
|
||||
MOUSE_BUTTON_WHEEL_UP:
|
||||
scroll_bar.value -= step * event.factor if event.factor > 0 else step
|
||||
MOUSE_BUTTON_WHEEL_DOWN:
|
||||
scroll_bar.value += step * event.factor if event.factor > 0 else step
|
||||
_:
|
||||
return
|
||||
rows_area.accept_event()
|
||||
@@ -0,0 +1 @@
|
||||
uid://tfgi4wql3cwa
|
||||
@@ -0,0 +1,66 @@
|
||||
class_name SongStatsRow
|
||||
extends PanelContainer
|
||||
## One song in the statistics' list of songs: its name, its soundtrack and how often it
|
||||
## was played, with the letters a search matched highlighted. Clicking it asks for the
|
||||
## song's soundtrack.
|
||||
##
|
||||
## Rows are reused for other songs as the list scrolls, see [SongStatsList].
|
||||
|
||||
## Emitted when the row is clicked.
|
||||
signal pressed(song: SongInfoForStats)
|
||||
|
||||
## The song the row shows.
|
||||
var song: SongInfoForStats
|
||||
|
||||
# The place in the list of the song shown, for the alternating background
|
||||
var _index: int = 0
|
||||
var _hovered: bool = false
|
||||
|
||||
@onready var song_label: RichTextLabel = %SongLabel
|
||||
@onready var soundtrack_label: RichTextLabel = %SoundtrackLabel
|
||||
@onready var played_label: Label = %PlayedLabel
|
||||
|
||||
|
||||
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], song number [param index] in the list, with the letters
|
||||
## [param search_text] matched highlighted.
|
||||
func show_song(data: SongInfoForStats, index: int, search_text: String) -> void:
|
||||
song = data
|
||||
_index = index
|
||||
_update_style()
|
||||
var highlight: Color = get_theme_color(&"font_color", &"HighlightLarge")
|
||||
_set_text(song_label, SearchPopup.highlighted_name(data.song_name, search_text, highlight))
|
||||
_set_text(soundtrack_label,
|
||||
SearchPopup.highlighted_name(data.soundtrack_name, search_text, highlight))
|
||||
played_label.text = str(data.times_played)
|
||||
tooltip_text = "%s\n%s" % [data.song_name, data.soundtrack_name]
|
||||
|
||||
|
||||
# Setting the same text again would make the label lay it out again for nothing
|
||||
static func _set_text(label: RichTextLabel, text: String) -> void:
|
||||
if label.text != text:
|
||||
label.text = text
|
||||
|
||||
|
||||
func _set_hovered(hovered: bool) -> void:
|
||||
_hovered = hovered
|
||||
_update_style()
|
||||
|
||||
|
||||
func _update_style() -> void:
|
||||
if _hovered:
|
||||
theme_type_variation = &"ListRowHover"
|
||||
else:
|
||||
theme_type_variation = &"ListRowOdd" if _index % 2 != 0 else &"ListRowEven"
|
||||
|
||||
|
||||
func _on_gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton && event.pressed \
|
||||
&& event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
pressed.emit(song)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dibwxcgne3p8c
|
||||
@@ -1,7 +1,8 @@
|
||||
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.
|
||||
## Shows every soundtrack on the server as a card with its play statistics, or every
|
||||
## song in a list with its soundtrack. Either can be searched, filtered on whether it has
|
||||
## been played, and sorted.
|
||||
##
|
||||
## Shown instead of the quiz when the Statistics tab is chosen.
|
||||
|
||||
@@ -11,16 +12,27 @@ 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.
|
||||
## Which soundtracks or songs are shown, in the order of the show menu.
|
||||
enum ShowFilter { ALL, PLAYED, NEVER_PLAYED }
|
||||
|
||||
## Whether soundtracks or songs are shown.
|
||||
enum Mode { SOUNDTRACKS, SONGS }
|
||||
|
||||
const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn")
|
||||
|
||||
## The cards in the order they are shown, hidden ones included.
|
||||
var cards: Array[SoundtrackCard] = []
|
||||
## Whether soundtracks or songs are shown. Change it with [method set_mode].
|
||||
var mode: Mode = Mode.SOUNDTRACKS
|
||||
|
||||
var _search: SoundtrackSearch = SoundtrackSearch.new()
|
||||
var _loading: bool = false
|
||||
# The soundtracks last loaded, by name
|
||||
var _soundtracks: Dictionary[String, SoundtrackWithSongs] = {}
|
||||
# Every song sorted A to Z, made when songs are first shown after loading
|
||||
var _songs_a_to_z: Array[SongInfoForStats] = []
|
||||
var _song_search: SoundtrackSearch = SoundtrackSearch.new()
|
||||
var _songs_ready: bool = false
|
||||
|
||||
@onready var search_field: LineEdit = %SearchField
|
||||
@onready var clear_button: Button = %ClearButton
|
||||
@@ -30,10 +42,19 @@ var _loading: bool = false
|
||||
@onready var status_label: Label = %StatusLabel
|
||||
@onready var retry_button: Button = %RetryButton
|
||||
@onready var card_grid: GridContainer = %CardGrid
|
||||
@onready var card_scroll: ScrollContainer = $ScrollContainer
|
||||
@onready var song_list: SongStatsList = %SongList
|
||||
@onready var soundtracks_mode_button: Button = %SoundtracksModeButton
|
||||
@onready var songs_mode_button: Button = %SongsModeButton
|
||||
@onready var search_delay: Timer = %SearchDelay
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
search_field.text_changed.connect(func(_text: String) -> void: apply_filter())
|
||||
search_field.text_changed.connect(_on_search_text_changed)
|
||||
search_delay.timeout.connect(apply_filter)
|
||||
soundtracks_mode_button.pressed.connect(set_mode.bind(Mode.SOUNDTRACKS))
|
||||
songs_mode_button.pressed.connect(set_mode.bind(Mode.SONGS))
|
||||
song_list.song_pressed.connect(_on_song_pressed)
|
||||
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())
|
||||
@@ -53,14 +74,31 @@ func refresh() -> void:
|
||||
MusicServerClient.fetch_soundtrack_statistics(_on_loaded, _on_load_failed)
|
||||
|
||||
|
||||
## Shows every card again, keeping the show filter and the sort order.
|
||||
## Shows soundtracks or songs, keeping the search, the show filter and the sort order.
|
||||
func set_mode(new_mode: Mode) -> void:
|
||||
mode = new_mode
|
||||
soundtracks_mode_button.set_pressed_no_signal(mode == Mode.SOUNDTRACKS)
|
||||
songs_mode_button.set_pressed_no_signal(mode == Mode.SONGS)
|
||||
card_scroll.visible = mode == Mode.SOUNDTRACKS
|
||||
song_list.visible = mode == Mode.SONGS
|
||||
search_field.placeholder_text = "Search soundtracks" if mode == Mode.SOUNDTRACKS \
|
||||
else "Search songs and soundtracks"
|
||||
apply_sort()
|
||||
if mode == Mode.SOUNDTRACKS:
|
||||
apply_filter()
|
||||
|
||||
|
||||
## Shows everything 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.
|
||||
## Puts the cards or songs in the order chosen in the sort menu.
|
||||
func apply_sort() -> void:
|
||||
if mode == Mode.SONGS:
|
||||
apply_filter()
|
||||
return
|
||||
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))
|
||||
@@ -68,8 +106,12 @@ func apply_sort() -> void:
|
||||
card_grid.move_child(cards[i], i)
|
||||
|
||||
|
||||
## Shows only the cards that match the search text and the show menu.
|
||||
## Shows only the cards or songs that match the search text and the show menu.
|
||||
func apply_filter() -> void:
|
||||
search_delay.stop()
|
||||
if mode == Mode.SONGS:
|
||||
_show_songs()
|
||||
return
|
||||
var search_text: String = search_field.text.strip_edges()
|
||||
var matching: Dictionary = {}
|
||||
for soundtrack_name: String in _search.find_matches(search_text) \
|
||||
@@ -100,14 +142,116 @@ func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
|
||||
while cards.size() > soundtracks.size():
|
||||
cards.pop_back().free()
|
||||
var names: Array[String] = []
|
||||
_soundtracks.clear()
|
||||
for i: int in soundtracks.size():
|
||||
cards[i].show_soundtrack(soundtracks[i])
|
||||
names.append(soundtracks[i].soundtrack_name)
|
||||
_soundtracks[soundtracks[i].soundtrack_name] = soundtracks[i]
|
||||
_search.set_names(names)
|
||||
_songs_ready = false
|
||||
apply_sort()
|
||||
if mode == Mode.SOUNDTRACKS:
|
||||
apply_filter()
|
||||
|
||||
|
||||
# Typing in the song list waits for a pause, since searching every song takes a moment
|
||||
func _on_search_text_changed(_text: String) -> void:
|
||||
if mode == Mode.SONGS:
|
||||
search_delay.start()
|
||||
else:
|
||||
apply_filter()
|
||||
|
||||
|
||||
func _on_song_pressed(song: SongInfoForStats) -> void:
|
||||
if _soundtracks.has(song.soundtrack_name):
|
||||
soundtrack_selected.emit(_soundtracks[song.soundtrack_name])
|
||||
|
||||
|
||||
# Shows the songs matching the search text and the show menu, in the sort menu's order
|
||||
func _show_songs() -> void:
|
||||
_prepare_songs()
|
||||
var search_text: String = search_field.text.strip_edges()
|
||||
var filter: ShowFilter = show_option.selected as ShowFilter
|
||||
var song_names: Dictionary = {}
|
||||
var soundtrack_names: Dictionary = {}
|
||||
if !search_text.is_empty():
|
||||
for song_name: String in _song_search.find_matches(search_text):
|
||||
song_names[song_name] = true
|
||||
for soundtrack_name: String in _search.find_matches(search_text):
|
||||
soundtrack_names[soundtrack_name] = true
|
||||
var shown: Array[SongInfoForStats] = []
|
||||
for song: SongInfoForStats in songs_in_order(_songs_a_to_z,
|
||||
sort_option.selected as SortOrder):
|
||||
if (search_text.is_empty() || song_names.has(song.song_name)
|
||||
|| soundtrack_names.has(song.soundtrack_name)) \
|
||||
&& _song_passes_filter(song, filter):
|
||||
shown.append(song)
|
||||
song_list.show_songs(shown, search_text)
|
||||
if shown.size() == _songs_a_to_z.size():
|
||||
count_label.text = "%d songs" % shown.size()
|
||||
else:
|
||||
count_label.text = "%d of %d songs" % [shown.size(), _songs_a_to_z.size()]
|
||||
|
||||
|
||||
# Lists every song once after loading, sorted A to Z, which the other orders start from
|
||||
func _prepare_songs() -> void:
|
||||
if _songs_ready:
|
||||
return
|
||||
_songs_ready = true
|
||||
_songs_a_to_z.clear()
|
||||
var names: Dictionary = {}
|
||||
for soundtrack: SoundtrackWithSongs in _soundtracks.values():
|
||||
for song: SongInfoForStats in soundtrack.songs:
|
||||
# The song list finds a song's soundtrack by its name
|
||||
song.soundtrack_name = soundtrack.soundtrack_name
|
||||
_songs_a_to_z.append(song)
|
||||
names[song.song_name] = true
|
||||
_songs_a_to_z.sort_custom(func(a: SongInfoForStats, b: SongInfoForStats) -> bool:
|
||||
var by_name: int = a.song_name.naturalnocasecmp_to(b.song_name)
|
||||
if by_name != 0:
|
||||
return by_name < 0
|
||||
return a.soundtrack_name.naturalnocasecmp_to(b.soundtrack_name) < 0)
|
||||
_song_search.set_names(names.keys())
|
||||
|
||||
|
||||
## [param a_to_z], songs sorted A to Z, in [param order]. Songs played as often stay in
|
||||
## A to Z order.
|
||||
static func songs_in_order(
|
||||
a_to_z: Array[SongInfoForStats], order: SortOrder) -> Array[SongInfoForStats]:
|
||||
match order:
|
||||
SortOrder.Z_TO_A:
|
||||
var z_to_a: Array[SongInfoForStats] = a_to_z.duplicate()
|
||||
z_to_a.reverse()
|
||||
return z_to_a
|
||||
SortOrder.MOST_PLAYED, SortOrder.LEAST_PLAYED:
|
||||
# Grouped by plays instead of sorted, which keeps the A to Z order within a
|
||||
# group and is quick for tens of thousands of songs
|
||||
var by_plays: Dictionary[int, Array] = {}
|
||||
for song: SongInfoForStats in a_to_z:
|
||||
if !by_plays.has(song.times_played):
|
||||
by_plays[song.times_played] = []
|
||||
by_plays[song.times_played].append(song)
|
||||
var plays: Array[int] = []
|
||||
plays.assign(by_plays.keys())
|
||||
plays.sort()
|
||||
if order == SortOrder.MOST_PLAYED:
|
||||
plays.reverse()
|
||||
var sorted: Array[SongInfoForStats] = []
|
||||
for times_played: int in plays:
|
||||
sorted.append_array(by_plays[times_played])
|
||||
return sorted
|
||||
return a_to_z
|
||||
|
||||
|
||||
static func _song_passes_filter(song: SongInfoForStats, filter: ShowFilter) -> bool:
|
||||
match filter:
|
||||
ShowFilter.PLAYED:
|
||||
return song.times_played > 0
|
||||
ShowFilter.NEVER_PLAYED:
|
||||
return song.times_played == 0
|
||||
return true
|
||||
|
||||
|
||||
func _on_load_failed() -> void:
|
||||
_loading = false
|
||||
if cards.is_empty():
|
||||
|
||||
+9331
-1659
File diff suppressed because it is too large.
Load diff
@@ -267,6 +267,15 @@ func run(on_state: Callable) -> void:
|
||||
statistics_view.apply_filter()
|
||||
await _settle()
|
||||
await on_state.call("statistics_search", statistics_view)
|
||||
statistics_view.set_mode(StatisticsView.Mode.SONGS)
|
||||
await _settle()
|
||||
await on_state.call("statistics_songs_search", statistics_view)
|
||||
statistics_view.clear_search()
|
||||
await _settle()
|
||||
await on_state.call("statistics_songs", statistics_view)
|
||||
statistics_view.search_field.text = "chrono"
|
||||
statistics_view.set_mode(StatisticsView.Mode.SOUNDTRACKS)
|
||||
await _settle()
|
||||
var stats_popup: SoundtrackStatsPopup = _node("SoundtrackStatsPopup")
|
||||
for card: SoundtrackCard in statistics_view.cards:
|
||||
if card.visible:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
extends MainWindowTestSuite
|
||||
## The Statistics tab: switching between it and the quiz, the soundtrack cards, sorting,
|
||||
## searching, the show filter and the popup with all songs of a soundtrack.
|
||||
## The Statistics tab: switching between it and the quiz, the soundtrack cards, the list
|
||||
## of songs, sorting, searching, the show filter and the popup with all songs of a
|
||||
## soundtrack.
|
||||
|
||||
const STATISTICS: Array[Dictionary] = [
|
||||
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", "soundtrack_played": 5,
|
||||
@@ -318,6 +319,198 @@ func test_filters_stay_when_statistics_are_loaded_again() -> void:
|
||||
assert_str(_view.cards[0].soundtrack.soundtrack_name).is_equal("Terranigma")
|
||||
|
||||
|
||||
# Songs
|
||||
|
||||
func _show_songs_mode() -> void:
|
||||
await _open_statistics()
|
||||
await _click_at(_center(_view.songs_mode_button))
|
||||
await _runner.simulate_frames(1)
|
||||
|
||||
|
||||
func _listed_songs() -> Array[String]:
|
||||
var listed: Array[String] = []
|
||||
for song: SongInfoForStats in _view.song_list.songs:
|
||||
listed.append(song.song_name)
|
||||
return listed
|
||||
|
||||
|
||||
func _search(text: String) -> void:
|
||||
_view.search_field.text = text
|
||||
_view.apply_filter()
|
||||
|
||||
|
||||
func test_songs_button_shows_every_song_a_to_z_instead_of_the_cards() -> void:
|
||||
await _show_songs_mode()
|
||||
|
||||
assert_bool(_view.song_list.visible).is_true()
|
||||
assert_bool(_view.card_scroll.visible).is_false()
|
||||
assert_bool(_view.songs_mode_button.button_pressed).is_true()
|
||||
assert_bool(_view.soundtracks_mode_button.button_pressed).is_false()
|
||||
assert_array(_listed_songs()).contains_exactly([
|
||||
"Battle with Magus", "Corridors of Time", "Dancing Mad", "Frog's Theme",
|
||||
"Peaceful Days", "Schala's Theme", "To Far Away Times", "Underworld", "Wind Scene",
|
||||
"Zeal Palace",
|
||||
])
|
||||
assert_str(_view.count_label.text).is_equal("10 songs")
|
||||
assert_str(_view.search_field.placeholder_text).is_equal("Search songs and soundtracks")
|
||||
|
||||
|
||||
func test_song_rows_show_the_song_its_soundtrack_and_plays() -> void:
|
||||
await _show_songs_mode()
|
||||
|
||||
var row: SongStatsRow = _view.song_list.shown_rows()[0]
|
||||
assert_str(row.song_label.get_parsed_text()).is_equal("Battle with Magus")
|
||||
assert_str(row.soundtrack_label.get_parsed_text()).is_equal("Chrono Trigger")
|
||||
assert_str(row.played_label.text).is_equal("2")
|
||||
assert_int(_view.song_list.shown_rows().size()).is_equal(10)
|
||||
|
||||
|
||||
func test_soundtracks_button_shows_the_cards_again() -> void:
|
||||
await _show_songs_mode()
|
||||
|
||||
await _click_at(_center(_view.soundtracks_mode_button))
|
||||
|
||||
assert_bool(_view.card_scroll.visible).is_true()
|
||||
assert_bool(_view.song_list.visible).is_false()
|
||||
assert_str(_view.count_label.text).is_equal("4 soundtracks")
|
||||
|
||||
|
||||
func test_song_search_finds_song_names_and_soundtrack_names() -> void:
|
||||
await _show_songs_mode()
|
||||
|
||||
_search("magus")
|
||||
assert_array(_listed_songs()).contains_exactly(["Battle with Magus"])
|
||||
assert_str(_view.count_label.text).is_equal("1 of 10 songs")
|
||||
|
||||
# Every song of a soundtrack whose name matches
|
||||
_search("final fantasy")
|
||||
assert_array(_listed_songs()).contains_exactly(["Dancing Mad"])
|
||||
|
||||
|
||||
func test_song_search_highlights_what_matched() -> void:
|
||||
await _show_songs_mode()
|
||||
|
||||
_search("magus")
|
||||
|
||||
var row: SongStatsRow = _view.song_list.shown_rows()[0]
|
||||
assert_str(row.song_label.text).contains("[color=")
|
||||
assert_str(row.song_label.get_parsed_text()).is_equal("Battle with Magus")
|
||||
assert_str(row.soundtrack_label.text).not_contains("[color=")
|
||||
|
||||
|
||||
func test_typing_a_song_search_waits_for_a_pause() -> void:
|
||||
await _show_songs_mode()
|
||||
|
||||
_view.search_field.text = "magus"
|
||||
_view.search_field.text_changed.emit("magus")
|
||||
assert_int(_view.song_list.songs.size()).is_equal(10)
|
||||
|
||||
await await_millis(300)
|
||||
assert_array(_listed_songs()).contains_exactly(["Battle with Magus"])
|
||||
|
||||
|
||||
func test_show_filter_for_songs() -> void:
|
||||
await _show_songs_mode()
|
||||
|
||||
_view.show_option.select(StatisticsView.ShowFilter.NEVER_PLAYED)
|
||||
_view.show_option.item_selected.emit(StatisticsView.ShowFilter.NEVER_PLAYED)
|
||||
assert_array(_listed_songs()).contains_exactly(
|
||||
["To Far Away Times", "Underworld", "Wind Scene"])
|
||||
|
||||
_view.show_option.select(StatisticsView.ShowFilter.PLAYED)
|
||||
_view.show_option.item_selected.emit(StatisticsView.ShowFilter.PLAYED)
|
||||
assert_int(_listed_songs().size()).is_equal(7)
|
||||
assert_str(_view.count_label.text).is_equal("7 of 10 songs")
|
||||
|
||||
|
||||
func test_song_sort_orders() -> void:
|
||||
await _show_songs_mode()
|
||||
var expected: Dictionary = {
|
||||
StatisticsView.SortOrder.MOST_PLAYED: ["Schala's Theme", "Frog's Theme",
|
||||
"Battle with Magus", "Corridors of Time", "Dancing Mad", "Peaceful Days",
|
||||
"Zeal Palace", "To Far Away Times", "Underworld", "Wind Scene"],
|
||||
StatisticsView.SortOrder.LEAST_PLAYED: ["To Far Away Times", "Underworld",
|
||||
"Wind Scene", "Corridors of Time", "Dancing Mad", "Peaceful Days",
|
||||
"Zeal Palace", "Battle with Magus", "Frog's Theme", "Schala's Theme"],
|
||||
StatisticsView.SortOrder.Z_TO_A: ["Zeal Palace", "Wind Scene", "Underworld",
|
||||
"To Far Away Times", "Schala's Theme", "Peaceful Days", "Frog's Theme",
|
||||
"Dancing Mad", "Corridors of Time", "Battle with Magus"],
|
||||
}
|
||||
for order: StatisticsView.SortOrder in expected:
|
||||
_view.sort_option.select(order)
|
||||
_view.sort_option.item_selected.emit(order)
|
||||
|
||||
assert_array(_listed_songs()).contains_exactly(expected[order])
|
||||
|
||||
|
||||
func test_songs_with_the_same_name_are_sorted_by_soundtrack() -> void:
|
||||
_server.respond_json("/statistics/soundtracks", [
|
||||
{"soundtrack_name": "Lufia II", "songs": [{"song_name": "Main Theme"}]},
|
||||
{"soundtrack_name": "Earthbound", "songs": [{"song_name": "Main Theme"}]},
|
||||
])
|
||||
await _show_songs_mode()
|
||||
|
||||
var soundtracks: Array[String] = []
|
||||
for song: SongInfoForStats in _view.song_list.songs:
|
||||
soundtracks.append(song.soundtrack_name)
|
||||
assert_array(soundtracks).contains_exactly(["Earthbound", "Lufia II"])
|
||||
|
||||
|
||||
func test_clicking_a_song_shows_its_soundtrack() -> void:
|
||||
await _show_songs_mode()
|
||||
var popup: SoundtrackStatsPopup = _window.get_node("SoundtrackStatsPopup")
|
||||
_search("dancing")
|
||||
|
||||
await _click_at(_center(_view.song_list.shown_rows()[0]))
|
||||
|
||||
assert_bool(popup.visible).is_true()
|
||||
assert_str(popup.title_label.text).is_equal("Final Fantasy VI")
|
||||
|
||||
|
||||
func test_songs_stay_shown_when_statistics_are_loaded_again() -> void:
|
||||
await _show_songs_mode()
|
||||
_server.respond_json("/statistics/soundtracks", STATISTICS.slice(1, 2))
|
||||
|
||||
_view.refresh()
|
||||
|
||||
assert_bool(_view.song_list.visible).is_true()
|
||||
assert_array(_listed_songs()).contains_exactly(["Underworld"])
|
||||
|
||||
|
||||
func test_only_the_rows_on_screen_are_made_for_many_songs() -> void:
|
||||
var songs: Array[Dictionary] = []
|
||||
for i: int in 5000:
|
||||
songs.append({"song_name": "Song %04d" % i, "times_played": i % 7})
|
||||
_server.respond_json("/statistics/soundtracks",
|
||||
[{"soundtrack_name": "Big", "songs": songs}])
|
||||
await _show_songs_mode()
|
||||
|
||||
var rows: int = _view.song_list.rows_area.get_child_count()
|
||||
assert_int(rows).is_less(60)
|
||||
assert_str(_view.count_label.text).is_equal("5000 songs")
|
||||
|
||||
_view.song_list.scroll_to(2500)
|
||||
await _runner.simulate_frames(1)
|
||||
assert_str(_view.song_list.shown_rows()[0].song.song_name).is_equal("Song 2500")
|
||||
assert_int(_view.song_list.rows_area.get_child_count()).is_equal(rows)
|
||||
|
||||
|
||||
func test_mouse_wheel_scrolls_the_songs() -> void:
|
||||
var songs: Array[Dictionary] = []
|
||||
for i: int in 200:
|
||||
songs.append({"song_name": "Song %03d" % i})
|
||||
_server.respond_json("/statistics/soundtracks",
|
||||
[{"soundtrack_name": "Big", "songs": songs}])
|
||||
await _show_songs_mode()
|
||||
var transform: Transform2D = _window.get_viewport().get_final_transform()
|
||||
|
||||
_runner.set_mouse_position(transform * _center(_view.song_list.rows_area))
|
||||
await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_WHEEL_DOWN)
|
||||
await _runner.await_input_processed()
|
||||
|
||||
assert_str(_view.song_list.shown_rows()[0].song.song_name).is_equal("Song 003")
|
||||
|
||||
|
||||
# Popup
|
||||
|
||||
func test_clicking_a_card_shows_all_its_songs_most_played_first() -> void:
|
||||
|
||||
@@ -39,6 +39,10 @@ const STYLES: Dictionary = {
|
||||
"LargeButton": "Button",
|
||||
"SearchField": "LineEdit",
|
||||
"SearchResult": "RichTextLabel",
|
||||
"ListRowHover": "PanelContainer",
|
||||
"SongStatsHeader": "PanelContainer",
|
||||
"SongStatsText": "RichTextLabel",
|
||||
"SongStatsSecondary": "RichTextLabel",
|
||||
}
|
||||
|
||||
## Theme types that only scripts read from, so they are not styles a node can pick.
|
||||
|
||||
+4
-1
@@ -37,7 +37,8 @@ inspector or `theme_type_variation` in code:
|
||||
| `MusicList` | PanelContainer | The list of songs in the quiz |
|
||||
| `SongRow` | PanelContainer | One song in that list |
|
||||
| `ShortcutRowDark` | PanelContainer | Every other row in the shortcuts popup |
|
||||
| `ListRowEven`, `ListRowOdd` | PanelContainer | Alternating rows in the inspiration and sync lists |
|
||||
| `ListRowEven`, `ListRowOdd` | PanelContainer | Alternating rows in the inspiration, sync and statistics song lists |
|
||||
| `ListRowHover` | PanelContainer | A row in the statistics song list under the mouse |
|
||||
| `TitleLarge` | Label | The soundtrack that is playing (50) |
|
||||
| `TitleMedium` | Label | The song that is playing, and the winner (35) |
|
||||
| `Title` | Label | The window title and popup titles (25) |
|
||||
@@ -48,6 +49,8 @@ inspector or `theme_type_variation` in code:
|
||||
| `TabButton` | Button | The Quiz and Statistics tabs; the chosen one is orange |
|
||||
| `StatisticsCard`, `StatisticsCardHover` | PanelContainer | A soundtrack card in the statistics, and the one under the mouse |
|
||||
| `StatisticsGrid` | GridContainer | The grid of soundtrack cards, with the space between them |
|
||||
| `SongStatsHeader` | PanelContainer | The column titles above the statistics song list, spaced like its rows |
|
||||
| `SongStatsText`, `SongStatsSecondary` | RichTextLabel | A song's name and its soundtrack (dimmer) in the statistics song list |
|
||||
|
||||
**Palette**: colours used from code.
|
||||
|
||||
|
||||
@@ -282,6 +282,23 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_listrowhover"]
|
||||
content_margin_left = 8.0
|
||||
content_margin_top = 2.0
|
||||
content_margin_right = 8.0
|
||||
content_margin_bottom = 2.0
|
||||
bg_color = Color(1, 1, 1, 0.2)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_songstatsheader"]
|
||||
content_margin_left = 8.0
|
||||
content_margin_top = 2.0
|
||||
content_margin_right = 8.0
|
||||
content_margin_bottom = 2.0
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_mxy8k"]
|
||||
content_margin_left = 8.0
|
||||
content_margin_top = 2.0
|
||||
@@ -844,6 +861,8 @@ LargeButton/base_type = &"Button"
|
||||
LargeButton/font_sizes/font_size = 24
|
||||
ListRowEven/base_type = &"PanelContainer"
|
||||
ListRowEven/styles/panel = SubResource("StyleBoxFlat_u5sc5")
|
||||
ListRowHover/base_type = &"PanelContainer"
|
||||
ListRowHover/styles/panel = SubResource("StyleBoxFlat_listrowhover")
|
||||
ListRowOdd/base_type = &"PanelContainer"
|
||||
ListRowOdd/styles/panel = SubResource("StyleBoxFlat_mxy8k")
|
||||
MarginContainer/constants/margin_bottom = 0
|
||||
@@ -943,6 +962,13 @@ SongRowLabelPlaying/base_type = &"Label"
|
||||
SongRowLabelPlaying/colors/font_color = Color(1, 0.5, 0, 1)
|
||||
SongRowLabelPlaying/font_sizes/font_size = 20
|
||||
SongRowLabelPlaying/styles/normal = SubResource("StyleBoxFlat_lkqs8")
|
||||
SongStatsHeader/base_type = &"PanelContainer"
|
||||
SongStatsHeader/styles/panel = SubResource("StyleBoxEmpty_songstatsheader")
|
||||
SongStatsSecondary/base_type = &"RichTextLabel"
|
||||
SongStatsSecondary/colors/default_color = Color(0.7, 0.7, 0.7, 1)
|
||||
SongStatsSecondary/font_sizes/normal_font_size = 18
|
||||
SongStatsText/base_type = &"RichTextLabel"
|
||||
SongStatsText/font_sizes/normal_font_size = 18
|
||||
StatisticsCard/base_type = &"PanelContainer"
|
||||
StatisticsCard/styles/panel = SubResource("StyleBoxFlat_statscard")
|
||||
StatisticsCardHover/base_type = &"PanelContainer"
|
||||
|
||||
Reference in new issue
Block a user