Files
MusicPlayer/statistics/soundtrack_card.gd
T
SansanandClaude Opus 5.5 ef6ac8f1fd
Build / test (push) Successful in 1m43s
Build / build (push) Successful in 1m14s
Show soundtrack covers and edit alternative names (#49)
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>
2026-10-14 22:06:30 +02:00

174 lines
6.7 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)