Files
MusicPlayer/statistics/statistics_view.gd
SansanandClaude Opus 5.5 06421bb63e
Build / test (push) Successful in 1m50s
Build / build (push) Successful in 1m14s
Change soundtrack covers from the app and show the cover with the answer
Cover menu in the soundtrack popup ("Change cover"):
- Choose picture uploads a JPEG, PNG or WebP picture (up to 10 MB).
- Pick by ID uses a game's cover on SteamGridDB or an anime's on AniList.
- Remove cover deletes it, so the server searches for a new one.
- The server's reason is shown when it refuses a change.
After a change the new cover time is fetched, and the popup, the
statistics card and the quiz show the new cover.

Quiz: once the answer is shown, the soundtrack's cover shows to the left of
the titles. Cover and titles are centered together, left and right and up
and down, and each title keeps to two lines. Cover times come from the
soundtrack list, and songs keep their soundtrack's UUID, also in saves.

Requests can now send any body with its content type, and every 2xx answer
counts as success. Cover frames are 2:3 like the server's covers. Small
copies of real covers are used by the tests and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:32:09 +02:00

340 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()
## Shows the new cover of the soundtrack [param uuid], with the cover time
## [param modified], or its initials when [param modified] is 0.
func update_cover(uuid: String, modified: int) -> void:
for card: SoundtrackCard in cards:
if card.soundtrack.soundtrack_uuid == uuid:
card.set_cover_time(modified)
load_covers_on_screen()
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