6 Commits
Author SHA1 Message Date
Sansan 4a934840f6 Restyle the music player bar
Build / test (push) Successful in 1m11s
Build / build (push) Successful in 1m20s
The bar is rounded and a little taller, with a round orange play button
and a restart button that lights up orange under the mouse. The icons sit
inside their buttons with room around them, and are one colour, cropped to
their shape. Progress fills in orange, the volume slider gets a speaker
icon, and the buttons say their shortcut keys in their tooltips.

Buttons grow a little under the mouse and shrink while held down, and the
play button pops when it switches between play and pause. ButtonBounce
holds these animations so other buttons can use them too.
2026-10-09 17:38:38 +02:00
Sansan 78e1facaf0 Lay out the song list's pictures and text to fit any number of players
The players' pictures sit on the right in even lines of at most four, each
ending at the row's right edge, so five pictures are 3 + 2 and seven 4 + 3.
The text takes the room they leave and wraps only when it does not fit, and
the row is as tall as the taller of the two, instead of a width fixed by
the number of pictures.

Rows are rounded and alternate like the other lists, are highlighted under
the mouse, and the playing song has an orange edge. Clicking a picture
clicks its song.
2026-10-09 17:25:38 +02:00
Sansan 84e137e16a Only list played songs in the statistics song list
Songs never played are left out of the song list, so the Show menu is
hidden while it is shown.
2026-10-09 17:10:21 +02:00
Sansan a4e6da4c28 Show every song in the statistics, as an alternative to the cards
A Soundtracks / Songs switch in the statistics toolbar shows a list of
every song with its soundtrack and how often it was played. It is searched
on both song and soundtrack names, filtered and sorted like the cards, and
clicking a song shows its soundtrack's popup.

The server has tens of thousands of songs, so the list only makes the rows
on screen and reuses them while scrolling, songs are sorted once per load,
and searching waits for a pause in typing.

Closes #47
2026-10-09 17:02:27 +02:00
Sansan cd4331881c Don't list unplayed songs on the statistics cards
Cards only list songs that have been played. With fewer than six played
songs they are all listed as most played, so no song is listed twice. The
soundtrack popup still lists every song.

Closes #46
2026-10-09 16:55:39 +02:00
Sansan 06f457b027 Add a button in Settings that opens the config folder
Shows the folder with config.ini, the saved settings and the cached
characters and songs in the system's file manager.

Closes #45
2026-10-09 16:54:45 +02:00
43 changed files with 11966 additions and 2292 deletions

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
class_name ButtonBounce
extends RefCounted
## Small animations that make buttons feel alive: a button grows a little under the mouse,
## shrinks while it is held down, and can pop when its icon changes.
##
## The button is scaled around its middle, so its place in a container does not change.
## How big a button is under the mouse.
const HOVER_SCALE: Vector2 = Vector2(1.08, 1.08)
## How big a button is while it is held down.
const PRESSED_SCALE: Vector2 = Vector2(0.92, 0.92)
## How small a button starts when it pops.
const POP_SCALE: Vector2 = Vector2(0.8, 0.8)
## How long growing or shrinking takes, in seconds.
const DURATION: float = 0.08
## How long a pop takes, in seconds.
const POP_DURATION: float = 0.18
const _TWEEN_META: StringName = &"button_bounce_tween"
const _PIVOT_META: StringName = &"button_bounce_pivot"
## Makes [param button] grow under the mouse and shrink while held down.
static func add_to(button: BaseButton) -> void:
_keep_pivot_centred(button)
button.mouse_entered.connect(func() -> void:
if !button.disabled:
_scale_to(button, HOVER_SCALE))
button.mouse_exited.connect(func() -> void: _scale_to(button, Vector2.ONE))
button.button_down.connect(func() -> void: _scale_to(button, PRESSED_SCALE))
button.button_up.connect(func() -> void: _scale_to(button, resting_scale(button)))
## The size [param button] goes back to when it is not held down: bigger under the mouse.
static func resting_scale(button: BaseButton) -> Vector2:
return HOVER_SCALE if button.is_hovered() && !button.disabled else Vector2.ONE
## Shrinks [param button] and lets it spring back, e.g. when its icon changes.
static func pop(button: BaseButton) -> void:
_keep_pivot_centred(button)
button.scale = POP_SCALE
var tween: Tween = _new_tween(button)
tween.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tween.tween_property(button, ^"scale", resting_scale(button), POP_DURATION)
static func _scale_to(button: BaseButton, target: Vector2) -> void:
var tween: Tween = _new_tween(button)
tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.tween_property(button, ^"scale", target, DURATION)
# A new tween for [param button], stopping the one before so they never fight
static func _new_tween(button: BaseButton) -> Tween:
if button.has_meta(_TWEEN_META):
var running: Tween = button.get_meta(_TWEEN_META)
if running.is_valid():
running.kill()
var tween: Tween = button.create_tween()
button.set_meta(_TWEEN_META, tween)
return tween
static func _keep_pivot_centred(button: BaseButton) -> void:
button.pivot_offset = button.size / 2
if !button.has_meta(_PIVOT_META):
button.set_meta(_PIVOT_META, true)
button.resized.connect(func() -> void: button.pivot_offset = button.size / 2)
+1
View File
@@ -0,0 +1 @@
uid://c4vekt3m8q1p7
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="416" height="416" viewBox="48 48 416 416">
<!-- The pause shape of pause_icon_light.svg in one colour -->
<path fill="#f9f9f9" d="M224,435.8V76.1c0-6.7-5.4-12.1-12.2-12.1h-71.6c-6.8,0-12.2,5.4-12.2,12.1v359.7c0,6.7,5.4,12.2,12.2,12.2h71.6 C218.6,448,224,442.6,224,435.8z"/>
<path fill="#f9f9f9" d="M371.8,64h-71.6c-6.7,0-12.2,5.4-12.2,12.1v359.7c0,6.7,5.4,12.2,12.2,12.2h71.6c6.7,0,12.2-5.4,12.2-12.2V76.1 C384,69.4,378.6,64,371.8,64z"/>
</svg>

After

Width:  |  Height:  |  Size: 502 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://djdsgbhw8gpj"
path="res://.godot/imported/pause_button_icon.svg-cf86876de39506a769c5610b329fdb75.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icons/pause_button_icon.svg"
dest_files=["res://.godot/imported/pause_button_icon.svg-cf86876de39506a769c5610b329fdb75.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="416" height="416" viewBox="64 48 416 416">
<!-- The play shape of play_icon_light.svg in one colour, shifted right so it looks centred -->
<path fill="#f9f9f9" transform="translate(16 0)" d="M405.2,232.9L126.8,67.2c-3.4-2-6.9-3.2-10.9-3.2c-10.9,0-19.8,9-19.8,20H96v344h0.1c0,11,8.9,20,19.8,20 c4.1,0,7.5-1.4,11.2-3.4l278.1-165.5c6.6-5.5,10.8-13.8,10.8-23.1C416,246.7,411.8,238.5,405.2,232.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dkweun1f426gl"
path="res://.godot/imported/play_button_icon.svg-76b19c7150b176f215ad401b4dd9488d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icons/play_button_icon.svg"
dest_files=["res://.godot/imported/play_button_icon.svg-76b19c7150b176f215ad401b4dd9488d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
<!-- reload_light_icon.svg mirrored, so the arrow turns back like a restart -->
<path fill="#f9f9f9" transform="translate(28 0) scale(-1 1)" d="M 12.273759,27.821814 C 8.301606,27.305128 4.6414458,25.025364 2.4172439,21.682595 -0.6740156,17.036719 -0.6987372,11.050866 2.3541959,6.4167276 6.4831292,0.1492961 14.7875,-1.7884148 21.095349,2.0437368 l 0.883303,0.5366251 0.09577,-0.6963111 c 0.07593,-0.5520493 0.198918,-0.7994615 0.593645,-1.1941885 0.682393,-0.6823936 1.29683,-0.7876518 2.135162,-0.3657721 1.11162,0.5594083 1.142977,0.6968977 1.099029,4.8188242 C 25.866598,8.48735 25.848168,8.714727 25.583585,9.075118 24.993194,9.879284 24.711316,9.931761 20.980939,9.931993 17.03905,9.932238 16.871859,9.891713 16.324094,8.803226 15.94065,8.041272 16.00249,7.4396205 16.528498,6.8144942 17.041282,6.2050862 17.302265,6.1106614 18.732442,6.0170974 l 1.29765,-0.084894 -0.711421,-0.4557502 C 16.407302,3.611373 12.42501,3.4754273 9.250891,5.1327637 6.9185825,6.3505563 5.186242,8.47902 4.3514391,11.152542 c -0.4596099,1.471937 -0.4596099,4.222979 0,5.694916 1.0532717,3.373185 3.4279179,5.747831 6.8011029,6.801103 0.874696,0.273122 1.291973,0.316826 2.983051,0.31243 1.791555,-0.0047 2.062404,-0.03855 3.050848,-0.381822 2.095349,-0.727675 4.004905,-2.225187 5.276499,-4.137935 0.712133,-1.071201 1.028577,-1.77101 1.500599,-3.318525 0.234713,-0.769505 0.466198,-1.242244 0.733848,-1.49867 0.768275,-0.736054 1.953395,-0.694987 2.67526,0.0927 0.56162,0.612834 0.678351,1.318164 0.398766,2.409492 -0.7664,2.991551 -3.210883,6.444937 -5.916304,8.35812 -2.688606,1.901292 -6.251776,2.77056 -9.58135,2.337459 z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://4t7vk634k0v4"
path="res://.godot/imported/restart_light_icon.svg-3822a30546bff23ba1adb74c1723ba80.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icons/restart_light_icon.svg"
dest_files=["res://.godot/imported/restart_light_icon.svg-3822a30546bff23ba1adb74c1723ba80.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="#f9f9f9" d="M3 9.5v5c0 .55.45 1 1 1h3.2l4.1 3.6c.65.57 1.7.11 1.7-.76V5.66c0-.87-1.05-1.33-1.7-.76L7.2 8.5H4c-.55 0-1 .45-1 1z"/>
<path fill="none" stroke="#f9f9f9" stroke-width="2" stroke-linecap="round" d="M16 8.5a5 5 0 0 1 0 7M18.8 5.8a9 9 0 0 1 0 12.4"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d0uyxivsv58uh"
path="res://.godot/imported/volume_light_icon.svg-a1b08f489924a01123c9c4f7fa5e16a0.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icons/volume_light_icon.svg"
dest_files=["res://.godot/imported/volume_light_icon.svg-a1b08f489924a01123c9c4f7fa5e16a0.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+52 -22
View File
@@ -1,9 +1,10 @@
[gd_scene load_steps=10 format=3 uid="uid://ds15cgsf8vpvc"] [gd_scene format=3 uid="uid://ds15cgsf8vpvc"]
[ext_resource type="Script" uid="uid://d1v0rquma6nt8" path="res://player/music_player.gd" id="1_t24ra"] [ext_resource type="Script" uid="uid://d1v0rquma6nt8" path="res://player/music_player.gd" id="1_t24ra"]
[ext_resource type="AudioStream" uid="uid://n2g8jddr85h2" path="res://sounds/01. Opening.mp3" id="2_xti80"] [ext_resource type="AudioStream" uid="uid://n2g8jddr85h2" path="res://sounds/01. Opening.mp3" id="2_xti80"]
[ext_resource type="Texture2D" uid="uid://comxqfiykp54f" path="res://icons/play_icon_light.svg" id="3_6g308"] [ext_resource type="Texture2D" path="res://icons/play_button_icon.svg" id="3_6g308"]
[ext_resource type="Texture2D" uid="uid://ccb6rvbldlgdg" path="res://icons/reload_light_icon.svg" id="4_jleuo"] [ext_resource type="Texture2D" path="res://icons/restart_light_icon.svg" id="4_jleuo"]
[ext_resource type="Texture2D" path="res://icons/volume_light_icon.svg" id="6_volume"]
[ext_resource type="PackedScene" uid="uid://w400rnew7453" path="res://player/VolumeSlider.tscn" id="5_iifuj"] [ext_resource type="PackedScene" uid="uid://w400rnew7453" path="res://player/VolumeSlider.tscn" id="5_iifuj"]
[sub_resource type="InputEventKey" id="InputEventKey_r4qo3"] [sub_resource type="InputEventKey" id="InputEventKey_r4qo3"]
@@ -29,7 +30,7 @@ events = [SubResource("InputEventKey_2nvce")]
[node name="MusicPlayer" type="Control"] [node name="MusicPlayer" type="Control"]
layout_mode = 3 layout_mode = 3
offset_right = 1792.0 offset_right = 1792.0
offset_bottom = 71.0 offset_bottom = 42.0
mouse_filter = 2 mouse_filter = 2
script = ExtResource("1_t24ra") script = ExtResource("1_t24ra")
@@ -46,52 +47,81 @@ stream = ExtResource("2_xti80")
bus = &"music" bus = &"music"
[node name="PlayerBar" type="PanelContainer" parent="."] [node name="PlayerBar" type="PanelContainer" parent="."]
layout_mode = 0 layout_mode = 1
offset_left = 1.0 anchors_preset = 15
offset_right = 1792.0 anchor_right = 1.0
offset_bottom = 32.0 anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_type_variation = &"PlayerBar"
[node name="MusicPlayerContainer" type="HBoxContainer" parent="PlayerBar"] [node name="MusicPlayerContainer" type="HBoxContainer" parent="PlayerBar"]
layout_mode = 2 layout_mode = 2
size_flags_vertical = 4 mouse_filter = 2
theme_override_constants/separation = 10
[node name="PlayTextureButton" type="TextureButton" parent="PlayerBar/MusicPlayerContainer"] [node name="PlayButton" type="Button" parent="PlayerBar/MusicPlayerContainer"]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(32, 32) custom_minimum_size = Vector2(34, 34)
layout_mode = 2 layout_mode = 2
size_flags_vertical = 4
tooltip_text = "Play or pause (Alt+X)"
focus_mode = 0 focus_mode = 0
theme_type_variation = &"PlayerButtonPrimary"
action_mode = 0 action_mode = 0
shortcut = SubResource("Shortcut_ipcfh") shortcut = SubResource("Shortcut_ipcfh")
texture_normal = ExtResource("3_6g308") shortcut_feedback = false
ignore_texture_size = true shortcut_in_tooltip = false
stretch_mode = 0 icon = ExtResource("3_6g308")
icon_alignment = 1
expand_icon = true
[node name="RestartTextureButton" type="TextureButton" parent="PlayerBar/MusicPlayerContainer"] [node name="RestartButton" type="Button" parent="PlayerBar/MusicPlayerContainer"]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(32, 32) custom_minimum_size = Vector2(34, 34)
layout_mode = 2 layout_mode = 2
size_flags_vertical = 4
tooltip_text = "Restart the song (Alt+Z)"
focus_mode = 0 focus_mode = 0
theme_type_variation = &"PlayerButton"
action_mode = 0 action_mode = 0
shortcut = SubResource("Shortcut_i7swq") shortcut = SubResource("Shortcut_i7swq")
texture_normal = ExtResource("4_jleuo") shortcut_feedback = false
ignore_texture_size = true shortcut_in_tooltip = false
stretch_mode = 0 icon = ExtResource("4_jleuo")
flip_h = true icon_alignment = 1
expand_icon = true
[node name="MusicPlayerSlider" type="HSlider" parent="PlayerBar/MusicPlayerContainer"] [node name="MusicPlayerSlider" type="HSlider" parent="PlayerBar/MusicPlayerContainer"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 4 size_flags_vertical = 4
theme_type_variation = &"ProgressSlider"
scrollable = false scrollable = false
[node name="MusicTimeLabel" type="Label" parent="PlayerBar/MusicPlayerContainer"] [node name="MusicTimeLabel" type="Label" parent="PlayerBar/MusicPlayerContainer"]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(120, 0)
layout_mode = 2 layout_mode = 2
text = "0:00 / 0:00" theme_type_variation = &"PlayerTime"
text = "00:00 / 00:00"
horizontal_alignment = 1
[node name="VolumeIcon" type="TextureRect" parent="PlayerBar/MusicPlayerContainer"]
custom_minimum_size = Vector2(22, 22)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
texture = ExtResource("6_volume")
expand_mode = 1
stretch_mode = 5
[node name="VolumeSlider" parent="PlayerBar/MusicPlayerContainer" instance=ExtResource("5_iifuj")] [node name="VolumeSlider" parent="PlayerBar/MusicPlayerContainer" instance=ExtResource("5_iifuj")]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(100, 0) custom_minimum_size = Vector2(110, 0)
layout_mode = 2 layout_mode = 2
size_flags_vertical = 4 size_flags_vertical = 4
tooltip_text = "Volume"
theme_type_variation = &"VolumeBar"
+29 -11
View File
@@ -2,19 +2,22 @@ class_name MusicPlayer
extends Control extends Control
## The bar that plays the songs, with play, restart, progress and volume. Whether a song ## The bar that plays the songs, with play, restart, progress and volume. Whether a song
## repeats is a quiz setting ([code]Settings.auto_repeat_song[/code]). ## repeats is a quiz setting ([code]Settings.auto_repeat_song[/code]).
##
## The buttons grow a little under the mouse and the play button pops when it switches
## between play and pause, see [ButtonBounce].
signal update_song_list signal update_song_list
signal play_next_song signal play_next_song
var play_icon: Texture = preload("res://icons/play_icon_light.svg") var play_icon: Texture = preload("res://icons/play_button_icon.svg")
var pause_icon: Texture = preload("res://icons/pause_icon_light.svg") var pause_icon: Texture = preload("res://icons/pause_button_icon.svg")
var is_changing: bool = false var is_changing: bool = false
var playback_position: float var playback_position: float
var stream: AudioStream var stream: AudioStream
var song_finished: bool = false var song_finished: bool = false
@onready var play_button: TextureButton = %PlayTextureButton @onready var play_button: Button = %PlayButton
@onready var restart_button: TextureButton = %RestartTextureButton @onready var restart_button: Button = %RestartButton
@onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer @onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer
@onready var sound_player: AudioStreamPlayer = $AudioSoundPlayer @onready var sound_player: AudioStreamPlayer = $AudioSoundPlayer
@onready var sound_effect_player: AudioStreamPlayer = $AudioSoundEffectPlayer @onready var sound_effect_player: AudioStreamPlayer = $AudioSoundEffectPlayer
@@ -26,6 +29,8 @@ var song_finished: bool = false
func _ready() -> void: func _ready() -> void:
play_button.pressed.connect(play_or_pause) play_button.pressed.connect(play_or_pause)
restart_button.pressed.connect(restart) restart_button.pressed.connect(restart)
ButtonBounce.add_to(play_button)
ButtonBounce.add_to(restart_button)
progress_slider.drag_started.connect(_on_drag_started) progress_slider.drag_started.connect(_on_drag_started)
progress_slider.drag_ended.connect(_on_drag_ended) progress_slider.drag_ended.connect(_on_drag_ended)
@@ -43,6 +48,19 @@ func _process(_delta: float) -> void:
music_time_label.text = format_text(progress_slider.value, 0.0) music_time_label.text = format_text(progress_slider.value, 0.0)
## Whether the play button shows pause, because a song is playing.
func shows_playing() -> bool:
return play_button.icon == pause_icon
# Shows pause on the play button while a song plays, and play otherwise
func _show_playing(playing: bool) -> void:
var icon: Texture = pause_icon if playing else play_icon
if play_button.icon != icon:
play_button.icon = icon
ButtonBounce.pop(play_button)
func format_time(time: float) -> String: func format_time(time: float) -> String:
var total_seconds: int = roundi(time) var total_seconds: int = roundi(time)
@warning_ignore("integer_division") @warning_ignore("integer_division")
@@ -64,7 +82,7 @@ func play_or_pause() -> void:
if song_finished: if song_finished:
restart() restart()
elif audio_player.stream_paused: elif audio_player.stream_paused:
play_button.texture_normal = pause_icon _show_playing(true)
audio_player.stream_paused = false audio_player.stream_paused = false
song_finished = false song_finished = false
audio_player.seek(playback_position) audio_player.seek(playback_position)
@@ -81,7 +99,7 @@ func play_or_pause() -> void:
func pause() -> void: func pause() -> void:
audio_player.stream_paused = true audio_player.stream_paused = true
playback_position = audio_player.get_playback_position() playback_position = audio_player.get_playback_position()
play_button.texture_normal = play_icon _show_playing(false)
func restart() -> void: func restart() -> void:
@@ -91,12 +109,12 @@ func restart() -> void:
progress_slider.value = 0 progress_slider.value = 0
playback_position = audio_player.get_playback_position() playback_position = audio_player.get_playback_position()
audio_player.seek(playback_position) audio_player.seek(playback_position)
play_button.texture_normal = pause_icon _show_playing(true)
audio_player.play() audio_player.play()
func _on_finished() -> void: func _on_finished() -> void:
play_button.texture_normal = play_icon _show_playing(false)
song_finished = true song_finished = true
if !Settings.stop_after_current: if !Settings.stop_after_current:
play_next_song.emit() play_next_song.emit()
@@ -150,7 +168,7 @@ func play_sound(sound_name: AudioStream) -> void:
audio_player.stop() audio_player.stop()
sound_player.play() sound_player.play()
song_finished = true song_finished = true
play_button.texture_normal = play_icon _show_playing(false)
progress_slider.value = 0 progress_slider.value = 0
@@ -159,7 +177,7 @@ func play_song(song: AudioStream) -> void:
audio_player.play() audio_player.play()
sound_player.stop() sound_player.stop()
song_finished = false song_finished = false
play_button.texture_normal = pause_icon _show_playing(true)
stream = audio_player.stream stream = audio_player.stream
progress_slider.value = 0 progress_slider.value = 0
progress_slider.max_value = round(stream.get_length()) progress_slider.max_value = round(stream.get_length())
@@ -179,7 +197,7 @@ func play_song_object(song_object_no: int) -> void:
sound_player.stop() sound_player.stop()
audio_player.play() audio_player.play()
song_finished = false song_finished = false
play_button.texture_normal = pause_icon _show_playing(true)
stream = audio_player.stream stream = audio_player.stream
progress_slider.value = 0 progress_slider.value = 0
progress_slider.max_value = round(stream.get_length()) progress_slider.max_value = round(stream.get_length())
+15
View File
@@ -158,6 +158,21 @@ focus_mode = 0
action_mode = 0 action_mode = 0
text = "Count plays in statistics" text = "Count plays in statistics"
[node name="ConfigFolderRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/ConfigFolderRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Settings and cached files"
[node name="OpenConfigFolderButton" type="Button" parent="SettingsPanel/VBoxContainer/ConfigFolderRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Open folder"
[node name="FolderDialog" type="FileDialog" parent="."] [node name="FolderDialog" type="FileDialog" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
title = "Choose a music folder" title = "Choose a music folder"
+26 -1
View File
@@ -2,7 +2,8 @@ class_name SettingsPopup
extends BasePopup extends BasePopup
## The application settings: which MusicServer or music folder to play from, fullscreen, ## The application settings: which MusicServer or music folder to play from, fullscreen,
## how many songs to download ahead, whether to prefer less played soundtracks and whether ## how many songs to download ahead, whether to prefer less played soundtracks and whether
## plays count in the statistics. Opened from the Settings button in the top bar. ## plays count in the statistics, and a button that opens the folder the settings are
## kept in. Opened from the Settings button in the top bar.
## ##
## A music folder (see [MusicFolder]) is used instead of the server until Use server is ## A music folder (see [MusicFolder]) is used instead of the server until Use server is
## pressed or the game is closed. The settings that need the server are off meanwhile. ## pressed or the game is closed. The settings that need the server are off meanwhile.
@@ -32,6 +33,11 @@ const PADDING: Vector2 = Vector2(20, 12)
@onready var play_order_row: HBoxContainer = %PlayOrderRow @onready var play_order_row: HBoxContainer = %PlayOrderRow
@onready var play_order_button: OptionButton = %PlayOrderButton @onready var play_order_button: OptionButton = %PlayOrderButton
@onready var folder_dialog: FileDialog = %FolderDialog @onready var folder_dialog: FileDialog = %FolderDialog
@onready var open_config_folder_button: Button = %OpenConfigFolderButton
## Shows a folder in the system's file manager. The tests replace it, so no file manager
## opens while they run.
var show_folder: Callable = _show_in_file_manager
# Where the panel's middle is, so it stays centred when rows are shown or hidden # Where the panel's middle is, so it stays centred when rows are shown or hidden
var _panel_middle_y: float = 0.0 var _panel_middle_y: float = 0.0
@@ -48,6 +54,8 @@ func _ready() -> void:
choose_folder_button.pressed.connect(folder_dialog.popup_centered) choose_folder_button.pressed.connect(folder_dialog.popup_centered)
folder_dialog.dir_selected.connect(use_music_folder) folder_dialog.dir_selected.connect(use_music_folder)
use_server_button.pressed.connect(use_server) use_server_button.pressed.connect(use_server)
open_config_folder_button.pressed.connect(open_config_folder)
open_config_folder_button.tooltip_text = Settings.config_dir()
play_order_button.item_selected.connect(_on_play_order_selected) play_order_button.item_selected.connect(_on_play_order_selected)
lower_cache_button.pressed.connect(lower_cache) lower_cache_button.pressed.connect(lower_cache)
increase_cache_button.pressed.connect(increase_cache) increase_cache_button.pressed.connect(increase_cache)
@@ -88,6 +96,14 @@ func use_server() -> void:
server_changed.emit() server_changed.emit()
## Shows the folder with the config file, the saved settings and the cached characters
## and songs in the system's file manager.
func open_config_folder() -> void:
var dir: String = Settings.config_dir()
DirAccess.make_dir_recursive_absolute(dir)
show_folder.call(dir)
func lower_cache() -> void: func lower_cache() -> void:
Playlist.number_of_tracks_to_preload = maxi(Playlist.number_of_tracks_to_preload - 1, 1) Playlist.number_of_tracks_to_preload = maxi(Playlist.number_of_tracks_to_preload - 1, 1)
cache_label.text = str(Playlist.number_of_tracks_to_preload) cache_label.text = str(Playlist.number_of_tracks_to_preload)
@@ -160,5 +176,14 @@ func _fit_to_rows() -> void:
rows.size = Vector2(settings_panel.size.x - PADDING.x * 2, height - PADDING.y * 2) rows.size = Vector2(settings_panel.size.x - PADDING.x * 2, height - PADDING.y * 2)
func _show_in_file_manager(dir: String) -> void:
var error: Error = OS.shell_show_in_file_manager(dir)
if error != OK:
# Not every platform has a file manager to show a folder in; open it instead
error = OS.shell_open(dir)
if error != OK:
GameLog.warning("Could not open %s: %s" % [dir, error_string(error)])
func _update_fullscreen_button() -> void: func _update_fullscreen_button() -> void:
fullscreen_button.set_pressed_no_signal(Settings.fullscreen) fullscreen_button.set_pressed_no_signal(Settings.fullscreen)
+2 -1
View File
@@ -13,6 +13,7 @@ script = ExtResource("part_script")
[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=707750459] [node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=707750459]
layout_mode = 2 layout_mode = 2
follow_focus = true follow_focus = true
horizontal_scroll_mode = 0
script = ExtResource("7_dj026") script = ExtResource("7_dj026")
[node name="MusicList" type="VBoxContainer" parent="ScrollContainer" unique_id=373398568] [node name="MusicList" type="VBoxContainer" parent="ScrollContainer" unique_id=373398568]
@@ -20,4 +21,4 @@ unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_constants/separation = 10 theme_override_constants/separation = 4
+2 -2
View File
@@ -48,9 +48,9 @@ offset_bottom = 384.0
[node name="MusicPlayer" parent="." unique_id=1990459832 instance=ExtResource("5_emn36")] [node name="MusicPlayer" parent="." unique_id=1990459832 instance=ExtResource("5_emn36")]
layout_mode = 0 layout_mode = 0
offset_left = 64.0 offset_left = 64.0
offset_top = 992.0 offset_top = 984.0
offset_right = 1856.0 offset_right = 1856.0
offset_bottom = 1063.0 offset_bottom = 1026.0
[node name="PlayersPanel" parent="." unique_id=1708375945 instance=ExtResource("scene_PlayersPanel")] [node name="PlayersPanel" parent="." unique_id=1708375945 instance=ExtResource("scene_PlayersPanel")]
layout_mode = 0 layout_mode = 0
+9 -43
View File
@@ -1,63 +1,29 @@
[gd_scene format=3 uid="uid://1jbtmhbqolqe"] [gd_scene format=3 uid="uid://1jbtmhbqolqe"]
[ext_resource type="Texture2D" uid="uid://c8r7ykphss4c2" path="res://characters/Link.png" id="1_ll28s"]
[ext_resource type="Script" uid="uid://cjo13wixip8mq" path="res://quiz/song_row.gd" id="1_lspki"] [ext_resource type="Script" uid="uid://cjo13wixip8mq" path="res://quiz/song_row.gd" id="1_lspki"]
[node name="SongRow" type="PanelContainer"] [node name="SongRow" type="PanelContainer"]
custom_minimum_size = Vector2(1000, 0)
offset_right = 1088.0
offset_bottom = 59.0
theme_type_variation = &"SongRow" theme_type_variation = &"SongRow"
script = ExtResource("1_lspki") script = ExtResource("1_lspki")
[node name="HBoxContainer" type="HBoxContainer" parent="."] [node name="HBoxContainer" type="HBoxContainer" parent="."]
layout_mode = 2 layout_mode = 2
size_flags_vertical = 0 mouse_filter = 2
theme_override_constants/separation = 12
[node name="SongListLabel" type="Label" parent="HBoxContainer"] [node name="SongListLabel" type="Label" parent="HBoxContainer"]
unique_name_in_owner = true unique_name_in_owner = true
texture_filter = 2
custom_minimum_size = Vector2(865, 28)
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
mouse_filter = 1 mouse_filter = 1
theme_type_variation = &"SongRowLabel" theme_type_variation = &"SongRowLabel"
text = "Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv) - 'Crystal Cave -arrange-'' from FINAL FANTASY III" vertical_alignment = 1
autowrap_mode = 2 autowrap_mode = 3
[node name="Panel" type="Panel" parent="HBoxContainer"] [node name="SongListPlayers" type="VBoxContainer" parent="HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 0
[node name="SongListPlayers" type="HBoxContainer" parent="HBoxContainer"]
unique_name_in_owner = true unique_name_in_owner = true
visible = false
layout_mode = 2 layout_mode = 2
size_flags_vertical = 0 size_flags_vertical = 4
mouse_filter = 2
[node name="TextureRect" type="TextureRect" parent="HBoxContainer/SongListPlayers"] theme_type_variation = &"SongRowPictureLines"
custom_minimum_size = Vector2(50, 25)
layout_mode = 2
texture = ExtResource("1_ll28s")
expand_mode = 1
stretch_mode = 5
[node name="TextureRect2" type="TextureRect" parent="HBoxContainer/SongListPlayers"]
custom_minimum_size = Vector2(50, 25)
layout_mode = 2
texture = ExtResource("1_ll28s")
expand_mode = 1
stretch_mode = 6
[node name="TextureRect3" type="TextureRect" parent="HBoxContainer/SongListPlayers"]
custom_minimum_size = Vector2(50, 25)
layout_mode = 2
texture = ExtResource("1_ll28s")
expand_mode = 1
[node name="TextureRect4" type="TextureRect" parent="HBoxContainer/SongListPlayers"]
custom_minimum_size = Vector2(50, 25)
layout_mode = 2
texture = ExtResource("1_ll28s")
expand_mode = 1
stretch_mode = 6
+4 -1
View File
@@ -14,9 +14,12 @@ var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
## Shows the songs in the playlist again, so it shows their latest state. ## Shows the songs in the playlist again, so it shows their latest state.
func refresh() -> void: func refresh() -> void:
Settings.delete_children(_rows) Settings.delete_children(_rows)
for s: SongObject in Playlist.get_playlist(): var songs: Array[SongObject] = Playlist.get_playlist()
for i: int in songs.size():
var s: SongObject = songs[i]
var new_song_list_object: SongRow = _song_list_object.instantiate() var new_song_list_object: SongRow = _song_list_object.instantiate()
new_song_list_object.song_object = s new_song_list_object.song_object = s
new_song_list_object.index = i
new_song_list_object.gui_input.connect(_on_row_input.bind(s.song_number)) new_song_list_object.gui_input.connect(_on_row_input.bind(s.song_number))
_rows.add_child(new_song_list_object) _rows.add_child(new_song_list_object)
+80 -12
View File
@@ -1,37 +1,105 @@
class_name SongRow class_name SongRow
extends PanelContainer extends PanelContainer
## One song in the music list: its number, soundtrack and title, and a marker for ## One song in the music list: its number, soundtrack and title, and the character of
## each player who got a point for it. ## each player who got a point for it.
##
## The pictures are on the right and the text takes the room they leave, wrapping only
## when it does not fit on one line. With many pictures they go on more lines, as even as
## possible and at most [constant MAX_PICTURES_PER_LINE] on each, so the text keeps most
## of the row. Every line ends at the row's right edge. The row is as tall as the taller
## of the text and the pictures.
## The most pictures on one line before they go on another.
const MAX_PICTURES_PER_LINE: int = 4
## The size of each picture. Characters are twice as wide as they are tall.
const PICTURE_SIZE: Vector2 = Vector2(56, 28)
var song_object: SongObject var song_object: SongObject
## The row's place in the list, for the alternating background.
var index: int = 0
var _hovered: bool = false
@onready var song_list_label: Label = %SongListLabel @onready var song_list_label: Label = %SongListLabel
@onready var song_list_players: HBoxContainer = %SongListPlayers ## The lines of pictures, top to bottom, each an HBoxContainer.
@onready var song_list_players: VBoxContainer = %SongListPlayers
func _ready() -> void: func _ready() -> void:
mouse_entered.connect(_set_hovered.bind(true))
mouse_exited.connect(_set_hovered.bind(false))
set_label() set_label()
update_players() update_players()
## The pictures shown, line by line.
func pictures() -> Array[TextureRect]:
var shown: Array[TextureRect] = []
for line: Node in song_list_players.get_children():
shown.append_array(line.get_children())
return shown
## The number of pictures on a full line for [param pictures] pictures: as few lines as
## [constant MAX_PICTURES_PER_LINE] allows, with the pictures spread evenly over them,
## e.g. 3 and 2 for five pictures instead of 4 and 1.
static func picture_columns(pictures: int) -> int:
if pictures <= 0:
return 1
var lines: int = ceili(float(pictures) / MAX_PICTURES_PER_LINE)
return ceili(float(pictures) / lines)
func set_label() -> void: func set_label() -> void:
if song_object != null: if song_object != null:
var size_to_change: int = song_object.players_given_point.size() * 50 song_list_label.theme_type_variation = &"SongRowLabelPlaying" \
song_list_label.custom_minimum_size = Vector2(1000 - size_to_change, 28) if song_object.is_playing else &"SongRowLabel"
if song_object.is_playing:
song_list_label.theme_type_variation = &"SongRowLabelPlaying"
song_list_label.text = song_object.get_song_info() song_list_label.text = song_object.get_song_info()
_update_style()
func update_players() -> void: func update_players() -> void:
Settings.delete_children(song_list_players) Settings.delete_children(song_list_players)
var scorers: Array[PlayerObject] = []
if song_object != null: if song_object != null:
# Show players in the same order as the player list # Show players in the same order as the player list
for player: PlayerObject in Settings.player_array: for player: PlayerObject in Settings.player_array:
if song_object.players_given_point.has(player.id): if song_object.players_given_point.has(player.id):
var texture: TextureRect = TextureRect.new() scorers.append(player)
texture.texture = player.character var per_line: int = picture_columns(scorers.size())
texture.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL var line: HBoxContainer = null
texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED for i: int in scorers.size():
texture.custom_minimum_size = Vector2(50, 25) if i % per_line == 0:
song_list_players.add_child(texture) line = HBoxContainer.new()
line.theme_type_variation = &"SongRowPictureLine"
line.alignment = BoxContainer.ALIGNMENT_END
line.mouse_filter = Control.MOUSE_FILTER_IGNORE
song_list_players.add_child(line)
line.add_child(_make_picture(scorers[i]))
song_list_players.visible = !scorers.is_empty()
func _make_picture(player: PlayerObject) -> TextureRect:
var picture: TextureRect = TextureRect.new()
picture.texture = player.character
picture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
picture.custom_minimum_size = PICTURE_SIZE
picture.tooltip_text = player.player_name
# Clicks on a picture still click the row
picture.mouse_filter = Control.MOUSE_FILTER_PASS
return picture
func _set_hovered(hovered: bool) -> void:
_hovered = hovered
_update_style()
func _update_style() -> void:
if _hovered:
theme_type_variation = &"SongRowHover"
elif song_object != null && song_object.is_playing:
theme_type_variation = &"SongRowPlaying"
else:
theme_type_variation = &"SongRowOdd" if index % 2 != 0 else &"SongRow"
+68
View File
@@ -0,0 +1,68 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://statistics/song_stats_list.gd" id="list_script"]
[node name="SongStatsList" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("list_script")
[node name="Layout" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Columns" type="VBoxContainer" parent="Layout"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="Header" type="PanelContainer" parent="Layout/Columns"]
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"SongStatsHeader"
[node name="Titles" type="HBoxContainer" parent="Layout/Columns/Header"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 16
[node name="SongTitle" type="Label" parent="Layout/Columns/Header/Titles"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_stretch_ratio = 3.0
theme_type_variation = &"HighlightMedium"
text = "Song"
[node name="SoundtrackTitle" type="Label" parent="Layout/Columns/Header/Titles"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_stretch_ratio = 2.0
theme_type_variation = &"HighlightMedium"
text = "Soundtrack"
[node name="PlayedTitle" type="Label" parent="Layout/Columns/Header/Titles"]
custom_minimum_size = Vector2(100, 0)
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "Played"
horizontal_alignment = 2
[node name="RowsArea" type="Control" parent="Layout/Columns"]
unique_name_in_owner = true
clip_contents = true
layout_mode = 2
size_flags_vertical = 3
[node name="ScrollBar" type="VScrollBar" parent="Layout"]
unique_name_in_owner = true
layout_mode = 2
+50
View File
@@ -0,0 +1,50 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://statistics/song_stats_row.gd" id="row_script"]
[node name="SongStatsRow" type="PanelContainer"]
mouse_filter = 1
theme_type_variation = &"ListRowEven"
script = ExtResource("row_script")
[node name="Columns" type="HBoxContainer" parent="."]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 16
[node name="SongLabel" type="RichTextLabel" parent="Columns"]
unique_name_in_owner = true
clip_contents = true
custom_minimum_size = Vector2(0, 28)
layout_mode = 2
size_flags_horizontal = 3
size_flags_stretch_ratio = 3.0
mouse_filter = 2
theme_type_variation = &"SongStatsText"
bbcode_enabled = true
scroll_active = false
autowrap_mode = 0
vertical_alignment = 1
[node name="SoundtrackLabel" type="RichTextLabel" parent="Columns"]
unique_name_in_owner = true
clip_contents = true
custom_minimum_size = Vector2(0, 28)
layout_mode = 2
size_flags_horizontal = 3
size_flags_stretch_ratio = 2.0
mouse_filter = 2
theme_type_variation = &"SongStatsSecondary"
bbcode_enabled = true
scroll_active = false
autowrap_mode = 0
vertical_alignment = 1
[node name="PlayedLabel" type="Label" parent="Columns"]
unique_name_in_owner = true
custom_minimum_size = Vector2(100, 0)
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"TextMedium"
horizontal_alignment = 2
vertical_alignment = 1
+41
View File
@@ -1,6 +1,9 @@
[gd_scene format=3] [gd_scene format=3]
[ext_resource type="Script" path="res://statistics/statistics_view.gd" id="view_script"] [ext_resource type="Script" path="res://statistics/statistics_view.gd" id="view_script"]
[ext_resource type="PackedScene" path="res://statistics/SongStatsList.tscn" id="song_list_scene"]
[sub_resource type="ButtonGroup" id="ButtonGroup_mode"]
[node name="StatisticsView" type="Panel" unique_id=1212000001] [node name="StatisticsView" type="Panel" unique_id=1212000001]
anchors_preset = 15 anchors_preset = 15
@@ -23,6 +26,30 @@ offset_bottom = 56.0
grow_horizontal = 2 grow_horizontal = 2
theme_override_constants/separation = 12 theme_override_constants/separation = 12
[node name="SoundtracksModeButton" type="Button" parent="Toolbar"]
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 0)
layout_mode = 2
focus_mode = 0
theme_type_variation = &"TabButton"
toggle_mode = true
button_pressed = true
button_group = SubResource("ButtonGroup_mode")
text = "Soundtracks"
[node name="SongsModeButton" type="Button" parent="Toolbar"]
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 0)
layout_mode = 2
focus_mode = 0
theme_type_variation = &"TabButton"
toggle_mode = true
button_group = SubResource("ButtonGroup_mode")
text = "Songs"
[node name="ModeSeparator" type="VSeparator" parent="Toolbar"]
layout_mode = 2
[node name="SearchField" type="LineEdit" parent="Toolbar" unique_id=1212000003] [node name="SearchField" type="LineEdit" parent="Toolbar" unique_id=1212000003]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(480, 0) custom_minimum_size = Vector2(480, 0)
@@ -100,6 +127,20 @@ size_flags_horizontal = 0
theme_type_variation = &"StatisticsGrid" theme_type_variation = &"StatisticsGrid"
columns = 6 columns = 6
[node name="SongList" parent="." instance=ExtResource("song_list_scene")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 32.0
offset_top = 72.0
offset_right = -32.0
offset_bottom = -16.0
[node name="SearchDelay" type="Timer" parent="."]
unique_name_in_owner = true
wait_time = 0.15
one_shot = true
[node name="StatusLabel" type="Label" parent="." unique_id=1212000012] [node name="StatusLabel" type="Label" parent="." unique_id=1212000012]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 1 layout_mode = 1
+110
View File
@@ -0,0 +1,110 @@
class_name SongStatsList
extends Control
## A scrollable list of songs with their soundtrack and how often they were played, under
## a header naming the columns. Shown in the statistics view instead of the soundtrack
## cards when Songs is chosen.
##
## The server has tens of thousands of songs, so only the rows on screen exist. They are
## reused for other songs as the list scrolls.
## Emitted when a song is clicked.
signal song_pressed(song: SongInfoForStats)
const ROW_SCENE: PackedScene = preload("res://statistics/SongStatsRow.tscn")
## How many rows one turn of the mouse wheel scrolls.
const ROWS_PER_WHEEL_STEP: int = 3
## The songs in the list, in the order shown.
var songs: Array[SongInfoForStats] = []
# The rows that fit on screen, top to bottom
var _rows: Array[SongStatsRow] = []
var _row_height: float = 0.0
var _search_text: String = ""
@onready var rows_area: Control = %RowsArea
@onready var scroll_bar: VScrollBar = %ScrollBar
func _ready() -> void:
var measured: SongStatsRow = ROW_SCENE.instantiate()
add_child(measured)
_row_height = measured.get_combined_minimum_size().y
measured.free()
rows_area.resized.connect(_fit_rows)
scroll_bar.value_changed.connect(func(_value: float) -> void: _show_rows())
rows_area.gui_input.connect(_on_gui_input)
_fit_rows()
## Shows [param new_songs] from the top, with the letters [param search_text] matched
## highlighted.
func show_songs(new_songs: Array[SongInfoForStats], search_text: String = "") -> void:
songs = new_songs
_search_text = search_text
_update_scroll_bar()
scroll_bar.value = 0
_show_rows()
## The rows showing a song now, top to bottom.
func shown_rows() -> Array[SongStatsRow]:
var shown: Array[SongStatsRow] = []
for row: SongStatsRow in _rows:
if row.visible:
shown.append(row)
return shown
## Scrolls so song number [param index] is at the top, as far as the list goes.
func scroll_to(index: int) -> void:
scroll_bar.value = index * _row_height
# Makes as many rows as fit on screen, plus one for the row partly scrolled out
func _fit_rows() -> void:
var needed: int = ceili(rows_area.size.y / _row_height) + 1
while _rows.size() < needed:
var row: SongStatsRow = ROW_SCENE.instantiate()
rows_area.add_child(row)
row.pressed.connect(song_pressed.emit)
_rows.append(row)
while _rows.size() > needed:
_rows.pop_back().free()
for row: SongStatsRow in _rows:
row.size = Vector2(rows_area.size.x, _row_height)
_update_scroll_bar()
_show_rows()
func _update_scroll_bar() -> void:
scroll_bar.max_value = songs.size() * _row_height
scroll_bar.page = rows_area.size.y
scroll_bar.step = 0
scroll_bar.visible = scroll_bar.max_value > scroll_bar.page
func _show_rows() -> void:
var first: int = floori(scroll_bar.value / _row_height)
var offset: float = scroll_bar.value - first * _row_height
for i: int in _rows.size():
var row: SongStatsRow = _rows[i]
var index: int = first + i
row.visible = index < songs.size()
if row.visible:
row.position = Vector2(0, i * _row_height - offset)
row.show_song(songs[index], index, _search_text)
func _on_gui_input(event: InputEvent) -> void:
if !(event is InputEventMouseButton) || !event.pressed:
return
var step: float = _row_height * ROWS_PER_WHEEL_STEP
match event.button_index:
MOUSE_BUTTON_WHEEL_UP:
scroll_bar.value -= step * event.factor if event.factor > 0 else step
MOUSE_BUTTON_WHEEL_DOWN:
scroll_bar.value += step * event.factor if event.factor > 0 else step
_:
return
rows_area.accept_event()
+1
View File
@@ -0,0 +1 @@
uid://tfgi4wql3cwa
+66
View File
@@ -0,0 +1,66 @@
class_name SongStatsRow
extends PanelContainer
## One song in the statistics' list of songs: its name, its soundtrack and how often it
## was played, with the letters a search matched highlighted. Clicking it asks for the
## song's soundtrack.
##
## Rows are reused for other songs as the list scrolls, see [SongStatsList].
## Emitted when the row is clicked.
signal pressed(song: SongInfoForStats)
## The song the row shows.
var song: SongInfoForStats
# The place in the list of the song shown, for the alternating background
var _index: int = 0
var _hovered: bool = false
@onready var song_label: RichTextLabel = %SongLabel
@onready var soundtrack_label: RichTextLabel = %SoundtrackLabel
@onready var played_label: Label = %PlayedLabel
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], song number [param index] in the list, with the letters
## [param search_text] matched highlighted.
func show_song(data: SongInfoForStats, index: int, search_text: String) -> void:
song = data
_index = index
_update_style()
var highlight: Color = get_theme_color(&"font_color", &"HighlightLarge")
_set_text(song_label, SearchPopup.highlighted_name(data.song_name, search_text, highlight))
_set_text(soundtrack_label,
SearchPopup.highlighted_name(data.soundtrack_name, search_text, highlight))
played_label.text = str(data.times_played)
tooltip_text = "%s\n%s" % [data.song_name, data.soundtrack_name]
# Setting the same text again would make the label lay it out again for nothing
static func _set_text(label: RichTextLabel, text: String) -> void:
if label.text != text:
label.text = text
func _set_hovered(hovered: bool) -> void:
_hovered = hovered
_update_style()
func _update_style() -> void:
if _hovered:
theme_type_variation = &"ListRowHover"
else:
theme_type_variation = &"ListRowOdd" if _index % 2 != 0 else &"ListRowEven"
func _on_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton && event.pressed \
&& event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
pressed.emit(song)
+1
View File
@@ -0,0 +1 @@
uid://dibwxcgne3p8c
+34 -8
View File
@@ -1,12 +1,15 @@
class_name SoundtrackCard class_name SoundtrackCard
extends PanelContainer extends PanelContainer
## One soundtrack in the statistics view: its name, how often and when it was last ## One soundtrack in the statistics view: its name, how often and when it was last
## played, and its most and least played songs. Clicking it asks for all its songs. ## 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.
## Emitted when the card is clicked. ## Emitted when the card is clicked.
signal pressed(soundtrack: SoundtrackWithSongs) signal pressed(soundtrack: SoundtrackWithSongs)
## How many of the most and least played songs the card lists. ## 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 const SONGS_SHOWN: int = 3
## The soundtrack the card shows. ## The soundtrack the card shows.
@@ -36,13 +39,17 @@ func show_soundtrack(data: SoundtrackWithSongs) -> void:
last_played_label.text = last_played_text(data.soundtrack_last_played) last_played_label.text = last_played_text(data.soundtrack_last_played)
last_played_label.visible = !data.soundtrack_last_played.is_empty() last_played_label.visible = !data.soundtrack_last_played.is_empty()
var most_played: Array[SongInfoForStats] = songs_by_plays(data.songs).slice(0, SONGS_SHOWN) var most_played: Array[SongInfoForStats] = []
# Without the songs already shown as most played var least_played: Array[SongInfoForStats] = []
var least_played: Array[SongInfoForStats] = songs_by_plays(data.songs, true).filter( var played: Array[SongInfoForStats] = played_songs(data.songs)
func(song: SongInfoForStats) -> bool: return !most_played.has(song) if played.size() >= SONGS_SHOWN * 2:
).slice(0, SONGS_SHOWN) 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 = "Most played" if !data.songs.is_empty() else "No songs" most_played_header.text = song_list_header(data.songs.size(), played.size())
_fill_song_list(most_played_list, most_played) _fill_song_list(most_played_list, most_played)
least_played_header.visible = !least_played.is_empty() least_played_header.visible = !least_played.is_empty()
_fill_song_list(least_played_list, least_played) _fill_song_list(least_played_list, least_played)
@@ -63,6 +70,25 @@ static func last_played_text(last_played: String) -> String:
return "Last played " + last_played.substr(0, 10) 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. ## One line of a song list: how often the song was played, then its name.
static func song_text(song: SongInfoForStats) -> String: static func song_text(song: SongInfoForStats) -> String:
return "%d × %s" % [song.times_played, song.song_name] return "%d × %s" % [song.times_played, song.song_name]
+148 -8
View File
@@ -1,7 +1,8 @@
class_name StatisticsView class_name StatisticsView
extends Panel extends Panel
## Shows every soundtrack on the server as a card with its play statistics. The cards ## Shows every soundtrack on the server as a card with its play statistics, or every
## can be searched, filtered on whether they have been played, and sorted. ## 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. ## Shown instead of the quiz when the Statistics tab is chosen.
@@ -11,29 +12,50 @@ signal soundtrack_selected(soundtrack: SoundtrackWithSongs)
## The orders the cards can be sorted in, in the order of the sort menu. ## 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 } enum SortOrder { A_TO_Z, Z_TO_A, MOST_PLAYED, LEAST_PLAYED }
## Which soundtracks are shown, in the order of the show menu. ## Which soundtracks or songs are shown, in the order of the show menu.
enum ShowFilter { ALL, PLAYED, NEVER_PLAYED } 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") const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn")
## The cards in the order they are shown, hidden ones included. ## The cards in the order they are shown, hidden ones included.
var cards: Array[SoundtrackCard] = [] var cards: Array[SoundtrackCard] = []
## Whether soundtracks or songs are shown. Change it with [method set_mode].
var mode: Mode = Mode.SOUNDTRACKS
var _search: SoundtrackSearch = SoundtrackSearch.new() var _search: SoundtrackSearch = SoundtrackSearch.new()
var _loading: bool = false 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 search_field: LineEdit = %SearchField
@onready var clear_button: Button = %ClearButton @onready var clear_button: Button = %ClearButton
@onready var show_label: Label = $Toolbar/ShowLabel
@onready var show_option: OptionButton = %ShowOption @onready var show_option: OptionButton = %ShowOption
@onready var sort_option: OptionButton = %SortOption @onready var sort_option: OptionButton = %SortOption
@onready var count_label: Label = %CountLabel @onready var count_label: Label = %CountLabel
@onready var status_label: Label = %StatusLabel @onready var status_label: Label = %StatusLabel
@onready var retry_button: Button = %RetryButton @onready var retry_button: Button = %RetryButton
@onready var card_grid: GridContainer = %CardGrid @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: func _ready() -> void:
search_field.text_changed.connect(func(_text: String) -> void: apply_filter()) 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) clear_button.pressed.connect(clear_search)
show_option.item_selected.connect(func(_index: int) -> void: apply_filter()) show_option.item_selected.connect(func(_index: int) -> void: apply_filter())
sort_option.item_selected.connect(func(_index: int) -> void: apply_sort()) sort_option.item_selected.connect(func(_index: int) -> void: apply_sort())
@@ -53,14 +75,34 @@ func refresh() -> void:
MusicServerClient.fetch_soundtrack_statistics(_on_loaded, _on_load_failed) MusicServerClient.fetch_soundtrack_statistics(_on_loaded, _on_load_failed)
## Shows every card again, keeping the show filter and the sort order. ## 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: func clear_search() -> void:
search_field.text = "" search_field.text = ""
apply_filter() apply_filter()
## Puts the cards in the order chosen in the sort menu. ## Puts the cards or songs in the order chosen in the sort menu.
func apply_sort() -> void: func apply_sort() -> void:
if mode == Mode.SONGS:
apply_filter()
return
var order: SortOrder = sort_option.selected as SortOrder var order: SortOrder = sort_option.selected as SortOrder
cards.sort_custom(func(a: SoundtrackCard, b: SoundtrackCard) -> bool: cards.sort_custom(func(a: SoundtrackCard, b: SoundtrackCard) -> bool:
return _comes_before(a.soundtrack, b.soundtrack, order)) return _comes_before(a.soundtrack, b.soundtrack, order))
@@ -68,8 +110,12 @@ func apply_sort() -> void:
card_grid.move_child(cards[i], i) card_grid.move_child(cards[i], i)
## Shows only the cards that match the search text and the show menu. ## Shows only the cards or songs that match the search text and the show menu.
func apply_filter() -> void: func apply_filter() -> void:
search_delay.stop()
if mode == Mode.SONGS:
_show_songs()
return
var search_text: String = search_field.text.strip_edges() var search_text: String = search_field.text.strip_edges()
var matching: Dictionary = {} var matching: Dictionary = {}
for soundtrack_name: String in _search.find_matches(search_text) \ for soundtrack_name: String in _search.find_matches(search_text) \
@@ -100,12 +146,106 @@ func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
while cards.size() > soundtracks.size(): while cards.size() > soundtracks.size():
cards.pop_back().free() cards.pop_back().free()
var names: Array[String] = [] var names: Array[String] = []
_soundtracks.clear()
for i: int in soundtracks.size(): for i: int in soundtracks.size():
cards[i].show_soundtrack(soundtracks[i]) cards[i].show_soundtrack(soundtracks[i])
names.append(soundtracks[i].soundtrack_name) names.append(soundtracks[i].soundtrack_name)
_soundtracks[soundtracks[i].soundtrack_name] = soundtracks[i]
_search.set_names(names) _search.set_names(names)
_songs_ready = false
apply_sort() apply_sort()
apply_filter() if mode == Mode.SOUNDTRACKS:
apply_filter()
# 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: func _on_load_failed() -> void:
File diff suppressed because it is too large. Load diff
+16
View File
@@ -267,6 +267,17 @@ func run(on_state: Callable) -> void:
statistics_view.apply_filter() statistics_view.apply_filter()
await _settle() await _settle()
await on_state.call("statistics_search", statistics_view) await on_state.call("statistics_search", statistics_view)
# Chrono Trigger has never been played, so the song list searches for another
statistics_view.search_field.text = "zelda"
statistics_view.set_mode(StatisticsView.Mode.SONGS)
await _settle()
await on_state.call("statistics_songs_search", statistics_view)
statistics_view.clear_search()
await _settle()
await on_state.call("statistics_songs", statistics_view)
statistics_view.search_field.text = "chrono"
statistics_view.set_mode(StatisticsView.Mode.SOUNDTRACKS)
await _settle()
var stats_popup: SoundtrackStatsPopup = _node("SoundtrackStatsPopup") var stats_popup: SoundtrackStatsPopup = _node("SoundtrackStatsPopup")
for card: SoundtrackCard in statistics_view.cards: for card: SoundtrackCard in statistics_view.cards:
if card.visible: if card.visible:
@@ -435,6 +446,11 @@ func _freeze_music_player() -> void:
music_player.sound_player.stop() music_player.sound_player.stop()
music_player.progress_slider.value = 0 music_player.progress_slider.value = 0
music_player.music_time_label.text = "00:00 / 00:01" music_player.music_time_label.text = "00:00 / 00:01"
# The buttons' animations would otherwise be caught halfway, see ButtonBounce
for button: Button in [music_player.play_button, music_player.restart_button]:
if button.has_meta(&"button_bounce_tween"):
(button.get_meta(&"button_bounce_tween") as Tween).kill()
button.scale = Vector2.ONE
## The log starts each row with the time of day; use a fixed time instead. ## The log starts each row with the time of day; use a fixed time instead.
@@ -26,3 +26,129 @@ func _click(control: Control) -> void:
func test_empty_space_lets_clicks_through() -> void: func test_empty_space_lets_clicks_through() -> void:
assert_int(_player.mouse_filter).is_equal(Control.MOUSE_FILTER_IGNORE) assert_int(_player.mouse_filter).is_equal(Control.MOUSE_FILTER_IGNORE)
func _song() -> AudioStreamMP3:
var song: AudioStreamMP3 = AudioStreamMP3.new()
song.data = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
return song
func _buttons() -> Array[Button]:
return [_player.play_button, _player.restart_button]
# Waits until the buttons' animations have finished
func _settle() -> void:
await await_millis(int(maxf(ButtonBounce.DURATION, ButtonBounce.POP_DURATION) * 1000) + 80)
# Icons
func test_icons_sit_inside_their_buttons_with_room_around_them() -> void:
await _runner.simulate_frames(1)
for button: Button in _buttons():
var style: StyleBox = button.get_theme_stylebox(&"normal")
for side: Side in [SIDE_LEFT, SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM]:
assert_float(style.get_content_margin(side)).override_failure_message(button.name) \
.is_greater_equal(8.0)
assert_bool(button.expand_icon).override_failure_message(button.name).is_true()
assert_int(button.icon_alignment).is_equal(HORIZONTAL_ALIGNMENT_CENTER)
# A square button, so the round background is a circle around the icon
assert_float(button.size.x).override_failure_message(button.name) \
.is_equal(button.size.y)
var icon_room: Vector2 = button.size - style.get_minimum_size()
assert_float(icon_room.x).override_failure_message(button.name).is_greater(12.0)
func test_buttons_are_centred_in_the_bar() -> void:
await _runner.simulate_frames(1)
var bar: Rect2 = _player.get_node("PlayerBar").get_global_rect()
for button: Button in _buttons():
var rect: Rect2 = button.get_global_rect()
assert_bool(bar.encloses(rect)).override_failure_message(button.name).is_true()
assert_float(rect.get_center().y).override_failure_message(button.name) \
.is_equal_approx(bar.get_center().y, 1.0)
func test_play_button_stands_out_from_restart() -> void:
assert_str(_player.play_button.theme_type_variation).is_equal("PlayerButtonPrimary")
assert_str(_player.restart_button.theme_type_variation).is_equal("PlayerButton")
assert_str(_player.progress_slider.theme_type_variation).is_equal("ProgressSlider")
assert_str(_player.volume_slider.theme_type_variation).is_equal("VolumeBar")
assert_str(_player.music_time_label.theme_type_variation).is_equal("PlayerTime")
func test_buttons_have_tooltips_with_their_keys() -> void:
assert_str(_player.play_button.tooltip_text).contains("Alt+X")
assert_str(_player.restart_button.tooltip_text).contains("Alt+Z")
# Play and pause
func test_play_button_shows_play_until_a_song_plays() -> void:
assert_bool(_player.shows_playing()).is_false()
_player.play_song(_song())
assert_bool(_player.shows_playing()).is_true()
assert_object(_player.play_button.icon).is_same(_player.pause_icon)
func test_pausing_shows_play_again() -> void:
await _runner.simulate_frames(1)
_player.play_song(_song())
await _click(_player.play_button)
assert_bool(_player.audio_player.stream_paused).is_true()
assert_bool(_player.shows_playing()).is_false()
func test_play_button_pops_when_it_changes() -> void:
await _runner.simulate_frames(1)
_player.play_song(_song())
assert_vector(_player.play_button.scale).is_equal(ButtonBounce.POP_SCALE)
await _settle()
assert_vector(_player.play_button.scale).is_equal_approx(Vector2.ONE, Vector2(0.01, 0.01))
func test_play_button_does_not_pop_when_it_stays_the_same() -> void:
_player.play_song(_song())
await _settle()
_player.play_song(_song())
assert_vector(_player.play_button.scale).is_equal_approx(Vector2.ONE, Vector2(0.01, 0.01))
# Hover
func test_buttons_grow_under_the_mouse_and_shrink_back() -> void:
await _runner.simulate_frames(1)
for button: Button in _buttons():
_runner.set_mouse_position(_player.get_viewport().get_final_transform()
* button.get_global_rect().get_center())
await _runner.simulate_frames(2)
await _settle()
assert_vector(button.scale).override_failure_message(button.name) \
.is_equal_approx(ButtonBounce.HOVER_SCALE, Vector2(0.01, 0.01))
_runner.set_mouse_position(Vector2.ZERO)
await _runner.simulate_frames(2)
await _settle()
assert_vector(button.scale).override_failure_message(button.name) \
.is_equal_approx(Vector2.ONE, Vector2(0.01, 0.01))
func test_growing_keeps_the_button_in_place() -> void:
await _runner.simulate_frames(1)
var before: Vector2 = _player.restart_button.get_global_rect().get_center()
_player.restart_button.mouse_entered.emit()
await _settle()
assert_vector(_player.restart_button.get_global_rect().get_center()) \
.is_equal_approx(before, Vector2(0.5, 0.5))
+2
View File
@@ -124,6 +124,8 @@ func test_server_features_are_off_and_the_folder_is_shown() -> void:
assert_str(_top_bar.offline_label.text).is_equal("Offline: Music") assert_str(_top_bar.offline_label.text).is_equal("Offline: Music")
assert_bool(_settings.select_server_button.disabled).is_true() assert_bool(_settings.select_server_button.disabled).is_true()
assert_bool(_settings.add_to_database_button.disabled).is_true() assert_bool(_settings.add_to_database_button.disabled).is_true()
# The config folder holds the cached characters, which are still used offline
assert_bool(_settings.open_config_folder_button.disabled).is_false()
func test_picking_a_folder_on_the_statistics_tab_goes_to_the_quiz() -> void: func test_picking_a_folder_on_the_statistics_tab_goes_to_the_quiz() -> void:
+12
View File
@@ -313,3 +313,15 @@ func test_alt_s_opens_search() -> void:
await _press_keys([KEY_ALT, KEY_S]) await _press_keys([KEY_ALT, KEY_S])
assert_bool((_window.get_node("SearchPopup") as Control).visible).is_true() assert_bool((_window.get_node("SearchPopup") as Control).visible).is_true()
func test_music_player_fits_between_the_song_buttons_and_quiz_settings() -> void:
var bar: Rect2 = _quiz.get_node("MusicPlayer/PlayerBar").get_global_rect()
var above: Rect2 = _quiz.get_node("SongControls/NextButton").get_global_rect()
var below: Rect2 = _quiz.get_node("QuizSettingsButton").get_global_rect()
assert_float(bar.position.y).is_greater_equal(above.end.y + 4)
assert_float(bar.end.y).is_less_equal(below.position.y - 2)
# As wide as the music list above it
var list: Rect2 = _quiz.get_node("MusicListPanel").get_global_rect()
assert_float(bar.position.x).is_equal(list.position.x)
+13
View File
@@ -53,3 +53,16 @@ func test_popup_fits_its_rows_and_is_centred() -> void:
# The rows' own height fits, so the last toggle is not squeezed or cut off # The rows' own height fits, so the last toggle is not squeezed or cut off
assert_float(rows.get_combined_minimum_size().y).is_less_equal(rows.size.y) assert_float(rows.get_combined_minimum_size().y).is_less_equal(rows.size.y)
assert_vector(panel.get_center()).is_equal_approx(Vector2(960, 540), Vector2(1, 1)) assert_vector(panel.get_center()).is_equal_approx(Vector2(960, 540), Vector2(1, 1))
func test_open_folder_button_shows_the_config_folder() -> void:
var shown: Array[String] = []
_popup.show_folder = func(dir: String) -> void: shown.append(dir)
_popup.open()
await _runner.simulate_frames(1)
await _click_at(_popup.open_config_folder_button.get_global_rect().get_center())
assert_array(shown).contains_exactly([Settings.config_dir()])
assert_bool(DirAccess.dir_exists_absolute(Settings.config_dir())).is_true()
+2 -2
View File
@@ -58,8 +58,8 @@ func test_buttons_do_what_the_list_says() -> void:
var expected: Dictionary = { var expected: Dictionary = {
"SongControls/NextButton": "Next song", "SongControls/NextButton": "Next song",
"SongControls/ShowAnswerButton": "Show answer", "SongControls/ShowAnswerButton": "Show answer",
"MusicPlayer/PlayerBar/MusicPlayerContainer/PlayTextureButton": "Play or pause", "MusicPlayer/PlayerBar/MusicPlayerContainer/PlayButton": "Play or pause",
"MusicPlayer/PlayerBar/MusicPlayerContainer/RestartTextureButton": "Restart song", "MusicPlayer/PlayerBar/MusicPlayerContainer/RestartButton": "Restart song",
} }
for path: String in expected: for path: String in expected:
var button: BaseButton = _quiz.get_node(path) var button: BaseButton = _quiz.get_node(path)
+365
View File
@@ -0,0 +1,365 @@
extends GdUnitTestSuite
## How a song in the music list is laid out: the players' pictures on the right in even
## lines of at most four, each ending at the row's right edge, the text in the room they leave, wrapping only when it does
## not fit, and the row as tall as the taller of the two. Checked for every number of
## players, with short, debug mode and very long song texts.
const SHORT: Array[String] = ["Chrono Trigger", "Corridors of Time"]
const LONG: Array[String] = [
"Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv)",
"'Crystal Cave -arrange-' from FINAL FANTASY III",
]
## The row's padding, from the SongRow styles in the theme.
const PADDING: float = 8.0
var _snapshot: AutoloadSnapshot
var _runner: GdUnitSceneRunner
var _panel: MusicListPanel
# The panel's width in the quiz, which songs must never make wider
var _panel_width: float
func before_test() -> void:
_snapshot = AutoloadSnapshot.take([Settings, Playlist])
Settings.is_debug = false
Playlist.reset_playlist()
Settings.player_array.clear()
for i: int in Settings.MAX_NUMBER_OF_PLAYERS:
Settings.player_array.append(PlayerObject.new("Player %d" % (i + 1)))
_runner = scene_runner("res://quiz/MusicListPanel.tscn")
_panel = _runner.scene()
_panel_width = _panel.size.x
func after_test() -> void:
await await_idle_frame()
_snapshot.restore()
# Adds a played and answered song with points for the first [param players] players
func _add_song(title: Array[String], players: int) -> SongObject:
var song: SongObject = SongObject.new()
song.song_number = Playlist.song_object_array.size()
song.soundtrack_title = title[0]
song.song_title = title[1]
song.has_played = true
song.is_answered = true
for i: int in players:
song.add_point(Settings.player_array[i].id)
Playlist.add_to_playlist(song)
return song
func _show() -> void:
_panel.refresh()
await _runner.simulate_frames(3)
func _rows() -> Array[SongRow]:
var rows: Array[SongRow] = []
rows.assign(_panel.get_node("%MusicList").get_children())
return rows
func _pictures(row: SongRow) -> Array[TextureRect]:
return row.pictures()
# How wide the row's text would be on one line
func _text_width(row: SongRow) -> float:
var label: Label = row.song_list_label
return label.get_theme_font(&"font").get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT,
-1, label.get_theme_font_size(&"font_size")).x
func _one_line_height() -> float:
_add_song(SHORT, 0)
await _show()
var height: float = _rows()[0].size.y
Playlist.reset_playlist()
return height
# Every number of players with every kind of text, in normal and debug mode
func _each_layout(check: Callable) -> void:
for debug: bool in [false, true]:
Settings.is_debug = debug
for title: Array[String] in [SHORT, LONG]:
Playlist.reset_playlist()
for players: int in Settings.MAX_NUMBER_OF_PLAYERS + 1:
_add_song(title, players)
await _show()
assert_float(_panel.size.x).override_failure_message(
"The songs made the list wider than its place in the quiz") \
.is_equal(_panel_width)
for row: SongRow in _rows():
check.call(row, "%d players, %s text%s" % [_pictures(row).size(),
"long" if title == LONG else "short", " (debug)" if debug else ""])
# Columns
func test_pictures_are_spread_evenly_over_as_few_lines_as_possible() -> void:
var columns: Array[int] = []
for pictures: int in range(0, 9):
columns.append(SongRow.picture_columns(pictures))
# 5 pictures are 3 + 2, not 4 + 1; 7 are 4 + 3
assert_array(columns).contains_exactly([1, 1, 2, 3, 4, 3, 3, 4, 4])
# Placement
func test_pictures_are_at_the_right_end_of_the_row() -> void:
await _each_layout(func(row: SongRow, layout: String) -> void:
if _pictures(row).is_empty():
assert_bool(row.song_list_players.visible).override_failure_message(layout).is_false()
return
var pictures: Rect2 = row.song_list_players.get_global_rect()
assert_float(pictures.end.x).override_failure_message(layout) \
.is_equal_approx(row.get_global_rect().end.x - PADDING, 1.0))
func test_every_line_of_pictures_ends_at_the_right_edge() -> void:
await _each_layout(func(row: SongRow, layout: String) -> void:
var right_edge: float = row.get_global_rect().end.x - PADDING
for line: HBoxContainer in row.song_list_players.get_children():
var last: TextureRect = line.get_child(line.get_child_count() - 1)
assert_float(last.get_global_rect().end.x).override_failure_message(layout) \
.is_equal_approx(right_edge, 1.0))
func test_text_and_pictures_never_overlap_and_stay_inside_the_row() -> void:
await _each_layout(func(row: SongRow, layout: String) -> void:
var row_rect: Rect2 = row.get_global_rect().grow(0.5)
var text: Rect2 = row.song_list_label.get_global_rect()
assert_bool(row_rect.encloses(text)).override_failure_message(
"Text outside the row: " + layout).is_true()
for picture: TextureRect in _pictures(row):
var rect: Rect2 = picture.get_global_rect()
assert_bool(row_rect.encloses(rect)).override_failure_message(
"Picture outside the row: " + layout).is_true()
assert_float(rect.position.x).override_failure_message(
"Picture over the text: " + layout).is_greater_equal(text.end.x))
func test_text_gets_all_the_room_the_pictures_leave() -> void:
await _each_layout(func(row: SongRow, layout: String) -> void:
var text: Rect2 = row.song_list_label.get_global_rect()
assert_float(text.position.x).override_failure_message(layout) \
.is_equal_approx(row.get_global_rect().position.x + PADDING, 1.0)
var right_end: float = row.get_global_rect().end.x - PADDING
if row.song_list_players.visible:
right_end = row.song_list_players.get_global_rect().position.x \
- row.get_node("HBoxContainer").get_theme_constant(&"separation")
assert_float(text.end.x).override_failure_message(layout) \
.is_equal_approx(right_end, 1.0))
func test_text_wraps_only_when_it_does_not_fit_on_one_line() -> void:
await _each_layout(func(row: SongRow, layout: String) -> void:
var lines: int = row.song_list_label.get_line_count()
var room: float = row.song_list_label.size.x
if _text_width(row) <= room - 1:
assert_int(lines).override_failure_message("Wrapped though it fits: " + layout) \
.is_equal(1)
elif _text_width(row) > room + 1:
assert_int(lines).override_failure_message("Not wrapped: " + layout) \
.is_greater(1))
func test_every_layout_holds_in_a_narrower_list() -> void:
_panel.size.x = 640
_panel_width = 640
await _runner.simulate_frames(1)
await _each_layout(func(row: SongRow, layout: String) -> void:
var text: Rect2 = row.song_list_label.get_global_rect()
assert_bool(row.get_global_rect().grow(0.5).encloses(text)).override_failure_message(
"Text outside the row: " + layout).is_true()
if row.song_list_players.visible:
assert_float(row.song_list_players.get_global_rect().position.x) \
.override_failure_message("Pictures over the text: " + layout) \
.is_greater_equal(text.end.x))
func test_short_text_is_one_line_even_with_every_player() -> void:
_add_song(SHORT, Settings.MAX_NUMBER_OF_PLAYERS)
await _show()
assert_int(_rows()[0].song_list_label.get_line_count()).is_equal(1)
func test_long_text_wraps_and_keeps_every_word() -> void:
_add_song(LONG, 2)
await _show()
var label: Label = _rows()[0].song_list_label
assert_int(label.get_line_count()).is_greater(1)
assert_str(label.text).ends_with("FINAL FANTASY III")
assert_int(label.get_visible_line_count()).is_equal(label.get_line_count())
# Height
func test_row_is_one_line_high_with_up_to_four_pictures() -> void:
var one_line: float = await _one_line_height()
for players: int in range(0, SongRow.MAX_PICTURES_PER_LINE + 1):
_add_song(SHORT, players)
await _show()
for row: SongRow in _rows():
assert_float(row.size.y).override_failure_message(
"%d pictures" % _pictures(row).size()).is_equal_approx(one_line, 0.5)
func test_five_pictures_go_on_two_lines_of_three_and_two() -> void:
_add_song(SHORT, 5)
await _show()
assert_array(_lines_of_pictures(_rows()[0])).contains_exactly([3, 2])
func test_seven_pictures_go_on_two_lines_of_four_and_three() -> void:
_add_song(SHORT, 7)
await _show()
assert_array(_lines_of_pictures(_rows()[0])).contains_exactly([4, 3])
# How many pictures are on each line, top to bottom
func _lines_of_pictures(row: SongRow) -> Array[int]:
var by_top: Dictionary[float, int] = {}
for picture: TextureRect in _pictures(row):
var top: float = roundf(picture.global_position.y)
by_top[top] = by_top.get(top, 0) + 1
var tops: Array[float] = []
tops.assign(by_top.keys())
tops.sort()
var lines: Array[int] = []
for top: float in tops:
lines.append(by_top[top])
return lines
func test_row_grows_to_fit_two_lines_of_pictures_and_centres_the_text() -> void:
var one_line: float = await _one_line_height()
_add_song(SHORT, 5)
await _show()
var row: SongRow = _rows()[0]
var pictures: Rect2 = row.song_list_players.get_global_rect()
assert_float(row.size.y).is_greater(one_line)
assert_float(pictures.size.y).is_equal_approx(
SongRow.PICTURE_SIZE.y * 2 + row.song_list_players.get_theme_constant(
&"separation"), 0.5)
assert_bool(row.get_global_rect().encloses(pictures)).is_true()
# The single line of text is in the middle, beside the pictures
assert_int(row.song_list_label.vertical_alignment).is_equal(VERTICAL_ALIGNMENT_CENTER)
assert_float(row.song_list_label.get_global_rect().get_center().y) \
.is_equal_approx(pictures.get_center().y, 1.0)
func test_pictures_are_centred_beside_text_on_many_lines() -> void:
_add_song(LONG, 1)
await _show()
var row: SongRow = _rows()[0]
var text: Rect2 = row.song_list_label.get_global_rect()
var pictures: Rect2 = row.song_list_players.get_global_rect()
assert_int(row.song_list_label.get_line_count()).is_greater(1)
assert_float(pictures.size.y).is_less(text.size.y)
assert_float(pictures.get_center().y).is_equal_approx(text.get_center().y, 1.0)
func test_every_picture_has_the_same_size() -> void:
await _each_layout(func(row: SongRow, layout: String) -> void:
for picture: TextureRect in _pictures(row):
assert_vector(picture.size).override_failure_message(layout) \
.is_equal(SongRow.PICTURE_SIZE))
func test_rows_never_need_sideways_scrolling() -> void:
Settings.is_debug = true
for players: int in Settings.MAX_NUMBER_OF_PLAYERS + 1:
_add_song(LONG, players)
await _show()
var scroll: ScrollContainer = _panel.get_node("ScrollContainer")
assert_bool(scroll.get_h_scroll_bar().visible).is_false()
assert_float(_panel.size.x).is_equal(_panel_width)
for row: SongRow in _rows():
assert_float(row.get_global_rect().end.x) \
.is_less_equal(scroll.get_global_rect().end.x + 0.5)
# Pictures
func test_pictures_show_who_got_a_point_in_player_list_order() -> void:
var song: SongObject = _add_song(SHORT, 0)
# Points given in another order than the player list
song.add_point(Settings.player_array[2].id)
song.add_point(Settings.player_array[0].id)
await _show()
var names: Array[String] = []
for picture: TextureRect in _pictures(_rows()[0]):
names.append(picture.tooltip_text)
assert_array(names).contains_exactly(["Player 1", "Player 3"])
assert_object(_pictures(_rows()[0])[0].texture).is_same(Settings.player_array[0].character)
func test_pictures_are_redone_when_points_change() -> void:
var song: SongObject = _add_song(SHORT, 4)
await _show()
song.add_point(Settings.player_array[4].id)
_rows()[0].update_players()
await _runner.simulate_frames(2)
assert_array(_lines_of_pictures(_rows()[0])).contains_exactly([3, 2])
func test_clicking_a_picture_clicks_the_song() -> void:
_add_song(SHORT, 0)
_add_song(SHORT, 3)
await _show()
var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel))
var picture: TextureRect = _pictures(_rows()[1])[1]
_runner.set_mouse_position(
_panel.get_viewport().get_final_transform() * picture.get_global_rect().get_center())
await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_LEFT)
await _runner.await_input_processed()
await signals.is_emitted("song_clicked", [1])
# Styles
func test_rows_alternate_and_the_playing_song_stands_out() -> void:
for i: int in 4:
_add_song(SHORT, 0)
Playlist.song_object_array[2].is_playing = true
await _show()
var styles: Array[StringName] = []
for row: SongRow in _rows():
styles.append(row.theme_type_variation)
assert_array(styles).contains_exactly(
[&"SongRow", &"SongRowOdd", &"SongRowPlaying", &"SongRowOdd"])
assert_str(_rows()[2].song_list_label.theme_type_variation).is_equal("SongRowLabelPlaying")
assert_str(_rows()[1].song_list_label.theme_type_variation).is_equal("SongRowLabel")
func test_hovering_a_row_highlights_it_until_the_mouse_leaves() -> void:
_add_song(SHORT, 0)
_add_song(SHORT, 0)
await _show()
var row: SongRow = _rows()[1]
row.mouse_entered.emit()
assert_str(row.theme_type_variation).is_equal("SongRowHover")
row.mouse_exited.emit()
assert_str(row.theme_type_variation).is_equal("SongRowOdd")
+1
View File
@@ -0,0 +1 @@
uid://dnggvrk5thb2v
+225 -7
View File
@@ -1,6 +1,7 @@
extends MainWindowTestSuite extends MainWindowTestSuite
## The Statistics tab: switching between it and the quiz, the soundtrack cards, sorting, ## The Statistics tab: switching between it and the quiz, the soundtrack cards, the list
## searching, the show filter and the popup with all songs of a soundtrack. ## of songs, sorting, searching, the show filter and the popup with all songs of a
## soundtrack.
const STATISTICS: Array[Dictionary] = [ const STATISTICS: Array[Dictionary] = [
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", "soundtrack_played": 5, {"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", "soundtrack_played": 5,
@@ -12,6 +13,7 @@ const STATISTICS: Array[Dictionary] = [
{"song_name": "To Far Away Times", "times_played": 0}, {"song_name": "To Far Away Times", "times_played": 0},
{"song_name": "Schala's Theme", "times_played": 4}, {"song_name": "Schala's Theme", "times_played": 4},
{"song_name": "Peaceful Days", "times_played": 1}, {"song_name": "Peaceful Days", "times_played": 1},
{"song_name": "Zeal Palace", "times_played": 1},
]}, ]},
{"soundtrack_uuid": "st-2", "soundtrack_name": "Terranigma", "soundtrack_played": 0, {"soundtrack_uuid": "st-2", "soundtrack_name": "Terranigma", "soundtrack_played": 0,
"songs": [{"song_name": "Underworld", "times_played": 0}]}, "songs": [{"song_name": "Underworld", "times_played": 0}]},
@@ -151,20 +153,48 @@ func test_card_shows_plays_last_played_and_most_and_least_played_songs() -> void
assert_str(card.last_played_label.text).is_equal("Last played 2026-09-30") assert_str(card.last_played_label.text).is_equal("Last played 2026-09-30")
assert_array(_visible_texts(card.most_played_list)).contains_exactly( assert_array(_visible_texts(card.most_played_list)).contains_exactly(
["4 × Schala's Theme", "3 × Frog's Theme", "2 × Battle with Magus"]) ["4 × Schala's Theme", "3 × Frog's Theme", "2 × Battle with Magus"])
# Songs never played are left out, so the least played are the least played of those
assert_array(_visible_texts(card.least_played_list)).contains_exactly( assert_array(_visible_texts(card.least_played_list)).contains_exactly(
["0 × To Far Away Times", "0 × Wind Scene", "1 × Corridors of Time"]) ["1 × Corridors of Time", "1 × Peaceful Days", "1 × Zeal Palace"])
assert_bool(card.least_played_header.visible).is_true()
func test_card_of_never_played_soundtrack() -> void: func test_card_of_never_played_soundtrack_lists_no_songs() -> void:
await _open_statistics() await _open_statistics()
var card: SoundtrackCard = _card("Terranigma") var card: SoundtrackCard = _card("Terranigma")
assert_str(card.played_label.text).is_equal("Never played") assert_str(card.played_label.text).is_equal("Never played")
assert_bool(card.last_played_label.visible).is_false() assert_bool(card.last_played_label.visible).is_false()
assert_array(_visible_texts(card.most_played_list)).contains_exactly(["0 × Underworld"]) assert_str(card.most_played_header.text).is_equal("No songs played yet")
assert_array(_visible_texts(card.most_played_list)).is_empty()
assert_bool(card.least_played_header.visible).is_false() assert_bool(card.least_played_header.visible).is_false()
func test_card_with_one_played_song_lists_only_it() -> void:
await _open_statistics()
var card: SoundtrackCard = _card("Final Fantasy VI")
assert_str(card.most_played_header.text).is_equal("Most played")
assert_array(_visible_texts(card.most_played_list)).contains_exactly(["1 × Dancing Mad"])
assert_bool(card.least_played_header.visible).is_false()
assert_array(_visible_texts(card.least_played_list)).is_empty()
func test_card_with_fewer_than_six_played_songs_lists_them_all_as_most_played() -> void:
var songs: Array[Dictionary] = [{"song_name": "Never", "times_played": 0}]
for i: int in 5:
songs.append({"song_name": "Song %d" % (i + 1), "times_played": i + 1})
_server.respond_json("/statistics/soundtracks", [
{"soundtrack_name": "Lufia II", "soundtrack_played": 15, "songs": songs}])
await _open_statistics()
var card: SoundtrackCard = _card("Lufia II")
assert_array(_visible_texts(card.most_played_list)).contains_exactly(
["5 × Song 5", "4 × Song 4", "3 × Song 3", "2 × Song 2", "1 × Song 1"])
assert_bool(card.least_played_header.visible).is_false()
assert_array(_visible_texts(card.least_played_list)).is_empty()
func test_card_without_songs() -> void: func test_card_without_songs() -> void:
await _open_statistics() await _open_statistics()
var card: SoundtrackCard = _card("earthbound") var card: SoundtrackCard = _card("earthbound")
@@ -289,6 +319,193 @@ func test_filters_stay_when_statistics_are_loaded_again() -> void:
assert_str(_view.cards[0].soundtrack.soundtrack_name).is_equal("Terranigma") assert_str(_view.cards[0].soundtrack.soundtrack_name).is_equal("Terranigma")
# Songs
func _show_songs_mode() -> void:
await _open_statistics()
await _click_at(_center(_view.songs_mode_button))
await _runner.simulate_frames(1)
func _listed_songs() -> Array[String]:
var listed: Array[String] = []
for song: SongInfoForStats in _view.song_list.songs:
listed.append(song.song_name)
return listed
func _search(text: String) -> void:
_view.search_field.text = text
_view.apply_filter()
func test_songs_button_shows_every_played_song_a_to_z_instead_of_the_cards() -> void:
await _show_songs_mode()
assert_bool(_view.song_list.visible).is_true()
assert_bool(_view.card_scroll.visible).is_false()
assert_bool(_view.songs_mode_button.button_pressed).is_true()
assert_bool(_view.soundtracks_mode_button.button_pressed).is_false()
# Songs never played are left out
assert_array(_listed_songs()).contains_exactly([
"Battle with Magus", "Corridors of Time", "Dancing Mad", "Frog's Theme",
"Peaceful Days", "Schala's Theme", "Zeal Palace",
])
assert_str(_view.count_label.text).is_equal("7 played songs")
assert_str(_view.search_field.placeholder_text).is_equal("Search songs and soundtracks")
func test_song_rows_show_the_song_its_soundtrack_and_plays() -> void:
await _show_songs_mode()
var row: SongStatsRow = _view.song_list.shown_rows()[0]
assert_str(row.song_label.get_parsed_text()).is_equal("Battle with Magus")
assert_str(row.soundtrack_label.get_parsed_text()).is_equal("Chrono Trigger")
assert_str(row.played_label.text).is_equal("2")
assert_int(_view.song_list.shown_rows().size()).is_equal(7)
func test_soundtracks_button_shows_the_cards_again() -> void:
await _show_songs_mode()
await _click_at(_center(_view.soundtracks_mode_button))
assert_bool(_view.card_scroll.visible).is_true()
assert_bool(_view.song_list.visible).is_false()
assert_str(_view.count_label.text).is_equal("4 soundtracks")
assert_bool(_view.show_option.visible).is_true()
func test_song_search_finds_song_names_and_soundtrack_names() -> void:
await _show_songs_mode()
_search("magus")
assert_array(_listed_songs()).contains_exactly(["Battle with Magus"])
assert_str(_view.count_label.text).is_equal("1 of 7 played songs")
# Every song of a soundtrack whose name matches
_search("final fantasy")
assert_array(_listed_songs()).contains_exactly(["Dancing Mad"])
func test_song_search_highlights_what_matched() -> void:
await _show_songs_mode()
_search("magus")
var row: SongStatsRow = _view.song_list.shown_rows()[0]
assert_str(row.song_label.text).contains("[color=")
assert_str(row.song_label.get_parsed_text()).is_equal("Battle with Magus")
assert_str(row.soundtrack_label.text).not_contains("[color=")
func test_typing_a_song_search_waits_for_a_pause() -> void:
await _show_songs_mode()
_view.search_field.text = "magus"
_view.search_field.text_changed.emit("magus")
assert_int(_view.song_list.songs.size()).is_equal(7)
await await_millis(300)
assert_array(_listed_songs()).contains_exactly(["Battle with Magus"])
func test_show_menu_is_hidden_for_songs() -> void:
await _show_songs_mode()
assert_bool(_view.show_option.visible).is_false()
assert_bool(_view.get_node("Toolbar/ShowLabel").visible).is_false()
func test_song_sort_orders() -> void:
await _show_songs_mode()
var expected: Dictionary = {
StatisticsView.SortOrder.MOST_PLAYED: ["Schala's Theme", "Frog's Theme",
"Battle with Magus", "Corridors of Time", "Dancing Mad", "Peaceful Days",
"Zeal Palace"],
StatisticsView.SortOrder.LEAST_PLAYED: ["Corridors of Time", "Dancing Mad",
"Peaceful Days", "Zeal Palace", "Battle with Magus", "Frog's Theme",
"Schala's Theme"],
StatisticsView.SortOrder.Z_TO_A: ["Zeal Palace", "Schala's Theme", "Peaceful Days",
"Frog's Theme", "Dancing Mad", "Corridors of Time", "Battle with Magus"],
}
for order: StatisticsView.SortOrder in expected:
_view.sort_option.select(order)
_view.sort_option.item_selected.emit(order)
assert_array(_listed_songs()).contains_exactly(expected[order])
func test_songs_with_the_same_name_are_sorted_by_soundtrack() -> void:
_server.respond_json("/statistics/soundtracks", [
{"soundtrack_name": "Lufia II",
"songs": [{"song_name": "Main Theme", "times_played": 1}]},
{"soundtrack_name": "Earthbound",
"songs": [{"song_name": "Main Theme", "times_played": 1}]},
])
await _show_songs_mode()
var soundtracks: Array[String] = []
for song: SongInfoForStats in _view.song_list.songs:
soundtracks.append(song.soundtrack_name)
assert_array(soundtracks).contains_exactly(["Earthbound", "Lufia II"])
func test_clicking_a_song_shows_its_soundtrack() -> void:
await _show_songs_mode()
var popup: SoundtrackStatsPopup = _window.get_node("SoundtrackStatsPopup")
_search("dancing")
await _click_at(_center(_view.song_list.shown_rows()[0]))
assert_bool(popup.visible).is_true()
assert_str(popup.title_label.text).is_equal("Final Fantasy VI")
func test_songs_stay_shown_when_statistics_are_loaded_again() -> void:
await _show_songs_mode()
_server.respond_json("/statistics/soundtracks", STATISTICS.slice(3, 4))
_view.refresh()
assert_bool(_view.song_list.visible).is_true()
assert_array(_listed_songs()).contains_exactly(["Dancing Mad"])
func test_only_the_rows_on_screen_are_made_for_many_songs() -> void:
var songs: Array[Dictionary] = []
for i: int in 5000:
songs.append({"song_name": "Song %04d" % i, "times_played": i % 7 + 1})
_server.respond_json("/statistics/soundtracks",
[{"soundtrack_name": "Big", "songs": songs}])
await _show_songs_mode()
var rows: int = _view.song_list.rows_area.get_child_count()
assert_int(rows).is_less(60)
assert_str(_view.count_label.text).is_equal("5000 played songs")
_view.song_list.scroll_to(2500)
await _runner.simulate_frames(1)
assert_str(_view.song_list.shown_rows()[0].song.song_name).is_equal("Song 2500")
assert_int(_view.song_list.rows_area.get_child_count()).is_equal(rows)
func test_mouse_wheel_scrolls_the_songs() -> void:
var songs: Array[Dictionary] = []
for i: int in 200:
songs.append({"song_name": "Song %03d" % i, "times_played": 1})
_server.respond_json("/statistics/soundtracks",
[{"soundtrack_name": "Big", "songs": songs}])
await _show_songs_mode()
var transform: Transform2D = _window.get_viewport().get_final_transform()
_runner.set_mouse_position(transform * _center(_view.song_list.rows_area))
await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_WHEEL_DOWN)
await _runner.await_input_processed()
assert_str(_view.song_list.shown_rows()[0].song.song_name).is_equal("Song 003")
# Popup # Popup
func test_clicking_a_card_shows_all_its_songs_most_played_first() -> void: func test_clicking_a_card_shows_all_its_songs_most_played_first() -> void:
@@ -300,13 +517,14 @@ func test_clicking_a_card_shows_all_its_songs_most_played_first() -> void:
assert_bool(popup.visible).is_true() assert_bool(popup.visible).is_true()
assert_str(popup.title_label.text).is_equal("Chrono Trigger") assert_str(popup.title_label.text).is_equal("Chrono Trigger")
assert_str(popup.summary_label.text) \ assert_str(popup.summary_label.text) \
.is_equal("Played 5 times · Last played 2026-09-30 · 7 songs") .is_equal("Played 5 times · Last played 2026-09-30 · 8 songs")
var rows: Array[String] = [] var rows: Array[String] = []
for row: ListRow in popup.song_list.get_children(): for row: ListRow in popup.song_list.get_children():
rows.append(row.text) rows.append(row.text)
assert_array(rows).contains_exactly([ assert_array(rows).contains_exactly([
"4 × Schala's Theme", "3 × Frog's Theme", "2 × Battle with Magus", "4 × Schala's Theme", "3 × Frog's Theme", "2 × Battle with Magus",
"1 × Corridors of Time", "1 × Peaceful Days", "0 × To Far Away Times", "0 × Wind Scene", "1 × Corridors of Time", "1 × Peaceful Days", "1 × Zeal Palace",
"0 × To Far Away Times", "0 × Wind Scene",
]) ])
+15
View File
@@ -32,6 +32,17 @@ const STYLES: Dictionary = {
"HighlightMedium": "Label", "HighlightMedium": "Label",
"SongRowLabel": "Label", "SongRowLabel": "Label",
"SongRowLabelPlaying": "Label", "SongRowLabelPlaying": "Label",
"SongRowOdd": "PanelContainer",
"SongRowHover": "PanelContainer",
"SongRowPlaying": "PanelContainer",
"SongRowPictureLine": "HBoxContainer",
"SongRowPictureLines": "VBoxContainer",
"PlayerBar": "PanelContainer",
"PlayerButton": "Button",
"PlayerButtonPrimary": "Button",
"PlayerTime": "Label",
"ProgressSlider": "HSlider",
"VolumeBar": "HSlider",
"StatisticsCard": "PanelContainer", "StatisticsCard": "PanelContainer",
"StatisticsCardHover": "PanelContainer", "StatisticsCardHover": "PanelContainer",
"StatisticsGrid": "GridContainer", "StatisticsGrid": "GridContainer",
@@ -39,6 +50,10 @@ const STYLES: Dictionary = {
"LargeButton": "Button", "LargeButton": "Button",
"SearchField": "LineEdit", "SearchField": "LineEdit",
"SearchResult": "RichTextLabel", "SearchResult": "RichTextLabel",
"ListRowHover": "PanelContainer",
"SongStatsHeader": "PanelContainer",
"SongStatsText": "RichTextLabel",
"SongStatsSecondary": "RichTextLabel",
} }
## Theme types that only scripts read from, so they are not styles a node can pick. ## Theme types that only scripts read from, so they are not styles a node can pick.
+80
View File
@@ -0,0 +1,80 @@
extends GdUnitTestSuite
## ButtonBounce: buttons grow under the mouse, shrink while held down, pop when asked to,
## and always around their middle.
var _button: Button
func before_test() -> void:
_button = auto_free(Button.new())
_button.size = Vector2(40, 40)
add_child(_button)
ButtonBounce.add_to(_button)
func _settle() -> void:
await await_millis(int(maxf(ButtonBounce.DURATION, ButtonBounce.POP_DURATION) * 1000) + 80)
func test_starts_at_its_normal_size_scaled_around_its_middle() -> void:
assert_vector(_button.scale).is_equal(Vector2.ONE)
assert_vector(_button.pivot_offset).is_equal(Vector2(20, 20))
func test_pivot_follows_the_size() -> void:
_button.size = Vector2(60, 30)
assert_vector(_button.pivot_offset).is_equal(Vector2(30, 15))
func test_grows_when_the_mouse_enters_and_shrinks_back_when_it_leaves() -> void:
_button.mouse_entered.emit()
await _settle()
assert_vector(_button.scale).is_equal_approx(ButtonBounce.HOVER_SCALE, Vector2(0.01, 0.01))
_button.mouse_exited.emit()
await _settle()
assert_vector(_button.scale).is_equal_approx(Vector2.ONE, Vector2(0.01, 0.01))
func test_shrinks_while_held_down() -> void:
_button.button_down.emit()
await _settle()
assert_vector(_button.scale).is_equal_approx(ButtonBounce.PRESSED_SCALE, Vector2(0.01, 0.01))
func test_goes_back_to_normal_when_let_go_away_from_the_mouse() -> void:
_button.button_down.emit()
await _settle()
_button.button_up.emit()
await _settle()
assert_vector(_button.scale).is_equal_approx(Vector2.ONE, Vector2(0.01, 0.01))
func test_disabled_button_does_not_grow() -> void:
_button.disabled = true
_button.mouse_entered.emit()
await _settle()
assert_vector(_button.scale).is_equal(Vector2.ONE)
func test_pop_starts_small_and_springs_back() -> void:
ButtonBounce.pop(_button)
assert_vector(_button.scale).is_equal(ButtonBounce.POP_SCALE)
await _settle()
assert_vector(_button.scale).is_equal_approx(Vector2.ONE, Vector2(0.01, 0.01))
func test_a_new_animation_stops_the_one_before() -> void:
_button.mouse_entered.emit()
_button.button_down.emit()
await _settle()
# Only the last one ran to the end
assert_vector(_button.scale).is_equal_approx(ButtonBounce.PRESSED_SCALE, Vector2(0.01, 0.01))
+1
View File
@@ -0,0 +1 @@
uid://b8tyllgau53y6
+11 -2
View File
@@ -35,9 +35,12 @@ inspector or `theme_type_variation` in code:
| `DarkSpacer` | Panel | The dark gaps in the player list and the add player row | | `DarkSpacer` | Panel | The dark gaps in the player list and the add player row |
| `CharacterCard` | Panel | A character in the character select popup | | `CharacterCard` | Panel | A character in the character select popup |
| `MusicList` | PanelContainer | The list of songs in the quiz | | `MusicList` | PanelContainer | The list of songs in the quiz |
| `SongRow` | PanelContainer | One song in that list | | `SongRow`, `SongRowOdd` | PanelContainer | Alternating songs in that list |
| `SongRowHover`, `SongRowPlaying` | PanelContainer | The song under the mouse, and the one that is playing |
| `SongRowPictureLines`, `SongRowPictureLine` | VBoxContainer, HBoxContainer | The space between the lines of player pictures on a song, and between the pictures |
| `ShortcutRowDark` | PanelContainer | Every other row in the shortcuts popup | | `ShortcutRowDark` | PanelContainer | Every other row in the shortcuts popup |
| `ListRowEven`, `ListRowOdd` | PanelContainer | Alternating rows in the inspiration and sync lists | | `ListRowEven`, `ListRowOdd` | PanelContainer | Alternating rows in the inspiration, sync and statistics song lists |
| `ListRowHover` | PanelContainer | A row in the statistics song list under the mouse |
| `TitleLarge` | Label | The soundtrack that is playing (50) | | `TitleLarge` | Label | The soundtrack that is playing (50) |
| `TitleMedium` | Label | The song that is playing, and the winner (35) | | `TitleMedium` | Label | The song that is playing, and the winner (35) |
| `Title` | Label | The window title and popup titles (25) | | `Title` | Label | The window title and popup titles (25) |
@@ -48,6 +51,12 @@ inspector or `theme_type_variation` in code:
| `TabButton` | Button | The Quiz and Statistics tabs; the chosen one is orange | | `TabButton` | Button | The Quiz and Statistics tabs; the chosen one is orange |
| `StatisticsCard`, `StatisticsCardHover` | PanelContainer | A soundtrack card in the statistics, and the one under the mouse | | `StatisticsCard`, `StatisticsCardHover` | PanelContainer | A soundtrack card in the statistics, and the one under the mouse |
| `StatisticsGrid` | GridContainer | The grid of soundtrack cards, with the space between them | | `StatisticsGrid` | GridContainer | The grid of soundtrack cards, with the space between them |
| `PlayerBar` | PanelContainer | The music player's rounded bar |
| `PlayerButtonPrimary`, `PlayerButton` | Button | The round play button, and the restart button; the icon's colour on hover comes from here |
| `ProgressSlider`, `VolumeBar` | HSlider | How far the song has played (orange), and the volume |
| `PlayerTime` | Label | The time played and the song's length |
| `SongStatsHeader` | PanelContainer | The column titles above the statistics song list, spaced like its rows |
| `SongStatsText`, `SongStatsSecondary` | RichTextLabel | A song's name and its soundtrack (dimmer) in the statistics song list |
**Palette**: colours used from code. **Palette**: colours used from code.
+272 -7
View File
@@ -282,6 +282,23 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4 corner_radius_bottom_right = 4
corner_radius_bottom_left = 4 corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_listrowhover"]
content_margin_left = 8.0
content_margin_top = 2.0
content_margin_right = 8.0
content_margin_bottom = 2.0
bg_color = Color(1, 1, 1, 0.2)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_songstatsheader"]
content_margin_left = 8.0
content_margin_top = 2.0
content_margin_right = 8.0
content_margin_bottom = 2.0
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_mxy8k"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_mxy8k"]
content_margin_left = 8.0 content_margin_left = 8.0
content_margin_top = 2.0 content_margin_top = 2.0
@@ -294,7 +311,15 @@ corner_radius_bottom_right = 4
corner_radius_bottom_left = 4 corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_33f1l"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_33f1l"]
content_margin_left = 6.0
content_margin_top = 6.0
content_margin_right = 6.0
content_margin_bottom = 6.0
bg_color = Color(0.257813, 0.257813, 0.257813, 1) bg_color = Color(0.257813, 0.257813, 0.257813, 1)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wbnll"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wbnll"]
content_margin_left = 8.0 content_margin_left = 8.0
@@ -564,13 +589,50 @@ corner_radius_bottom_left = 3
corner_detail = 5 corner_detail = 5
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oxgwg"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oxgwg"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1) content_margin_left = 8.0
content_margin_top = 4.0
content_margin_right = 8.0
content_margin_bottom = 4.0
bg_color = Color(1, 1, 1, 0.05)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_r1nnu"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_songrowodd"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1) content_margin_left = 8.0
content_margin_top = 4.0
content_margin_right = 8.0
content_margin_bottom = 4.0
bg_color = Color(1, 1, 1, 0.11)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lkqs8"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_songrowhover"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1) content_margin_left = 8.0
content_margin_top = 4.0
content_margin_right = 8.0
content_margin_bottom = 4.0
bg_color = Color(1, 1, 1, 0.2)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_songrowplaying"]
content_margin_left = 8.0
content_margin_top = 4.0
content_margin_right = 8.0
content_margin_bottom = 4.0
bg_color = Color(1, 0.5, 0, 0.12)
border_width_left = 4
border_color = Color(1, 0.5, 0, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_1tv07"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_1tv07"]
content_margin_left = 4.0 content_margin_left = 4.0
@@ -757,6 +819,158 @@ corner_radius_top_right = 6
corner_radius_bottom_right = 6 corner_radius_bottom_right = 6
corner_radius_bottom_left = 6 corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_playerbar"]
content_margin_left = 6.0
content_margin_top = 4.0
content_margin_right = 14.0
content_margin_bottom = 4.0
bg_color = Color(0.16, 0.16, 0.16, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_playerbutton"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(1, 1, 1, 0)
corner_radius_top_left = 17
corner_radius_top_right = 17
corner_radius_bottom_right = 17
corner_radius_bottom_left = 17
corner_detail = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_playerbuttonhover"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(1, 1, 1, 0.12)
corner_radius_top_left = 17
corner_radius_top_right = 17
corner_radius_bottom_right = 17
corner_radius_bottom_left = 17
corner_detail = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_playerbuttonpressed"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(1, 1, 1, 0.2)
corner_radius_top_left = 17
corner_radius_top_right = 17
corner_radius_bottom_right = 17
corner_radius_bottom_left = 17
corner_detail = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_playerprimary"]
content_margin_left = 9.0
content_margin_top = 9.0
content_margin_right = 9.0
content_margin_bottom = 9.0
bg_color = Color(1, 0.5, 0, 1)
corner_radius_top_left = 17
corner_radius_top_right = 17
corner_radius_bottom_right = 17
corner_radius_bottom_left = 17
corner_detail = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_playerprimaryhover"]
content_margin_left = 9.0
content_margin_top = 9.0
content_margin_right = 9.0
content_margin_bottom = 9.0
bg_color = Color(1, 0.6, 0.18, 1)
corner_radius_top_left = 17
corner_radius_top_right = 17
corner_radius_bottom_right = 17
corner_radius_bottom_left = 17
corner_detail = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_playerprimarypressed"]
content_margin_left = 9.0
content_margin_top = 9.0
content_margin_right = 9.0
content_margin_bottom = 9.0
bg_color = Color(0.82, 0.4, 0, 1)
corner_radius_top_left = 17
corner_radius_top_right = 17
corner_radius_bottom_right = 17
corner_radius_bottom_left = 17
corner_detail = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_playerprimarydisabled"]
content_margin_left = 9.0
content_margin_top = 9.0
content_margin_right = 9.0
content_margin_bottom = 9.0
bg_color = Color(0.4, 0.4, 0.4, 1)
corner_radius_top_left = 17
corner_radius_top_right = 17
corner_radius_bottom_right = 17
corner_radius_bottom_left = 17
corner_detail = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_playertrack"]
content_margin_left = 0.0
content_margin_top = 3.0
content_margin_right = 0.0
content_margin_bottom = 3.0
bg_color = Color(0, 0, 0, 0.35)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_progressfill"]
content_margin_left = 0.0
content_margin_top = 3.0
content_margin_right = 0.0
content_margin_bottom = 3.0
bg_color = Color(1, 0.5, 0, 1)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_progressfillhover"]
content_margin_left = 0.0
content_margin_top = 3.0
content_margin_right = 0.0
content_margin_bottom = 3.0
bg_color = Color(1, 0.62, 0.22, 1)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_volumefill"]
content_margin_left = 0.0
content_margin_top = 3.0
content_margin_right = 0.0
content_margin_bottom = 3.0
bg_color = Color(0.8, 0.8, 0.8, 1)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_volumefillhover"]
content_margin_left = 0.0
content_margin_top = 3.0
content_margin_right = 0.0
content_margin_bottom = 3.0
bg_color = Color(1, 1, 1, 1)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_playerfocus"]
[resource] [resource]
default_font = ExtResource("1_default_font") default_font = ExtResource("1_default_font")
Button/colors/font_color = Color(0.875, 0.875, 0.875, 1) Button/colors/font_color = Color(0.875, 0.875, 0.875, 1)
@@ -844,6 +1058,8 @@ LargeButton/base_type = &"Button"
LargeButton/font_sizes/font_size = 24 LargeButton/font_sizes/font_size = 24
ListRowEven/base_type = &"PanelContainer" ListRowEven/base_type = &"PanelContainer"
ListRowEven/styles/panel = SubResource("StyleBoxFlat_u5sc5") ListRowEven/styles/panel = SubResource("StyleBoxFlat_u5sc5")
ListRowHover/base_type = &"PanelContainer"
ListRowHover/styles/panel = SubResource("StyleBoxFlat_listrowhover")
ListRowOdd/base_type = &"PanelContainer" ListRowOdd/base_type = &"PanelContainer"
ListRowOdd/styles/panel = SubResource("StyleBoxFlat_mxy8k") ListRowOdd/styles/panel = SubResource("StyleBoxFlat_mxy8k")
MarginContainer/constants/margin_bottom = 0 MarginContainer/constants/margin_bottom = 0
@@ -876,6 +1092,32 @@ OptionButton/styles/pressed_mirrored = SubResource("StyleBoxFlat_a0ksy")
Palette/colors/backdrop = Color(0, 0, 0, 0.55) Palette/colors/backdrop = Color(0, 0, 0, 0.55)
Panel/styles/panel = SubResource("StyleBoxFlat_hwwyi") Panel/styles/panel = SubResource("StyleBoxFlat_hwwyi")
PanelContainer/styles/panel = SubResource("StyleBoxFlat_wl3gy") PanelContainer/styles/panel = SubResource("StyleBoxFlat_wl3gy")
PlayerBar/base_type = &"PanelContainer"
PlayerBar/styles/panel = SubResource("StyleBoxFlat_playerbar")
PlayerButton/base_type = &"Button"
PlayerButton/colors/icon_hover_color = Color(1, 0.5, 0, 1)
PlayerButton/colors/icon_hover_pressed_color = Color(1, 0.5, 0, 1)
PlayerButton/colors/icon_normal_color = Color(0.88, 0.88, 0.88, 1)
PlayerButton/colors/icon_pressed_color = Color(1, 0.6, 0.2, 1)
PlayerButton/styles/focus = SubResource("StyleBoxEmpty_playerfocus")
PlayerButton/styles/hover = SubResource("StyleBoxFlat_playerbuttonhover")
PlayerButton/styles/hover_pressed = SubResource("StyleBoxFlat_playerbuttonpressed")
PlayerButton/styles/normal = SubResource("StyleBoxFlat_playerbutton")
PlayerButton/styles/pressed = SubResource("StyleBoxFlat_playerbuttonpressed")
PlayerButtonPrimary/base_type = &"Button"
PlayerButtonPrimary/colors/icon_hover_color = Color(1, 1, 1, 1)
PlayerButtonPrimary/colors/icon_hover_pressed_color = Color(1, 1, 1, 1)
PlayerButtonPrimary/colors/icon_normal_color = Color(1, 1, 1, 1)
PlayerButtonPrimary/colors/icon_pressed_color = Color(1, 1, 1, 1)
PlayerButtonPrimary/styles/disabled = SubResource("StyleBoxFlat_playerprimarydisabled")
PlayerButtonPrimary/styles/focus = SubResource("StyleBoxEmpty_playerfocus")
PlayerButtonPrimary/styles/hover = SubResource("StyleBoxFlat_playerprimaryhover")
PlayerButtonPrimary/styles/hover_pressed = SubResource("StyleBoxFlat_playerprimarypressed")
PlayerButtonPrimary/styles/normal = SubResource("StyleBoxFlat_playerprimary")
PlayerButtonPrimary/styles/pressed = SubResource("StyleBoxFlat_playerprimarypressed")
PlayerTime/base_type = &"Label"
PlayerTime/colors/font_color = Color(0.82, 0.82, 0.82, 1)
PlayerTime/font_sizes/font_size = 18
PopupBackground/base_type = &"Panel" PopupBackground/base_type = &"Panel"
PopupBackground/styles/panel = SubResource("StyleBoxFlat_vputg") PopupBackground/styles/panel = SubResource("StyleBoxFlat_vputg")
PopupMenu/colors/font_accelerator_color = Color(0.7, 0.7, 0.7, 0.8) PopupMenu/colors/font_accelerator_color = Color(0.7, 0.7, 0.7, 0.8)
@@ -926,6 +1168,10 @@ ScrollContainer/colors/scroll_hint_horizontal_color = Color(0, 0, 0, 1)
ScrollContainer/colors/scroll_hint_vertical_color = Color(0, 0, 0, 1) ScrollContainer/colors/scroll_hint_vertical_color = Color(0, 0, 0, 1)
ScrollContainer/styles/focus = SubResource("StyleBoxFlat_35dwi") ScrollContainer/styles/focus = SubResource("StyleBoxFlat_35dwi")
ScrollContainer/styles/panel = SubResource("StyleBoxEmpty_r3gy8") ScrollContainer/styles/panel = SubResource("StyleBoxEmpty_r3gy8")
ProgressSlider/base_type = &"HSlider"
ProgressSlider/styles/grabber_area = SubResource("StyleBoxFlat_progressfill")
ProgressSlider/styles/grabber_area_highlight = SubResource("StyleBoxFlat_progressfillhover")
ProgressSlider/styles/slider = SubResource("StyleBoxFlat_playertrack")
SearchField/base_type = &"LineEdit" SearchField/base_type = &"LineEdit"
SearchField/font_sizes/font_size = 28 SearchField/font_sizes/font_size = 28
SearchResult/base_type = &"RichTextLabel" SearchResult/base_type = &"RichTextLabel"
@@ -936,13 +1182,28 @@ ShortcutRowDark/base_type = &"PanelContainer"
ShortcutRowDark/styles/panel = SubResource("StyleBoxFlat_anjdx") ShortcutRowDark/styles/panel = SubResource("StyleBoxFlat_anjdx")
SongRow/base_type = &"PanelContainer" SongRow/base_type = &"PanelContainer"
SongRow/styles/panel = SubResource("StyleBoxFlat_oxgwg") SongRow/styles/panel = SubResource("StyleBoxFlat_oxgwg")
SongRowHover/base_type = &"PanelContainer"
SongRowHover/styles/panel = SubResource("StyleBoxFlat_songrowhover")
SongRowLabel/base_type = &"Label" SongRowLabel/base_type = &"Label"
SongRowLabel/font_sizes/font_size = 20 SongRowLabel/font_sizes/font_size = 20
SongRowLabel/styles/normal = SubResource("StyleBoxFlat_r1nnu")
SongRowLabelPlaying/base_type = &"Label" SongRowLabelPlaying/base_type = &"Label"
SongRowLabelPlaying/colors/font_color = Color(1, 0.5, 0, 1) SongRowLabelPlaying/colors/font_color = Color(1, 0.5, 0, 1)
SongRowLabelPlaying/font_sizes/font_size = 20 SongRowLabelPlaying/font_sizes/font_size = 20
SongRowLabelPlaying/styles/normal = SubResource("StyleBoxFlat_lkqs8") SongRowOdd/base_type = &"PanelContainer"
SongRowOdd/styles/panel = SubResource("StyleBoxFlat_songrowodd")
SongRowPictureLine/base_type = &"HBoxContainer"
SongRowPictureLine/constants/separation = 4
SongRowPictureLines/base_type = &"VBoxContainer"
SongRowPictureLines/constants/separation = 4
SongRowPlaying/base_type = &"PanelContainer"
SongRowPlaying/styles/panel = SubResource("StyleBoxFlat_songrowplaying")
SongStatsHeader/base_type = &"PanelContainer"
SongStatsHeader/styles/panel = SubResource("StyleBoxEmpty_songstatsheader")
SongStatsSecondary/base_type = &"RichTextLabel"
SongStatsSecondary/colors/default_color = Color(0.7, 0.7, 0.7, 1)
SongStatsSecondary/font_sizes/normal_font_size = 18
SongStatsText/base_type = &"RichTextLabel"
SongStatsText/font_sizes/normal_font_size = 18
StatisticsCard/base_type = &"PanelContainer" StatisticsCard/base_type = &"PanelContainer"
StatisticsCard/styles/panel = SubResource("StyleBoxFlat_statscard") StatisticsCard/styles/panel = SubResource("StyleBoxFlat_statscard")
StatisticsCardHover/base_type = &"PanelContainer" StatisticsCardHover/base_type = &"PanelContainer"
@@ -1000,5 +1261,9 @@ VScrollBar/styles/scroll = SubResource("StyleBoxFlat_6fedo")
VScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_8sdcv") VScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_8sdcv")
VSeparator/constants/separation = 4 VSeparator/constants/separation = 4
VSeparator/styles/separator = SubResource("StyleBoxLine_r3erx") VSeparator/styles/separator = SubResource("StyleBoxLine_r3erx")
VolumeBar/base_type = &"HSlider"
VolumeBar/styles/grabber_area = SubResource("StyleBoxFlat_volumefill")
VolumeBar/styles/grabber_area_highlight = SubResource("StyleBoxFlat_volumefillhover")
VolumeBar/styles/slider = SubResource("StyleBoxFlat_playertrack")
WindowBackground/base_type = &"Panel" WindowBackground/base_type = &"Panel"
WindowBackground/styles/panel = SubResource("StyleBoxFlat_2mdtn") WindowBackground/styles/panel = SubResource("StyleBoxFlat_2mdtn")