Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.
Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.
The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.
Closes #49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
331 lines
12 KiB
GDScript
331 lines
12 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.
|
|
##
|
|
## 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
|