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>
174 lines
6.7 KiB
GDScript
174 lines
6.7 KiB
GDScript
class_name SoundtrackCard
|
||
extends PanelContainer
|
||
## One soundtrack in the statistics view: its name, how often and when it was last
|
||
## played, and its most and least played songs. Songs never played are not listed, and
|
||
## the least played are only shown when there are enough played songs to not repeat
|
||
## any. Clicking it asks for all its songs.
|
||
##
|
||
## Its cover shows on the left once [method load_cover] has fetched it; until then, and
|
||
## when the soundtrack has no cover, the soundtrack's initials stand in for it.
|
||
|
||
## Emitted when the card is clicked.
|
||
signal pressed(soundtrack: SoundtrackWithSongs)
|
||
|
||
## How many of the most and least played songs the card lists. With fewer than twice
|
||
## as many played songs, all of them are listed as most played.
|
||
const SONGS_SHOWN: int = 3
|
||
|
||
## The soundtrack the card shows.
|
||
var soundtrack: SoundtrackWithSongs
|
||
|
||
# The cover time asked for, so a cover is only asked for again when it changed
|
||
var _cover_asked: int = 0
|
||
|
||
@onready var cover_picture: TextureRect = %CoverPicture
|
||
@onready var cover_placeholder: Label = %CoverPlaceholder
|
||
@onready var name_label: Label = %NameLabel
|
||
@onready var played_label: Label = %PlayedLabel
|
||
@onready var last_played_label: Label = %LastPlayedLabel
|
||
@onready var most_played_header: Label = %MostPlayedHeader
|
||
@onready var most_played_list: VBoxContainer = %MostPlayedList
|
||
@onready var least_played_header: Label = %LeastPlayedHeader
|
||
@onready var least_played_list: VBoxContainer = %LeastPlayedList
|
||
|
||
|
||
func _ready() -> void:
|
||
mouse_entered.connect(_set_hovered.bind(true))
|
||
mouse_exited.connect(_set_hovered.bind(false))
|
||
gui_input.connect(_on_gui_input)
|
||
|
||
|
||
## Shows [param data] on the card.
|
||
func show_soundtrack(data: SoundtrackWithSongs) -> void:
|
||
var cover_changed: bool = soundtrack == null \
|
||
|| soundtrack.soundtrack_uuid != data.soundtrack_uuid \
|
||
|| soundtrack.cover_modified != data.cover_modified
|
||
soundtrack = data
|
||
if cover_changed:
|
||
show_cover(null)
|
||
_cover_asked = 0
|
||
cover_placeholder.text = initials(data.soundtrack_name)
|
||
name_label.text = data.soundtrack_name
|
||
tooltip_text = data.soundtrack_name
|
||
played_label.text = played_text(data.soundtrack_played)
|
||
last_played_label.text = last_played_text(data.soundtrack_last_played)
|
||
last_played_label.visible = !data.soundtrack_last_played.is_empty()
|
||
|
||
var most_played: Array[SongInfoForStats] = []
|
||
var least_played: Array[SongInfoForStats] = []
|
||
var played: Array[SongInfoForStats] = played_songs(data.songs)
|
||
if played.size() >= SONGS_SHOWN * 2:
|
||
most_played = played.slice(0, SONGS_SHOWN)
|
||
least_played = songs_by_plays(played, true).slice(0, SONGS_SHOWN)
|
||
else:
|
||
# Too few to split without listing a song twice, so all of them are most played
|
||
most_played = played
|
||
|
||
most_played_header.text = song_list_header(data.songs.size(), played.size())
|
||
_fill_song_list(most_played_list, most_played)
|
||
least_played_header.visible = !least_played.is_empty()
|
||
_fill_song_list(least_played_list, least_played)
|
||
|
||
|
||
## Asks [param covers] for the soundtrack's cover, unless it already has, and shows it
|
||
## when it comes. Soundtracks without a cover keep their initials.
|
||
func load_cover(covers: CoverCache) -> void:
|
||
if soundtrack == null || soundtrack.cover_modified <= 0 \
|
||
|| _cover_asked == soundtrack.cover_modified:
|
||
return
|
||
_cover_asked = soundtrack.cover_modified
|
||
covers.fetch_cover(soundtrack.soundtrack_uuid, soundtrack.cover_modified, show_cover)
|
||
|
||
|
||
## Shows [param cover], or the initials when it is null.
|
||
func show_cover(cover: Image) -> void:
|
||
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
|
||
cover_placeholder.visible = cover == null
|
||
|
||
|
||
## Up to two initials of [param soundtrack_name], e.g. "FF" for "Final Fantasy VI".
|
||
static func initials(soundtrack_name: String) -> String:
|
||
var letters: String = ""
|
||
for word: String in soundtrack_name.split(" ", false):
|
||
var first: String = word.left(1)
|
||
if first.to_upper() != first.to_lower():
|
||
letters += first.to_upper()
|
||
if letters.length() == 2:
|
||
break
|
||
return letters if !letters.is_empty() else soundtrack_name.left(1)
|
||
|
||
|
||
## "Played 3 times", or "Never played" for [code]0[/code].
|
||
static func played_text(times_played: int) -> String:
|
||
if times_played == 0:
|
||
return "Never played"
|
||
return "Played %d %s" % [times_played, "time" if times_played == 1 else "times"]
|
||
|
||
|
||
## "Last played 2026-10-01" from the server's date and time, or an empty String when
|
||
## there is none.
|
||
static func last_played_text(last_played: String) -> String:
|
||
if last_played.is_empty():
|
||
return ""
|
||
return "Last played " + last_played.substr(0, 10)
|
||
|
||
|
||
## The songs of [param songs] that have been played, most played first. Songs never
|
||
## played are left out: the card only lists them in the popup with every song.
|
||
static func played_songs(songs: Array[SongInfoForStats]) -> Array[SongInfoForStats]:
|
||
var played: Array[SongInfoForStats] = []
|
||
played.assign(songs_by_plays(songs).filter(
|
||
func(song: SongInfoForStats) -> bool: return song.times_played > 0))
|
||
return played
|
||
|
||
|
||
## The header above the most played songs, for a soundtrack with [param song_count]
|
||
## songs of which [param played_count] have been played.
|
||
static func song_list_header(song_count: int, played_count: int) -> String:
|
||
if song_count == 0:
|
||
return "No songs"
|
||
if played_count == 0:
|
||
return "No songs played yet"
|
||
return "Most played"
|
||
|
||
|
||
## One line of a song list: how often the song was played, then its name.
|
||
static func song_text(song: SongInfoForStats) -> String:
|
||
return "%d × %s" % [song.times_played, song.song_name]
|
||
|
||
|
||
## [param songs] sorted with the most played first, or the least played first with
|
||
## [param least_first], and by name when played as often.
|
||
static func songs_by_plays(
|
||
songs: Array[SongInfoForStats], least_first: bool = false) -> Array[SongInfoForStats]:
|
||
var sorted: Array[SongInfoForStats] = songs.duplicate()
|
||
sorted.sort_custom(func(a: SongInfoForStats, b: SongInfoForStats) -> bool:
|
||
if a.times_played != b.times_played:
|
||
return (a.times_played < b.times_played) == least_first
|
||
return a.song_name.naturalnocasecmp_to(b.song_name) < 0
|
||
)
|
||
return sorted
|
||
|
||
|
||
func _fill_song_list(list: VBoxContainer, songs: Array[SongInfoForStats]) -> void:
|
||
while list.get_child_count() < songs.size():
|
||
var label: Label = Label.new()
|
||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
list.add_child(label)
|
||
for i: int in list.get_child_count():
|
||
var label: Label = list.get_child(i)
|
||
label.visible = i < songs.size()
|
||
if label.visible:
|
||
label.text = song_text(songs[i])
|
||
|
||
|
||
func _set_hovered(hovered: bool) -> void:
|
||
theme_type_variation = &"StatisticsCardHover" if hovered else &"StatisticsCard"
|
||
|
||
|
||
func _on_gui_input(event: InputEvent) -> void:
|
||
if event is InputEventMouseButton && event.pressed \
|
||
&& event.button_index == MOUSE_BUTTON_LEFT:
|
||
accept_event()
|
||
pressed.emit(soundtrack)
|