class_name StatisticsView extends Panel ## 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. ## 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_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 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 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(): 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