Songs never played are left out of the song list, so the Show menu is hidden while it is shown.
280 lines
10 KiB
GDScript
280 lines
10 KiB
GDScript
class_name StatisticsView
|
|
extends Panel
|
|
## Shows every soundtrack on the server as a card with its play statistics, or every
|
|
## played song in a list with its soundtrack. Both can be searched and sorted, and the
|
|
## cards filtered on whether the soundtrack has been played.
|
|
##
|
|
## 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 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
|
|
@onready var show_label: Label = $Toolbar/ShowLabel
|
|
@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
|
|
@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(_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())
|
|
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 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
|
|
# The song list only has played songs, so there is nothing to filter
|
|
show_label.visible = mode == Mode.SOUNDTRACKS
|
|
show_option.visible = mode == Mode.SOUNDTRACKS
|
|
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 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))
|
|
for i: int in cards.size():
|
|
card_grid.move_child(cards[i], i)
|
|
|
|
|
|
## 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) \
|
|
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] = []
|
|
_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, in the sort menu's order
|
|
func _show_songs() -> void:
|
|
_prepare_songs()
|
|
var search_text: String = search_field.text.strip_edges()
|
|
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):
|
|
shown.append(song)
|
|
song_list.show_songs(shown, search_text)
|
|
if shown.size() == _songs_a_to_z.size():
|
|
count_label.text = "%d played songs" % shown.size()
|
|
else:
|
|
count_label.text = "%d of %d played songs" % [shown.size(), _songs_a_to_z.size()]
|
|
|
|
|
|
# Lists every played song once after loading, sorted A to Z, which the other orders
|
|
# start from. Songs never played are left out: there are many and they say nothing.
|
|
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:
|
|
if song.times_played == 0:
|
|
continue
|
|
# 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
|
|
|
|
|
|
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
|