Files
MusicPlayer/statistics/statistics_view.gd
SansanandClaude Opus 5.5 c86dacb890
Build / test (pull_request) Successful in 2m3s
Build / build (pull_request) Skipped
Add a soundtrack by picking or dropping a folder (#50)
"Add soundtrack…" in the statistics toolbar, or a folder dropped anywhere on
the window, opens a popup that lists the folder's songs and uploads them to
the server, which adds the soundtrack at once (Sansan/MusicServer#33).

- The name comes from the folder and can be changed; characters the server
  refuses are replaced.
- Only MP3s are uploaded, as the server takes no other; other song files
  are listed greyed out as left out. Hidden files are skipped.
- Disc folders are flattened with the disc's number first ("1-01 Title.mp3"),
  as the server wants one folder. With one disc folder, names stay as
  they are.
- Songs are sent one at a time with a progress bar, reading one file at a
  time. A failed song is sent again twice, then the upload is cancelled
  and the server's reason shown. Closing the popup cancels it too.
- Not offered while the server syncs, offline, or without MP3s.
- Afterwards search, the inspiration list and the statistics reload.

The API types come from the server's spec (Upload, UploadFile,
UploadRequest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:43:59 +02:00

344 lines
13 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)
## Emitted when Add soundtrack… is pressed.
signal add_soundtrack_pressed
## 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
@onready var add_soundtrack_button: Button = %AddSoundtrackButton
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)
add_soundtrack_button.pressed.connect(add_soundtrack_pressed.emit)
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