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. ## ## Covers are loaded for the cards on screen as they scroll into view, a few at a time, ## not for every soundtrack when the tab opens. See [CoverCache]. ## 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") ## How far outside the visible part cards still get their covers, so they are ready when ## scrolled to. const COVER_MARGIN: float = 400.0 ## 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 ## The covers on disk, shared by the cards. var covers: CoverCache = CoverCache.new() 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 # The cards have moved by the end of the frame card_scroll.get_v_scroll_bar().value_changed.connect( func(_value: float) -> void: load_covers_on_screen.call_deferred()) card_scroll.resized.connect(load_covers_on_screen) visibility_changed.connect(load_covers_on_screen) ## 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) load_covers_on_screen.call_deferred() ## 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: var was_visible: bool = card.visible card.visible = (search_text.is_empty() || matching.has(card.soundtrack.soundtrack_name)) \ && _passes_filter(card.soundtrack, filter) if card.visible: shown += 1 # Cards that come back with the filter fade in if !was_visible: Anim.fade_in(card) if shown == cards.size(): count_label.text = "%d soundtracks" % cards.size() else: count_label.text = "%d of %d soundtracks" % [shown, cards.size()] # The grid has moved the cards by the end of the frame load_covers_on_screen.call_deferred() 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() _soundtracks.clear() var cover_times: Dictionary = {} for i: int in soundtracks.size(): cards[i].show_soundtrack(soundtracks[i]) _soundtracks[soundtracks[i].soundtrack_name] = soundtracks[i] cover_times[soundtracks[i].soundtrack_uuid] = soundtracks[i].cover_modified covers.keep_only(cover_times) _update_search_names() _songs_ready = false apply_sort() if mode == Mode.SOUNDTRACKS: apply_filter() ## Asks for the covers of the cards that are on screen, or nearly. func load_covers_on_screen() -> void: if !is_visible_in_tree() || mode != Mode.SOUNDTRACKS: return var view: Rect2 = card_scroll.get_global_rect().grow(COVER_MARGIN) for card: SoundtrackCard in cards: if card.visible && view.intersects(card.get_global_rect()): card.load_cover(covers) ## Shows the new alternative names of the soundtrack [param updated] and finds it by ## them, without loading the statistics again. func update_alternative_names(updated: Soundtrack) -> void: for soundtrack: SoundtrackWithSongs in _soundtracks.values(): if soundtrack.soundtrack_uuid == updated.uuid: soundtrack.alternative_names = updated.alternative_names _update_search_names() apply_filter() func _update_search_names() -> void: var names: Array[String] = [] var alternatives: Dictionary = {} for soundtrack: SoundtrackWithSongs in _soundtracks.values(): names.append(soundtrack.soundtrack_name) alternatives[soundtrack.soundtrack_name] = soundtrack.alternative_names _search.set_names(names, alternatives) # 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