Files
MusicPlayer/statistics/soundtrack_card.gd
T
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

182 lines
6.9 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)
## Takes the soundtrack's new cover time [param modified], 0 when its cover was removed,
## and shows the initials until [method load_cover] fetches the new cover.
func set_cover_time(modified: int) -> void:
soundtrack.cover_modified = modified
show_cover(null)
_cover_asked = 0
## 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)