9 Commits
Author SHA1 Message Date
SansanandClaude Opus 5.5 0862d160e5 Give the sync progress bar colour and animation
Build / test (push) Successful in 1m55s
Build / build (push) Successful in 1m12s
The bar in the Sync popup is now an AnimatedProgressBar: taller, with a
darker track and the theme's accent fill (orange, frost blue in Nord, green
with a gold shine for Christmas). It shows Godot's busy animation until the
server reports progress, glides to each new value, has a soft highlight
sweeping along the filled part inside its rounded corners, and fills and
pops before the result shows. With Reduce animations it only jumps to its
values.

The styles are SyncProgressBar, SyncProgressBarFill and SyncProgressBarText
in every theme. Anim.stop stops a keyed animation without starting an empty
one.

The server now sends sync times as "MM:SS"; the spec is updated from
MusicServer develop (also the linux-package route), and the test data and
screenshots use the new format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:13:43 +02:00
SansanandClaude Opus 5.5 f39865fffc Update the game from inside it (#13)
Build / test (push) Successful in 1m53s
Build / build (push) Successful in 1m15s
"Version X is available" in the top bar is now a button that opens an
Update popup:
- Update and restart gets the download link from the server (the redirect
  is not followed, as the Gitea link needs no token), downloads the new game
  next to the running one with a progress bar, checks it (complete, and an
  MZ or ELF file), swaps it in and restarts. The quiz is saved first and
  continues. On Windows the running .exe is renamed to .old, put back if
  the swap fails, and deleted at the next start.
- Download in browser opens the .exe, the Linux package, or the release
  page on other platforms.
- From the editor, an unwritable folder or another platform, only the
  browser download is offered, with the reason.

The message now fits between the tabs and the buttons in every theme; in
Nord and Christmas it ran into Sync. The macOS export preset is removed,
and the unused download_client_* functions are replaced by
fetch_client_download_url.

Closes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:23:26 +02:00
SansanandClaude Opus 5.5 c03c0e4efe Add Clear cache to the settings
Build / test (push) Successful in 1m51s
Build / build (push) Successful in 1m14s
A new row, "Downloaded pictures and songs", shows how much space the cached
character pictures, covers and downloaded songs take, with a Clear cache
button. Clearing deletes them, but keeps the songs the current quiz uses,
the settings, config.ini and the saved quiz. The characters are loaded
again at once; covers come back when they are shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:50:58 +02:00
SansanandClaude Opus 5.5 06421bb63e Change soundtrack covers from the app and show the cover with the answer
Build / test (push) Successful in 1m50s
Build / build (push) Successful in 1m14s
Cover menu in the soundtrack popup ("Change cover"):
- Choose picture uploads a JPEG, PNG or WebP picture (up to 10 MB).
- Pick by ID uses a game's cover on SteamGridDB or an anime's on AniList.
- Remove cover deletes it, so the server searches for a new one.
- The server's reason is shown when it refuses a change.
After a change the new cover time is fetched, and the popup, the
statistics card and the quiz show the new cover.

Quiz: once the answer is shown, the soundtrack's cover shows to the left of
the titles. Cover and titles are centered together, left and right and up
and down, and each title keeps to two lines. Cover times come from the
soundtrack list, and songs keep their soundtrack's UUID, also in saves.

Requests can now send any body with its content type, and every 2xx answer
counts as success. Cover frames are 2:3 like the server's covers. Small
copies of real covers are used by the tests and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:32:09 +02:00
SansanandClaude Opus 5.5 ef6ac8f1fd Show soundtrack covers and edit alternative names (#49)
Build / test (push) Successful in 1m43s
Build / build (push) Successful in 1m14s
Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.

Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.

The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.

Closes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:06:30 +02:00
SansanandClaude Opus 5.5 a735c515bf Add Short songs and a choice of how random songs are picked (#30, #51)
Build / test (push) Successful in 1m40s
Build / build (push) Successful in 1m28s
Short songs (#30): a quiz settings switch and a Seconds per song row (30 by
default, 10-120 in steps of 5), saved with the quiz settings. Only part of
each quiz song plays, from a random point 10-60% into it, picked once per song
so restart and repeat play the same part. The progress bar, time and seeking
stay within the part, a hidden beginning counts inside it, and its end counts
as the song ending (repeat, stop or go to the next song as set).

Random songs (#51): the low-played switch in Settings is now a Normal / Low
played / Classic choice, stored as Settings.random_mode and not saved.
fetch_random_song(mode) picks /music/rand, /music/rand/low or
/music/rand/classic.

Closes #30
Closes #51

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:21:20 +02:00
SansanandClaude Opus 5.5 0fc3be79e1 Add animations to popups, points, songs, the winner and the tabs (#48)
Build / test (push) Successful in 1m39s
Build / build (push) Successful in 1m14s
Popups fade in while growing and close the reverse way, with the backdrop
fading along. The winner gets confetti, bouncing pictures and a score graph
that draws itself from the left. Filtered statistics cards and search rows
fade in, the tabs slide in from their side, and every button bounces on hover
and click (ButtonBounce, now added to all buttons).

In the player list the score pops on every point, the first point flashes the
row, and a player on match point glows with a "Match point!" tag. A song
starting fades in the title, flashes its row (kept across list rebuilds) and
pops the play button.

Common/anim.gd holds the helpers. A saved "Reduce animations" switch in
Settings turns all movement off; the tests use it. The flash colours are
theme colours (Palette first_point, match_point, flash) in every theme.

Closes #48

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:49:01 +02:00
SansanandClaude Opus 5.5 ae231975bd Show each player's picture first in the player list
Build / test (push) Successful in 1m30s
Build / build (push) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:18:19 +02:00
SansanandClaude Opus 5.5 b9fa3c3d63 Add a Christmas theme
Christmas red window and panels with snow white buttons and red text, a thick
white "fur" trim on popups, cards, the lists and the player bar, the tree's
green for the chosen tab and the play button, and gold for the playing song and
highlights. Icons on buttons take the text's colour, and drop-down lists stay
dark red so their arrow shows. It uses Nord's round shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:18:19 +02:00
108 changed files with 19006 additions and 2962 deletions

No files matched your search

+12
View File
@@ -27,3 +27,15 @@ Unpack `MusicPlayer_<version>_linux.tar.gz` and run `./install.sh` inside the
`MusicPlayer` folder. It installs the game to `~/.local/bin` and adds it, with its icon,
to the app menu. The bare `MusicPlayer_<version>.x86_64` is still published for anyone
who just wants to run the file.
## Updating
When the server has a newer release, the top bar says "Version X is available". Clicking
it offers **Update and restart**: the game downloads its new file from the release
(`MusicPlayer_<version>.exe` on Windows, `MusicPlayer_<version>.x86_64` on Linux, through
the server's `/download/<platform>` routes), puts it in place of the running file and
starts it, and the quiz continues where it was. On Windows the old file is kept as
`<game>.old` until the next start. So keep the release file names as they are: the server
finds them by their ending. Where the game can't replace itself (run from the editor, a
folder it can't write to, or another platform), it opens the download in the browser
instead.
+145
View File
@@ -0,0 +1,145 @@
class_name Anim
extends RefCounted
## Short animations made with tweens, used all over the game: popping, fading, flashing
## and pulsing.
##
## Every animation is short so a quiz never waits for one. With
## [member Settings.reduce_animations] on, nothing moves: each helper jumps straight to
## where the animation would end.
## How long quick feedback, such as a button press, takes.
const FAST: float = 0.12
## How long most animations take.
const NORMAL: float = 0.2
## How long flashes and bigger moments take.
const SLOW: float = 0.45
## Whether animations are on.
static func enabled() -> bool:
return !Settings.reduce_animations
## A new tween on [param node] for the animation called [param key], stopping an earlier
## one with the same key so they don't fight. Returns null when animations are off or the
## node is not in the tree.
static func tween(node: Node, key: StringName = &"anim") -> Tween:
stop(node, key)
if !enabled() || !node.is_inside_tree():
return null
var new_tween: Tween = node.create_tween()
node.set_meta(StringName("_tween_" + key), new_tween)
return new_tween
## Stops the animation called [param key] on [param node], if one is running.
static func stop(node: Node, key: StringName = &"anim") -> void:
var meta: StringName = StringName("_tween_" + key)
var old: Variant = node.get_meta(meta) if node.has_meta(meta) else null
if old is Tween && (old as Tween).is_valid():
(old as Tween).kill()
## Makes [param control] grow from its middle.
static func center_pivot(control: Control) -> void:
control.pivot_offset = control.size / 2
## Makes [param control] jump to [param peak] times its size and spring back.
static func pop(control: Control, peak: float = 1.3, duration: float = NORMAL) -> void:
var pop_tween: Tween = tween(control, &"pop")
control.scale = Vector2.ONE
if pop_tween == null:
return
center_pivot(control)
control.scale = Vector2.ONE * peak
pop_tween.tween_property(control, "scale", Vector2.ONE, duration) \
.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
## Makes [param control] grow from [param from] times its size with a bounce.
static func bounce_in(control: Control, from: float = 0.4, duration: float = SLOW) -> void:
var bounce_tween: Tween = tween(control, &"pop")
control.scale = Vector2.ONE
if bounce_tween == null:
return
center_pivot(control)
control.scale = Vector2.ONE * from
bounce_tween.tween_property(control, "scale", Vector2.ONE, duration * 1.6) \
.set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
## Fades [param item] in from invisible.
static func fade_in(item: CanvasItem, duration: float = NORMAL) -> void:
var fade_tween: Tween = tween(item, &"fade")
item.modulate.a = 1.0
if fade_tween == null:
return
item.modulate.a = 0.0
fade_tween.tween_property(item, "modulate:a", 1.0, duration)
## Slides [param control] in from [param offset] away from where it is, fading in.
static func slide_in(control: Control, offset: Vector2, duration: float = NORMAL) -> void:
var slide_tween: Tween = tween(control, &"slide")
if control.has_meta(&"_slide_home"):
control.position = control.get_meta(&"_slide_home")
control.modulate.a = 1.0
if slide_tween == null:
return
var home: Vector2 = control.position
control.set_meta(&"_slide_home", home)
control.position = home + offset
control.modulate.a = 0.0
slide_tween.set_parallel()
slide_tween.tween_property(control, "position", home, duration) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
slide_tween.tween_property(control, "modulate:a", 1.0, duration)
slide_tween.chain().tween_callback(func() -> void: control.remove_meta(&"_slide_home"))
## Scales [param control] to [param target] times its size, e.g. a little bigger under
## the mouse.
static func scale_to(control: Control, target: float, duration: float = FAST) -> void:
var scale_tween: Tween = tween(control, &"scale")
if scale_tween == null:
control.scale = Vector2.ONE
return
center_pivot(control)
scale_tween.tween_property(control, "scale", Vector2.ONE * target, duration) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
## Lights up [param container] with [param colour] for a moment, e.g. a row that just
## changed. A coloured layer is laid over the container and fades away.
static func flash(container: Container, colour: Color, duration: float = SLOW) -> void:
if !enabled() || !container.is_inside_tree():
return
var layer: ColorRect = _layer(container, colour)
var flash_tween: Tween = layer.create_tween()
flash_tween.tween_property(layer, "color:a", 0.0, duration) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
flash_tween.tween_callback(layer.queue_free)
## Keeps [param container] glowing in [param colour], softly pulsing, until the returned
## layer is freed. With animations off the glow stays still.
static func pulse(container: Container, colour: Color) -> ColorRect:
var layer: ColorRect = _layer(container, colour)
layer.name = "Pulse"
if enabled():
var pulse_tween: Tween = layer.create_tween().set_loops()
pulse_tween.tween_property(layer, "color:a", colour.a * 0.3, 0.6) \
.set_trans(Tween.TRANS_SINE)
pulse_tween.tween_property(layer, "color:a", colour.a, 0.6) \
.set_trans(Tween.TRANS_SINE)
return layer
# A layer of [param colour] over the whole of [param container] that lets clicks through
static func _layer(container: Container, colour: Color) -> ColorRect:
var layer: ColorRect = ColorRect.new()
layer.color = colour
layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
container.add_child(layer)
return layer
+1
View File
@@ -0,0 +1 @@
uid://67is3ejxq0j8
+118
View File
@@ -0,0 +1,118 @@
class_name AnimatedProgressBar
extends ProgressBar
## A progress bar with some life in it:
## - [method wait] shows Godot's busy animation until there is any progress;
## - [method move_to] glides to each new value instead of jumping;
## - a soft highlight sweeps along the filled part, kept inside its rounded corners.
##
## The bar's style, e.g. [code]SyncProgressBar[/code], has a [code]shine[/code] colour for
## the highlight, and two more styles go with it: [code]<style>Fill[/code], a Panel shaped
## like the filled part that the highlight is kept inside, and [code]<style>Text[/code],
## the Label with the percentage. With Reduce animations the bar only jumps to its values,
## with no busy animation or highlight.
## How wide the highlight is, in pixels.
const SHINE_WIDTH: float = 260.0
## How fast the highlight moves, in pixels per second.
const SHINE_SPEED: float = 520.0
## How far past the end the highlight goes before starting over, so sweeps have a pause.
const SHINE_GAP: float = 600.0
# The filled part's shape, which only its children are drawn inside
var _mask: Panel
var _shine: TextureRect
# The percentage, drawn above the highlight rather than under it
var _label: Label
var _shine_x: float = -SHINE_WIDTH
func _ready() -> void:
_mask = Panel.new()
_mask.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mask.clip_children = CanvasItem.CLIP_CHILDREN_ONLY
add_child(_mask)
_shine = TextureRect.new()
_shine.mouse_filter = Control.MOUSE_FILTER_IGNORE
_shine.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_shine.stretch_mode = TextureRect.STRETCH_SCALE
_mask.add_child(_shine)
_label = Label.new()
_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_label.visible = show_percentage
add_child(_label)
show_percentage = false
_mask.theme_type_variation = StringName(String(theme_type_variation) + "Fill")
_label.theme_type_variation = StringName(String(theme_type_variation) + "Text")
theme_changed.connect(_update_style)
value_changed.connect(func(_value: float) -> void: _update_label())
_update_style()
_update_label()
func _process(delta: float) -> void:
var fill_width: float = fill_width()
_mask.size = Vector2(fill_width, size.y)
_mask.visible = fill_width > 0.0 && !indeterminate && Anim.enabled()
if !_mask.visible:
return
_shine_x += SHINE_SPEED * delta
if _shine_x > fill_width + SHINE_GAP:
_shine_x = -SHINE_WIDTH
_shine.position = Vector2(_shine_x, 0.0)
_shine.size = Vector2(SHINE_WIDTH, size.y)
## Shows the busy animation, with no value, until [method move_to] has some progress.
func wait() -> void:
Anim.stop(self, &"progress")
value = min_value
indeterminate = Anim.enabled()
_update_label()
## Glides to [param target]. Any progress ends the busy animation.
func move_to(target: float) -> void:
if target > min_value:
indeterminate = false
var glide: Tween = Anim.tween(self, &"progress")
if glide == null:
value = target
return
glide.tween_property(self, "value", target, Anim.SLOW) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
## How wide the filled part is, the way [ProgressBar] draws it.
func fill_width() -> float:
var margins: float = get_theme_stylebox(&"fill").get_minimum_size().x
var filled: float = roundf(ratio_done() * (size.x - margins))
return filled + margins if filled > 0.0 else 0.0
func _update_style() -> void:
var shine: Color = get_theme_color(&"shine")
var clear: Color = shine
clear.a = 0.0
var gradient: Gradient = Gradient.new()
gradient.offsets = PackedFloat32Array([0.0, 0.5, 1.0])
gradient.colors = PackedColorArray([clear, shine, clear])
var texture: GradientTexture2D = GradientTexture2D.new()
texture.gradient = gradient
texture.width = 256
texture.height = 1
_shine.texture = texture
func _update_label() -> void:
if _label == null:
return
_label.text = "" if indeterminate else "%d%%" % roundi(ratio_done() * 100.0)
## How much is done, from 0 to 1.
func ratio_done() -> float:
var span: float = max_value - min_value
return clampf((value - min_value) / span, 0.0, 1.0) if span > 0.0 else 0.0
+1
View File
@@ -0,0 +1 @@
uid://bxjx3kacfqg6j
+29 -1
View File
@@ -4,6 +4,7 @@ extends RefCounted
## 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.
## With [member Settings.reduce_animations] on, buttons keep their size.
## How big a button is under the mouse.
const HOVER_SCALE: Vector2 = Vector2(1.08, 1.08)
@@ -18,10 +19,32 @@ const POP_DURATION: float = 0.18
const _TWEEN_META: StringName = &"button_bounce_tween"
const _PIVOT_META: StringName = &"button_bounce_pivot"
const _ADDED_META: StringName = &"button_bounce_added"
## Makes [param button] grow under the mouse and shrink while held down.
## Gives every button in [param tree] the bounce, also buttons added later, e.g. rows
## of the player list. Check buttons are left out: they span a whole settings row.
static func add_to_every_button(tree: SceneTree) -> void:
if !tree.node_added.is_connected(_on_node_added):
tree.node_added.connect(_on_node_added)
var nodes: Array[Node] = [tree.root]
while !nodes.is_empty():
var node: Node = nodes.pop_back()
_on_node_added(node)
nodes.append_array(node.get_children())
static func _on_node_added(node: Node) -> void:
if node is BaseButton && !(node is CheckButton) && !(node is CheckBox):
add_to(node)
## Makes [param button] grow under the mouse and shrink while held down. Adding it to
## the same button twice does nothing.
static func add_to(button: BaseButton) -> void:
if button.has_meta(_ADDED_META):
return
button.set_meta(_ADDED_META, true)
_keep_pivot_centred(button)
button.mouse_entered.connect(func() -> void:
if !button.disabled:
@@ -38,6 +61,8 @@ static func resting_scale(button: BaseButton) -> Vector2:
## Shrinks [param button] and lets it spring back, e.g. when its icon changes.
static func pop(button: BaseButton) -> void:
if !Anim.enabled():
return
_keep_pivot_centred(button)
button.scale = POP_SCALE
var tween: Tween = _new_tween(button)
@@ -46,6 +71,9 @@ static func pop(button: BaseButton) -> void:
static func _scale_to(button: BaseButton, target: Vector2) -> void:
if !Anim.enabled():
button.scale = Vector2.ONE
return
var tween: Tween = _new_tween(button)
tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.tween_property(button, ^"scale", target, DURATION)
+19 -62
View File
@@ -14,15 +14,15 @@ extends RefCounted
## can't be reached at all, the cached pictures are used as they are.
const FOLDER_NAME: String = "characters"
const INDEX_FILE_NAME: String = "index.json"
const INDEX_FILE_NAME: String = FileCache.INDEX_FILE_NAME
## How long a picture is used before downloading it again, when the server can't tell
## whether it changed.
const MAX_AGE_SECONDS: int = 7 * 24 * 60 * 60
## The change time of a picture when the server can't tell it.
const UNKNOWN_TIME: int = -1
const UNKNOWN_TIME: int = FileCache.UNKNOWN_TIME
# What index.json holds: name -> {"modified": int, "downloaded": int}
var _index: Dictionary = {}
# The pictures on disk and their index
var _files: FileCache = FileCache.new(FOLDER_NAME)
# When the server last changed each picture on the list, or UNKNOWN_TIME
var _modified: Dictionary[String, int] = {}
# True when the server could not be reached and only cached pictures are shown
@@ -34,11 +34,16 @@ static func folder() -> String:
return Settings.config_dir().path_join(FOLDER_NAME)
## Deletes every cached picture. They are downloaded again when next asked for.
func clear() -> void:
_files.clear()
## Fetches the names of the characters and calls [param on_done] with them as an
## [code]Array[String][/code]. Cached pictures of characters no longer on the server are
## deleted.
func load_list(on_done: Callable) -> void:
_load_index()
_files.load_index()
_cache_only = false
var details_fetched: Callable = func details_fetched(details: Array) -> void:
_modified.clear()
@@ -51,9 +56,8 @@ func load_list(on_done: Callable) -> void:
## Calls [param on_done] with the picture of [param character_name] as an [Image], from
## disk when the cached one is still fresh, otherwise from the server.
func fetch_image(character_name: String, on_done: Callable) -> void:
var path: String = _file_path(character_name)
if (_cache_only || _is_fresh(character_name)) && FileAccess.file_exists(path):
var cached: Image = CharacterCard.image_from_bytes(FileAccess.get_file_as_bytes(path))
if (_cache_only || _is_fresh(character_name)) && _files.has_file(character_name):
var cached: Image = CharacterCard.image_from_bytes(_files.read(character_name))
if cached != null:
on_done.call(cached)
return
@@ -63,7 +67,7 @@ func fetch_image(character_name: String, on_done: Callable) -> void:
if picture == null:
GameLog.warning("Could not load the picture of ", character_name)
return
_store(character_name, bytes)
_files.store(character_name, bytes, _modified.get(character_name, UNKNOWN_TIME))
on_done.call(picture)
MusicServerClient.fetch_character_image(character_name, image_fetched)
@@ -84,71 +88,24 @@ func _load_cached_only(on_done: Callable) -> void:
GameLog.warning("Could not get the characters from the server, showing the cached ones")
_cache_only = true
var names: Array[String] = []
for character_name: String in _index:
if FileAccess.file_exists(_file_path(character_name)):
for character_name: String in _files.keys():
if _files.has_file(character_name):
names.append(character_name)
names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0)
on_done.call(names)
func _list_loaded(on_done: Callable) -> void:
var removed: bool = false
for character_name: String in _index.keys():
if !_modified.has(character_name):
DirAccess.remove_absolute(_file_path(character_name))
_index.erase(character_name)
removed = true
if removed:
_save_index()
_files.keep_only(_modified)
var names: Array[String] = []
names.assign(_modified.keys())
on_done.call(names)
func _is_fresh(character_name: String) -> bool:
if !_index.has(character_name):
if !_files.keys().has(character_name):
return false
var entry: Dictionary = _index[character_name]
var modified: int = _modified.get(character_name, UNKNOWN_TIME)
if modified != UNKNOWN_TIME:
return int(entry.get("modified", UNKNOWN_TIME)) == modified
var age: float = Time.get_unix_time_from_system() - float(entry.get("downloaded", 0))
return age < MAX_AGE_SECONDS
func _store(character_name: String, bytes: PackedByteArray) -> void:
DirAccess.make_dir_recursive_absolute(folder())
var file: FileAccess = FileAccess.open(_file_path(character_name), FileAccess.WRITE)
if file == null:
GameLog.warning("Could not cache the picture of ", character_name, ": ",
error_string(FileAccess.get_open_error()))
return
file.store_buffer(bytes)
file.close()
_index[character_name] = {
"modified": _modified.get(character_name, UNKNOWN_TIME),
"downloaded": int(Time.get_unix_time_from_system()),
}
_save_index()
func _load_index() -> void:
_index = {}
var path: String = folder().path_join(INDEX_FILE_NAME)
if FileAccess.file_exists(path):
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if parsed is Dictionary:
_index = parsed
func _save_index() -> void:
var file: FileAccess = FileAccess.open(folder().path_join(INDEX_FILE_NAME),
FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(_index, "\t", true))
# The server's names are plain file names, but they are made safe anyway since they
# become paths on this computer
func _file_path(character_name: String) -> String:
return folder().path_join(character_name.validate_filename())
return _files.modified(character_name) == modified
return _files.age(character_name) < MAX_AGE_SECONDS
+149
View File
@@ -0,0 +1,149 @@
class_name CoverCache
extends RefCounted
## Soundtrack covers kept on disk in the [code]covers[/code] folder next to the settings,
## by soundtrack UUID, so each one is downloaded only when it changed on the server (its
## [code]cover_modified[/code] time). See [FileCache].
##
## Covers are downloaded a few at a time, in the order they are asked for, so opening the
## statistics doesn't send a request for every soundtrack at once.
##
## It also remembers each soundtrack's cover time from the soundtrack lists it is given,
## so [method fetch_cover_of] finds a cover by the soundtrack alone, e.g. in the quiz.
## Emitted when the cover time of a soundtrack is new or changed, e.g. once the soundtrack
## list has loaded or a cover was changed.
signal cover_times_changed
const FOLDER_NAME: String = "covers"
## How many covers are downloaded at the same time.
const MAX_DOWNLOADS: int = 3
# The covers on disk and their index
var _files: FileCache = FileCache.new(FOLDER_NAME)
# Covers waiting to be downloaded: [uuid, modified]
var _waiting: Array[Array] = []
# Who is waiting for each cover that is asked for or downloading: uuid -> Array[Callable]
var _callbacks: Dictionary = {}
var _downloading: int = 0
# The server's cover time of each soundtrack seen: uuid -> int, 0 for none
var _times: Dictionary = {}
func _init() -> void:
_files.load_index()
## The folder the covers are kept in.
static func folder() -> String:
return Settings.config_dir().path_join(FOLDER_NAME)
## Deletes every cached cover. They are downloaded again when next asked for.
func clear() -> void:
_files.clear()
## Deletes the covers of soundtracks that are not in [param covers] (UUID -> cover
## time), and those whose soundtrack no longer has a cover.
func keep_only(covers: Dictionary) -> void:
set_cover_times(covers)
var kept: Dictionary = {}
for uuid: String in covers:
if int(covers[uuid]) > 0:
kept[uuid] = true
_files.keep_only(kept)
## Remembers the cover times in [param covers] (UUID -> cover time, 0 for no cover) and
## emits [signal cover_times_changed] when any is new or changed.
func set_cover_times(covers: Dictionary) -> void:
var changed: bool = false
for uuid: String in covers:
var modified: int = int(covers[uuid])
if _times.get(uuid, -1) != modified:
_times[uuid] = modified
changed = true
if changed:
cover_times_changed.emit()
## The cover time of the soundtrack [param uuid], 0 when it has no cover or none is known.
func cover_time(uuid: String) -> int:
return _times.get(uuid, 0)
## Like [method fetch_cover], with the cover time remembered for the soundtrack.
func fetch_cover_of(uuid: String, on_done: Callable) -> void:
fetch_cover(uuid, cover_time(uuid), on_done)
## Asks the server for the cover time of the soundtrack [param uuid] again, e.g. after
## its cover was changed, then calls [param on_done] with the new time, 0 for no cover.
## A removed cover is deleted from disk too. When the server doesn't answer, it is called
## with the time it had.
func refresh(uuid: String, on_done: Callable) -> void:
var with_soundtrack: Callable = func with_soundtrack(
soundtrack: SoundtrackWithSongList) -> void:
if soundtrack.cover_modified <= 0:
_files.remove(uuid)
set_cover_times({uuid: soundtrack.cover_modified})
on_done.call(soundtrack.cover_modified)
# Without an answer the cover is left as it was
MusicServerClient.fetch_soundtrack(uuid, with_soundtrack,
func() -> void: on_done.call(cover_time(uuid)))
## Calls [param on_done] with the cover of the soundtrack [param uuid] as an [Image]:
## from disk when the cached one has the server's time [param modified], otherwise once
## it has been downloaded. Nothing is called when the soundtrack has no cover
## ([param modified] 0) or the download fails.
func fetch_cover(uuid: String, modified: int, on_done: Callable) -> void:
if modified <= 0 || uuid.is_empty():
return
if _files.has_file(uuid) && _files.modified(uuid) == modified:
var cached: Image = CharacterCard.image_from_bytes(_files.read(uuid))
if cached != null:
on_done.call(cached)
return
if _callbacks.has(uuid):
_callbacks[uuid].append(on_done)
return
_callbacks[uuid] = [on_done]
_waiting.append([uuid, modified])
_download_next()
## How many covers are waiting for their turn or downloading.
func busy() -> int:
return _callbacks.size()
func _download_next() -> void:
while _downloading < MAX_DOWNLOADS && !_waiting.is_empty():
var next: Array = _waiting.pop_front()
var uuid: String = next[0]
_downloading += 1
MusicServerClient.fetch_soundtrack_cover(uuid,
_on_downloaded.bind(uuid, next[1]), _on_failed.bind(uuid))
func _on_downloaded(bytes: PackedByteArray, uuid: String, modified: int) -> void:
_downloading -= 1
var cover: Image = CharacterCard.image_from_bytes(bytes)
if cover == null:
GameLog.warning("The cover of soundtrack ", uuid, " could not be read")
else:
_files.store(uuid, bytes, modified)
for on_done: Callable in _callbacks.get(uuid, []):
# A card may be gone by the time its cover arrives
if cover != null && on_done.is_valid():
on_done.call(cover)
_callbacks.erase(uuid)
_download_next()
# A 404 means the server has no cover for it yet
func _on_failed(uuid: String) -> void:
_downloading -= 1
_callbacks.erase(uuid)
_download_next()
+1
View File
@@ -0,0 +1 @@
uid://djxm2kkq8hol1
+140
View File
@@ -0,0 +1,140 @@
class_name FileCache
extends RefCounted
## Downloaded files kept in a folder next to the settings, with an [code]index.json[/code]
## that records for each file when the server last changed it and when it was
## downloaded:
## [codeblock]
## {"Crono.png": {"modified": 1730000000, "downloaded": 1730100000}}
## [/codeblock]
## Used by [CharacterCache] and [CoverCache], which decide when a file is fresh.
const INDEX_FILE_NAME: String = "index.json"
## The change time of a file when the server can't tell it.
const UNKNOWN_TIME: int = -1
## The folder's name in [method Settings.config_dir].
var folder_name: String
# What index.json holds: key -> {"modified": int, "downloaded": int}
var _index: Dictionary = {}
func _init(new_folder_name: String) -> void:
folder_name = new_folder_name
## The folder the files are kept in.
func folder() -> String:
return Settings.config_dir().path_join(folder_name)
## Reads [code]index.json[/code]. A missing or broken index starts empty.
func load_index() -> void:
_index = {}
var path: String = folder().path_join(INDEX_FILE_NAME)
if FileAccess.file_exists(path):
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if parsed is Dictionary:
_index = parsed
## The keys of the files in the index.
func keys() -> Array:
return _index.keys()
## Whether the file for [param key] is in the index and on disk.
func has_file(key: String) -> bool:
return _index.has(key) && FileAccess.file_exists(file_path(key))
## When the server last changed the file for [param key], as recorded when it was
## downloaded, or [constant UNKNOWN_TIME].
func modified(key: String) -> int:
return int(_index.get(key, {}).get("modified", UNKNOWN_TIME))
## How many seconds ago the file for [param key] was downloaded.
func age(key: String) -> float:
return Time.get_unix_time_from_system() - float(_index.get(key, {}).get("downloaded", 0))
## The file for [param key].
func read(key: String) -> PackedByteArray:
return FileAccess.get_file_as_bytes(file_path(key))
## Writes [param bytes] as the file for [param key], last changed on the server at
## [param modified_time], and saves the index. Returns false if it could not be written.
func store(key: String, bytes: PackedByteArray, modified_time: int) -> bool:
DirAccess.make_dir_recursive_absolute(folder())
var file: FileAccess = FileAccess.open(file_path(key), FileAccess.WRITE)
if file == null:
GameLog.warning("Could not cache ", key, " in ", folder_name, ": ",
error_string(FileAccess.get_open_error()))
return false
file.store_buffer(bytes)
file.close()
_index[key] = {
"modified": modified_time,
"downloaded": int(Time.get_unix_time_from_system()),
}
save_index()
return true
## Deletes the file for [param key] and its entry, and saves the index.
func remove(key: String) -> void:
DirAccess.remove_absolute(file_path(key))
if _index.erase(key):
save_index()
## Deletes every file whose key is not in [param kept], e.g. files of things the server
## no longer has.
func keep_only(kept: Dictionary) -> void:
var removed: bool = false
for key: String in _index.keys():
if !kept.has(key):
DirAccess.remove_absolute(file_path(key))
_index.erase(key)
removed = true
if removed:
save_index()
## Deletes every file in the folder, the index too, e.g. to free the space they take.
func clear() -> void:
_index = {}
if !DirAccess.dir_exists_absolute(folder()):
return
for file_name: String in DirAccess.get_files_at(folder()):
DirAccess.remove_absolute(folder().path_join(file_name))
## How many bytes the files in [param dir] take, leaving out the paths in [param skip]
## and any subfolders. 0 when the folder doesn't exist.
static func folder_size(dir: String, skip: PackedStringArray = []) -> int:
if !DirAccess.dir_exists_absolute(dir):
return 0
var total: int = 0
for file_name: String in DirAccess.get_files_at(dir):
var path: String = dir.path_join(file_name)
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file != null && !skip.has(path):
total += file.get_length()
return total
func save_index() -> void:
DirAccess.make_dir_recursive_absolute(folder())
var file: FileAccess = FileAccess.open(folder().path_join(INDEX_FILE_NAME),
FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(_index, "\t", true))
## Where the file for [param key] is. Keys come from the server, so they are made safe
## as file names.
func file_path(key: String) -> String:
return folder().path_join(key.validate_filename())
+1
View File
@@ -0,0 +1 @@
uid://0jfq23w54ihw
+33 -8
View File
@@ -5,23 +5,36 @@ extends RefCounted
## letters also find the letters in order with others in between.
##
## Used by the Search popup and the statistics view, so both find the same soundtracks.
##
## A soundtrack is also found by its alternative names, e.g. "FF7" for "Final Fantasy
## VII". The results are always the real names; [method matched_alternative] tells which
## alternative name matched.
## Each name as a Dictionary with its original, lowercase and cleaned form.
## Each searchable name as a Dictionary: the soundtrack's real name ("original"), the name
## searched in ("text"), and its lowercase and cleaned forms.
var _names: Array[Dictionary] = []
# The alternative name that matched in the last search, by real name
var _matched_alternatives: Dictionary = {}
## Compiled patterns for the letters-in-order search, by cleaned search term.
var _regex_cache: Dictionary = {}
## Replaces the names to search in.
func set_names(names: Array) -> void:
## Replaces the names to search in. [param alternatives] has the other names of a
## soundtrack, by its real name.
func set_names(names: Array, alternatives: Dictionary = {}) -> void:
_names.clear()
_matched_alternatives.clear()
for soundtrack: String in names:
_names.append({
"original": soundtrack,
"lowercase": soundtrack.to_lower(),
"cleaned": clean_term(soundtrack),
})
_add_name(soundtrack, soundtrack)
for alternative: String in alternatives.get(soundtrack, []):
_add_name(soundtrack, alternative)
## The alternative name of [param soundtrack_name] that matched in the last
## [method find_matches], or an empty String when its real name matched.
func matched_alternative(soundtrack_name: String) -> String:
return _matched_alternatives.get(soundtrack_name, "")
## Returns the names matching [param search_text], best matches first: plain
@@ -41,11 +54,14 @@ func find_matches(search_text: String) -> Array[String]:
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null)
var matches: Array[String] = []
_matched_alternatives.clear()
for matches_pass: Callable in passes:
for entry: Dictionary in _names:
if !seen.has(entry.original) && matches_pass.call(entry):
seen[entry.original] = true
matches.append(entry.original)
if entry.text != entry.original:
_matched_alternatives[entry.original] = entry.text
return matches
@@ -107,6 +123,15 @@ static func clean_term(term: String) -> String:
.replace("'n", "and").to_lower()
func _add_name(soundtrack: String, text: String) -> void:
_names.append({
"original": soundtrack,
"text": text,
"lowercase": text.to_lower(),
"cleaned": clean_term(text),
})
# Compiles the letters-in-order pattern for [param search_term], once per term
func _compile_regex_cached(search_term: String) -> RegEx:
if not search_term in _regex_cache:
+174
View File
@@ -0,0 +1,174 @@
class_name Updater
extends Node
## Updates the game in place to the newest release: downloads the new game next to the
## running one, checks it, swaps the files and starts the new version.
##
## The game is one file on Windows and Linux (the game data is embedded). A running .exe
## can't be overwritten on Windows, but it can be renamed, so it is moved aside to
## [code]<exe>.old[/code], which [method remove_old_files] deletes at the next start. On
## Linux the new file simply replaces the running one.
##
## [member executable_path], [member os_name], [member in_editor], [member start_process]
## and [member quit] can be replaced, so tests choose the platform and nothing restarts.
## Where the newest release can be downloaded by hand.
const RELEASES_PAGE: String = "https://gitea.sanplex.xyz/Sansan/MusicPlayer/releases/latest"
## Added to the game's path for the downloaded new version.
const UPDATE_SUFFIX: String = ".update"
## Added to the game's path for the old version, on Windows.
const OLD_SUFFIX: String = ".old"
## rwxr-xr-x, for the new game on Linux.
const EXECUTABLE_PERMISSIONS: int = 0b111_101_101
## Seconds the whole download may take.
const DOWNLOAD_TIMEOUT: float = 900.0
## The running game's file.
var executable_path: String = OS.get_executable_path()
## The platform, as [method OS.get_name] names it.
var os_name: String = OS.get_name()
## Whether the game runs from the Godot editor, where there is no game file to replace.
var in_editor: bool = OS.has_feature("editor")
## Starts a program: [code](path: String, arguments: PackedStringArray) -> int[/code].
var start_process: Callable = OS.create_process
## Quits the game.
var quit: Callable
var _request: HTTPRequest
var _on_progress: Callable
func _init() -> void:
quit = func() -> void: get_tree().quit()
func _process(_delta: float) -> void:
if _request != null && _on_progress.is_valid():
_on_progress.call(_request.get_downloaded_bytes(), _request.get_body_size())
## The release file to download for [param os_name]: the game itself to update in place,
## or, when not [param in_place], what a person downloads, which on Linux is the package
## with an installer. Empty when there is no release for the platform.
static func asset_for(for_os: String, in_place: bool) -> String:
match for_os:
"Windows":
return "windows"
"Linux":
return "linux" if in_place else "linux-package"
return ""
## Why the game can't update itself here, or empty when it can.
func can_update_in_place() -> String:
if in_editor:
return "The game runs from the Godot editor."
if asset_for(os_name, true).is_empty():
return "The game updates itself on Windows and Linux."
var probe: String = executable_path + ".write-test"
var file: FileAccess = FileAccess.open(probe, FileAccess.WRITE)
if file == null:
return "The game's folder %s can't be written to." % executable_path.get_base_dir()
file.close()
DirAccess.remove_absolute(probe)
return ""
## Where the new version is downloaded to, next to the game so it can be renamed into
## place.
func update_path() -> String:
return executable_path + UPDATE_SUFFIX
## Downloads the new game from [param url]. Calls [param on_progress] with the bytes
## downloaded and the total (-1 while unknown) every frame, then [param on_done] with the
## file's path once it is checked, or [param on_failed] with the reason.
func download(url: String, on_progress: Callable, on_done: Callable,
on_failed: Callable) -> void:
cancel()
_on_progress = on_progress
_request = HTTPRequest.new()
_request.download_file = update_path()
_request.timeout = DOWNLOAD_TIMEOUT
add_child(_request)
_request.request_completed.connect(_on_downloaded.bind(on_done, on_failed))
var error: Error = _request.request(url)
if error != OK:
cancel()
on_failed.call("The download could not start: " + error_string(error))
## Stops a download and deletes what was downloaded.
func cancel() -> void:
if _request != null:
_request.cancel_request()
_request.queue_free()
_request = null
DirAccess.remove_absolute(update_path())
## Whether [param path] looks like the game for [param for_os]: a Windows program starts
## with "MZ", a Linux one with the ELF header. An error page saved by mistake does not.
static func check_file(path: String, for_os: String) -> bool:
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null || file.get_length() < 4:
return false
var start: PackedByteArray = file.get_buffer(4)
match for_os:
"Windows":
return start.slice(0, 2) == "MZ".to_ascii_buffer()
"Linux":
return start == PackedByteArray([0x7F, 0x45, 0x4C, 0x46])
return false
## Puts the new game at [param update] in place of the game at [param exe]. On Windows
## the running game is moved aside first, and put back if the new one can't be moved in.
static func install(update: String, exe: String, for_os: String) -> Error:
if for_os == "Windows":
var old: String = exe + OLD_SUFFIX
if FileAccess.file_exists(old):
DirAccess.remove_absolute(old)
var error: Error = DirAccess.rename_absolute(exe, old)
if error != OK:
return error
error = DirAccess.rename_absolute(update, exe)
if error != OK:
DirAccess.rename_absolute(old, exe)
return error
FileAccess.set_unix_permissions(update, EXECUTABLE_PERMISSIONS)
return DirAccess.rename_absolute(update, exe)
## Deletes what an update left next to the game at [param exe]: the old version, and a
## download that was not finished.
static func remove_old_files(exe: String) -> void:
for suffix: String in [OLD_SUFFIX, UPDATE_SUFFIX]:
if FileAccess.file_exists(exe + suffix):
DirAccess.remove_absolute(exe + suffix)
## Starts the game again, now the new version, and quits this one.
func restart() -> void:
start_process.call(executable_path, OS.get_cmdline_args())
quit.call()
func _on_downloaded(result: int, response_code: int, _headers: PackedStringArray,
_body: PackedByteArray, on_done: Callable, on_failed: Callable) -> void:
var expected: int = _request.get_body_size()
_request.queue_free()
_request = null
var reason: String = ""
if result != HTTPRequest.RESULT_SUCCESS:
reason = "The download failed. Check the connection and try again."
elif response_code != 200:
reason = "The download failed: the server answered %d." % response_code
elif expected >= 0 && FileAccess.get_size(update_path()) != expected:
reason = "The download stopped before the end. Try again."
elif !check_file(update_path(), os_name):
reason = "The downloaded file is not the game for %s." % os_name
if !reason.is_empty():
DirAccess.remove_absolute(update_path())
on_failed.call(reason)
return
on_done.call(update_path())
+1
View File
@@ -0,0 +1 @@
uid://bu3bi7qhxr7it
+13
View File
@@ -108,6 +108,10 @@ func get_current_soundtrack_title() -> String:
return song_object_array[currently_playing_song].get_soundtrack_title()
func get_current_soundtrack_uuid() -> String:
return song_object_array[currently_playing_song].get_soundtrack_uuid()
func get_current_song_title() -> String:
return song_object_array[currently_playing_song].get_song_title()
@@ -171,6 +175,15 @@ func store_song(song_object: SongObject, mp3: PackedByteArray) -> void:
## Deletes the files of the songs downloaded so far, except those in [param keep].
## The downloaded song files the playlist uses, which must be kept while it does.
func song_files_in_use() -> PackedStringArray:
var files: PackedStringArray = []
for song_object: SongObject in song_object_array:
if !song_object.song_path.is_empty():
files.append(song_object.song_path)
return files
func clear_song_files(keep: PackedStringArray = []) -> void:
if !DirAccess.dir_exists_absolute(songs_folder()):
return
+15 -3
View File
@@ -6,6 +6,10 @@ extends Node
## Emitted when another theme is used, for code that reads theme colours itself.
signal theme_changed
## How the server picks a random song: any soundtrack then any of its songs, preferring
## soundtracks played less than average, or every song with the same chance.
enum RandomMode { NORMAL, LOW_PLAYED, CLASSIC }
const MAX_NUMBER_OF_PLAYERS: int = 7
## How many letters a search needs before it also finds names with the letters in order
## but apart, e.g. "lttp" for "A Link to the Past". Shorter searches only find names
@@ -26,10 +30,10 @@ const SAVED_SETTINGS: Dictionary[String, Array] = {
"quiz": [
&"auto_repeat_song", &"stop_after_current", &"hide_next_track", &"hide_beginning",
&"hide_for_seconds", &"hide_length", &"hide_ticks", &"winning_score",
&"inspiration_list_speed",
&"inspiration_list_speed", &"short_songs", &"short_song_seconds",
],
"window": [&"fullscreen"],
"app": [&"last_seen_version", &"theme_id"],
"app": [&"last_seen_version", &"theme_id", &"reduce_animations"],
}
## The looks the game can have, picked in Settings: an id that is saved, the name shown
## and the theme file. The first is the default. See themes/README.md.
@@ -37,6 +41,7 @@ const THEMES: Array[Dictionary] = [
{"id": "classic", "name": "Classic", "path": "res://themes/main_theme.tres"},
{"id": "original_web", "name": "Original web", "path": "res://themes/original_web_theme.tres"},
{"id": "nord", "name": "Nord", "path": "res://themes/nord_theme.tres"},
{"id": "christmas", "name": "Christmas", "path": "res://themes/christmas_theme.tres"},
]
## When this environment variable is set, the settings files are kept in that folder
## instead of the user's config folder. The tests set it so they never touch the real files.
@@ -76,7 +81,8 @@ var stop_after_current: bool = true
var auto_repeat_song: bool = true
var hide_next_track: bool = true
var add_to_stats: bool = true
var use_low_played_mode: bool = false
## How the server picks random songs. Not saved, like statistics counting.
var random_mode: RandomMode = RandomMode.NORMAL
var winning_score: int = 20
var fullscreen: bool = false
var inspiration_list_speed: int = 1
@@ -84,6 +90,10 @@ var hide_for_seconds: float = 5.0
var hide_beginning: bool = false
var hide_length: bool = false
var hide_ticks: bool = false
## Plays only [member short_song_seconds] of each song, from a random point, so more
## songs fit in a game.
var short_songs: bool = false
var short_song_seconds: int = 30
var player_array: Array[PlayerObject]
var edit_players: bool = false
var currently_syncing: bool = false
@@ -95,6 +105,8 @@ var queue_ids: Dictionary[String, String] = {}
## The version the game last showed the About popup for. When it is not [member version],
## this version is new and the main window opens the About popup to show what is new.
var last_seen_version: String = ""
## Turns every animation off, so nothing moves. See [Anim].
var reduce_animations: bool = false
## The id of the theme in use, one of [constant THEMES].
var theme_id: String = THEMES[0].id
## The folder songs are played from instead of the server, picked in the settings, or null
+12
View File
@@ -5,6 +5,8 @@ class_name SongObject
var song_number: int
var song_title: String
var soundtrack_title: String
## The UUID of the song's soundtrack on the server, empty for songs from a music folder.
var soundtrack_uuid: String = ""
## Ids of the players who got a point for this song.
var players_given_point: Array[String] = []
@@ -17,6 +19,9 @@ var is_playing: bool = false
var song_path: String = ""
## The song itself, only when it could not be saved to a file.
var song: AudioStream
## Where the part played with Short songs starts, in seconds, or -1 before it is picked.
## Picked once, so restarting and repeating play the same part.
var short_start: float = -1.0
func get_soundtrack_title() -> String:
@@ -33,6 +38,11 @@ func get_song_title() -> String:
return "??????"
## The soundtrack's UUID once the answer is shown, otherwise empty.
func get_soundtrack_uuid() -> String:
return soundtrack_uuid if is_answered else ""
func get_song_info() -> String:
if Settings.is_debug:
return "%d. %s | played: %s | answered: %s" % [(song_number + 1), _full_title(),
@@ -68,6 +78,7 @@ func to_dict() -> Dictionary:
"number": song_number, "soundtrack": soundtrack_title, "song": song_title,
"points": players_given_point.duplicate(), "answered": is_answered,
"played": has_played, "playing": is_playing, "path": song_path,
"soundtrack_uuid": soundtrack_uuid,
}
@@ -80,6 +91,7 @@ static func from_dict(data: Variant) -> SongObject:
song_object.song_number = int(data.number)
song_object.soundtrack_title = str(data.get("soundtrack", ""))
song_object.song_title = str(data.get("song", ""))
song_object.soundtrack_uuid = str(data.get("soundtrack_uuid", ""))
if data.get("points") is Array:
for player_id: Variant in data.points:
song_object.add_point(str(player_id))
+3 -263
View File
@@ -75,266 +75,6 @@ debug/export_console_script=1
[preset.1]
name="macOS"
platform="macOS"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="tests/*, addons/gdUnit4/*, linux/*"
export_path="./MusicPlayer_0.9_Beta.dmg"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
export/distribution_type=1
binary_format/architecture="universal"
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
application/icon=""
application/icon_interpolation=4
application/bundle_identifier="tech.sanplex.musicplayer"
application/signature=""
application/app_category="Music-games"
application/short_version=""
application/version=""
application/copyright=""
application/copyright_localized={}
application/min_macos_version_x86_64="10.12"
application/min_macos_version_arm64="11.00"
application/export_angle=0
display/high_res=true
application/additional_plist_content=""
xcode/platform_build="14C18"
xcode/sdk_version="13.1"
xcode/sdk_build="22C55"
xcode/sdk_name="macosx13.1"
xcode/xcode_version="1420"
xcode/xcode_build="14C18"
codesign/codesign=1
codesign/installer_identity=""
codesign/apple_team_id=""
codesign/identity=""
codesign/entitlements/custom_file=""
codesign/entitlements/allow_jit_code_execution=false
codesign/entitlements/allow_unsigned_executable_memory=false
codesign/entitlements/allow_dyld_environment_variables=false
codesign/entitlements/disable_library_validation=false
codesign/entitlements/audio_input=false
codesign/entitlements/camera=false
codesign/entitlements/location=false
codesign/entitlements/address_book=false
codesign/entitlements/calendars=false
codesign/entitlements/photos_library=false
codesign/entitlements/apple_events=false
codesign/entitlements/debugging=false
codesign/entitlements/app_sandbox/enabled=false
codesign/entitlements/app_sandbox/network_server=false
codesign/entitlements/app_sandbox/network_client=false
codesign/entitlements/app_sandbox/device_usb=false
codesign/entitlements/app_sandbox/device_bluetooth=false
codesign/entitlements/app_sandbox/files_downloads=0
codesign/entitlements/app_sandbox/files_pictures=0
codesign/entitlements/app_sandbox/files_music=0
codesign/entitlements/app_sandbox/files_movies=0
codesign/entitlements/app_sandbox/files_user_selected=0
codesign/entitlements/app_sandbox/helper_executables=[]
codesign/entitlements/additional=""
codesign/custom_options=PackedStringArray()
notarization/notarization=0
privacy/microphone_usage_description=""
privacy/microphone_usage_description_localized={}
privacy/camera_usage_description=""
privacy/camera_usage_description_localized={}
privacy/location_usage_description=""
privacy/location_usage_description_localized={}
privacy/address_book_usage_description=""
privacy/address_book_usage_description_localized={}
privacy/calendar_usage_description=""
privacy/calendar_usage_description_localized={}
privacy/photos_library_usage_description=""
privacy/photos_library_usage_description_localized={}
privacy/desktop_folder_usage_description=""
privacy/desktop_folder_usage_description_localized={}
privacy/documents_folder_usage_description=""
privacy/documents_folder_usage_description_localized={}
privacy/downloads_folder_usage_description=""
privacy/downloads_folder_usage_description_localized={}
privacy/network_volumes_usage_description=""
privacy/network_volumes_usage_description_localized={}
privacy/removable_volumes_usage_description=""
privacy/removable_volumes_usage_description_localized={}
privacy/tracking_enabled=false
privacy/tracking_domains=PackedStringArray()
privacy/collected_data/name/collected=false
privacy/collected_data/name/linked_to_user=false
privacy/collected_data/name/used_for_tracking=false
privacy/collected_data/name/collection_purposes=0
privacy/collected_data/email_address/collected=false
privacy/collected_data/email_address/linked_to_user=false
privacy/collected_data/email_address/used_for_tracking=false
privacy/collected_data/email_address/collection_purposes=0
privacy/collected_data/phone_number/collected=false
privacy/collected_data/phone_number/linked_to_user=false
privacy/collected_data/phone_number/used_for_tracking=false
privacy/collected_data/phone_number/collection_purposes=0
privacy/collected_data/physical_address/collected=false
privacy/collected_data/physical_address/linked_to_user=false
privacy/collected_data/physical_address/used_for_tracking=false
privacy/collected_data/physical_address/collection_purposes=0
privacy/collected_data/other_contact_info/collected=false
privacy/collected_data/other_contact_info/linked_to_user=false
privacy/collected_data/other_contact_info/used_for_tracking=false
privacy/collected_data/other_contact_info/collection_purposes=0
privacy/collected_data/health/collected=false
privacy/collected_data/health/linked_to_user=false
privacy/collected_data/health/used_for_tracking=false
privacy/collected_data/health/collection_purposes=0
privacy/collected_data/fitness/collected=false
privacy/collected_data/fitness/linked_to_user=false
privacy/collected_data/fitness/used_for_tracking=false
privacy/collected_data/fitness/collection_purposes=0
privacy/collected_data/payment_info/collected=false
privacy/collected_data/payment_info/linked_to_user=false
privacy/collected_data/payment_info/used_for_tracking=false
privacy/collected_data/payment_info/collection_purposes=0
privacy/collected_data/credit_info/collected=false
privacy/collected_data/credit_info/linked_to_user=false
privacy/collected_data/credit_info/used_for_tracking=false
privacy/collected_data/credit_info/collection_purposes=0
privacy/collected_data/other_financial_info/collected=false
privacy/collected_data/other_financial_info/linked_to_user=false
privacy/collected_data/other_financial_info/used_for_tracking=false
privacy/collected_data/other_financial_info/collection_purposes=0
privacy/collected_data/precise_location/collected=false
privacy/collected_data/precise_location/linked_to_user=false
privacy/collected_data/precise_location/used_for_tracking=false
privacy/collected_data/precise_location/collection_purposes=0
privacy/collected_data/coarse_location/collected=false
privacy/collected_data/coarse_location/linked_to_user=false
privacy/collected_data/coarse_location/used_for_tracking=false
privacy/collected_data/coarse_location/collection_purposes=0
privacy/collected_data/sensitive_info/collected=false
privacy/collected_data/sensitive_info/linked_to_user=false
privacy/collected_data/sensitive_info/used_for_tracking=false
privacy/collected_data/sensitive_info/collection_purposes=0
privacy/collected_data/contacts/collected=false
privacy/collected_data/contacts/linked_to_user=false
privacy/collected_data/contacts/used_for_tracking=false
privacy/collected_data/contacts/collection_purposes=0
privacy/collected_data/emails_or_text_messages/collected=false
privacy/collected_data/emails_or_text_messages/linked_to_user=false
privacy/collected_data/emails_or_text_messages/used_for_tracking=false
privacy/collected_data/emails_or_text_messages/collection_purposes=0
privacy/collected_data/photos_or_videos/collected=false
privacy/collected_data/photos_or_videos/linked_to_user=false
privacy/collected_data/photos_or_videos/used_for_tracking=false
privacy/collected_data/photos_or_videos/collection_purposes=0
privacy/collected_data/audio_data/collected=false
privacy/collected_data/audio_data/linked_to_user=false
privacy/collected_data/audio_data/used_for_tracking=false
privacy/collected_data/audio_data/collection_purposes=0
privacy/collected_data/gameplay_content/collected=false
privacy/collected_data/gameplay_content/linked_to_user=false
privacy/collected_data/gameplay_content/used_for_tracking=false
privacy/collected_data/gameplay_content/collection_purposes=0
privacy/collected_data/customer_support/collected=false
privacy/collected_data/customer_support/linked_to_user=false
privacy/collected_data/customer_support/used_for_tracking=false
privacy/collected_data/customer_support/collection_purposes=0
privacy/collected_data/other_user_content/collected=false
privacy/collected_data/other_user_content/linked_to_user=false
privacy/collected_data/other_user_content/used_for_tracking=false
privacy/collected_data/other_user_content/collection_purposes=0
privacy/collected_data/browsing_history/collected=false
privacy/collected_data/browsing_history/linked_to_user=false
privacy/collected_data/browsing_history/used_for_tracking=false
privacy/collected_data/browsing_history/collection_purposes=0
privacy/collected_data/search_hhistory/collected=false
privacy/collected_data/search_hhistory/linked_to_user=false
privacy/collected_data/search_hhistory/used_for_tracking=false
privacy/collected_data/search_hhistory/collection_purposes=0
privacy/collected_data/user_id/collected=false
privacy/collected_data/user_id/linked_to_user=false
privacy/collected_data/user_id/used_for_tracking=false
privacy/collected_data/user_id/collection_purposes=0
privacy/collected_data/device_id/collected=false
privacy/collected_data/device_id/linked_to_user=false
privacy/collected_data/device_id/used_for_tracking=false
privacy/collected_data/device_id/collection_purposes=0
privacy/collected_data/purchase_history/collected=false
privacy/collected_data/purchase_history/linked_to_user=false
privacy/collected_data/purchase_history/used_for_tracking=false
privacy/collected_data/purchase_history/collection_purposes=0
privacy/collected_data/product_interaction/collected=false
privacy/collected_data/product_interaction/linked_to_user=false
privacy/collected_data/product_interaction/used_for_tracking=false
privacy/collected_data/product_interaction/collection_purposes=0
privacy/collected_data/advertising_data/collected=false
privacy/collected_data/advertising_data/linked_to_user=false
privacy/collected_data/advertising_data/used_for_tracking=false
privacy/collected_data/advertising_data/collection_purposes=0
privacy/collected_data/other_usage_data/collected=false
privacy/collected_data/other_usage_data/linked_to_user=false
privacy/collected_data/other_usage_data/used_for_tracking=false
privacy/collected_data/other_usage_data/collection_purposes=0
privacy/collected_data/crash_data/collected=false
privacy/collected_data/crash_data/linked_to_user=false
privacy/collected_data/crash_data/used_for_tracking=false
privacy/collected_data/crash_data/collection_purposes=0
privacy/collected_data/performance_data/collected=false
privacy/collected_data/performance_data/linked_to_user=false
privacy/collected_data/performance_data/used_for_tracking=false
privacy/collected_data/performance_data/collection_purposes=0
privacy/collected_data/other_diagnostic_data/collected=false
privacy/collected_data/other_diagnostic_data/linked_to_user=false
privacy/collected_data/other_diagnostic_data/used_for_tracking=false
privacy/collected_data/other_diagnostic_data/collection_purposes=0
privacy/collected_data/environment_scanning/collected=false
privacy/collected_data/environment_scanning/linked_to_user=false
privacy/collected_data/environment_scanning/used_for_tracking=false
privacy/collected_data/environment_scanning/collection_purposes=0
privacy/collected_data/hands/collected=false
privacy/collected_data/hands/linked_to_user=false
privacy/collected_data/hands/used_for_tracking=false
privacy/collected_data/hands/collection_purposes=0
privacy/collected_data/head/collected=false
privacy/collected_data/head/linked_to_user=false
privacy/collected_data/head/used_for_tracking=false
privacy/collected_data/head/collection_purposes=0
privacy/collected_data/other_data_types/collected=false
privacy/collected_data/other_data_types/linked_to_user=false
privacy/collected_data/other_data_types/used_for_tracking=false
privacy/collected_data/other_data_types/collection_purposes=0
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="#!/usr/bin/env bash
unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\"
open \"{temp_dir}/{exe_name}.app\" --args {cmd_args}"
ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash
kill $(pgrep -x -f \"{temp_dir}/{exe_name}.app/Contents/MacOS/{exe_name} {cmd_args}\")
rm -rf \"{temp_dir}\""
dotnet/include_scripts_content=false
dotnet/include_debug_symbols=true
dotnet/embed_build_outputs=false
application/min_macos_version="10.12"
debug/export_console_script=1
notarization/apple_team_id=""
[preset.2]
name="Web"
platform="Web"
runnable=false
@@ -353,7 +93,7 @@ encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.2.options]
[preset.1.options]
custom_template/debug=""
custom_template/release=""
@@ -380,7 +120,7 @@ dotnet/include_scripts_content=false
dotnet/include_debug_symbols=true
dotnet/embed_build_outputs=false
[preset.3]
[preset.2]
name="Linux"
platform="Linux"
@@ -400,7 +140,7 @@ encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.3.options]
[preset.2.options]
custom_template/debug=""
custom_template/release=""
+10
View File
@@ -12,6 +12,7 @@
[ext_resource type="PackedScene" path="res://statistics/StatisticsView.tscn" id="statistics_view_scene"]
[ext_resource type="PackedScene" path="res://popups/SoundtrackStatsPopup.tscn" id="soundtrack_stats_popup_scene"]
[ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://popups/SettingsPopup.tscn" id="settings_popup_scene"]
[ext_resource type="PackedScene" path="res://popups/UpdatePopup.tscn" id="update_popup_scene"]
[node name="MainWindow" type="Control" unique_id=407149696]
layout_mode = 3
@@ -109,3 +110,12 @@ offset_left = 610.0
offset_top = 140.0
offset_right = 610.0
offset_bottom = 140.0
[node name="UpdatePopup" parent="." instance=ExtResource("update_popup_scene")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 640.0
offset_top = 390.0
offset_right = 640.0
offset_bottom = 390.0
+13 -5
View File
@@ -40,19 +40,27 @@ theme_type_variation = &"HighlightLarge"
text = "DEBUG"
vertical_alignment = 1
[node name="UpdateLabel" type="Label" parent="." unique_id=1959221258]
[node name="UpdateBox" type="HBoxContainer" parent="." unique_id=1959221258]
visible = false
layout_mode = 0
offset_left = 1096.0
offset_top = 12.0
offset_right = 1432.0
offset_bottom = 50.0
mouse_filter = 1
theme_type_variation = &"HighlightMedium"
mouse_filter = 2
alignment = 2
[node name="UpdateButton" type="Button" parent="UpdateBox"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 0
mouse_default_cursor_shape = 2
theme_type_variation = &"UpdateButton"
text = "Version 0.0.0 is available"
horizontal_alignment = 2
vertical_alignment = 1
text_overrun_behavior = 3
clip_text = true
alignment = 2
[node name="OfflineLabel" type="Label" parent="." unique_id=1959221259]
visible = false
+44 -1
View File
@@ -5,6 +5,9 @@ extends Control
##
## Everything that is not part of a quiz lives here.
## How far a tab slides in from the side when it is chosen.
const TAB_SLIDE: float = 60.0
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
var popup_backdrop: PopupBackdrop
@@ -17,12 +20,15 @@ var popup_backdrop: PopupBackdrop
@onready var about_popup: AboutPopup = %AboutPopup
@onready var log_popup: LogPopup = %LogPopup
@onready var settings_popup: SettingsPopup = %SettingsPopup
@onready var update_popup: UpdatePopup = %UpdatePopup
@onready var statistics_view: StatisticsView = $StatisticsView
@onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup
func _ready() -> void:
popup_backdrop = PopupBackdrop.add_to(self, _popups())
# Every button grows a little under the mouse and shrinks when clicked
ButtonBounce.add_to_every_button(get_tree())
if OS.has_feature("macos"):
GameLog.debug("is_mac")
quiz.search_pressed.connect(show_search)
@@ -32,8 +38,11 @@ func _ready() -> void:
top_bar.about_pressed.connect(show_about)
top_bar.qr_pressed.connect(show_qr)
top_bar.settings_pressed.connect(settings_popup.open)
top_bar.update_pressed.connect(update_popup.show_update)
update_popup.restarting.connect(quiz.save_now)
settings_popup.server_changed.connect(quiz.on_server_changed)
settings_popup.characters_changed.connect(quiz.character_select_popup.load_characters)
settings_popup.clear_cache_requested.connect(clear_cache)
settings_popup.visibility_changed.connect(_on_settings_popup_visibility_changed)
sync_popup.sync_started.connect(_on_sync_started)
@@ -42,13 +51,41 @@ func _ready() -> void:
quiz.server_changed.connect(_on_music_source_changed)
quiz.event_logged.connect(log_popup.add_log_row)
statistics_view.soundtrack_selected.connect(soundtrack_stats_popup.show_soundtrack)
soundtrack_stats_popup.covers = statistics_view.covers
quiz.now_playing.covers = statistics_view.covers
search_popup.soundtracks_loaded.connect(_remember_cover_times)
# New alternative names are found by the statistics search and the Search popup
soundtrack_stats_popup.alternative_names_changed.connect(
statistics_view.update_alternative_names)
soundtrack_stats_popup.alternative_names_changed.connect(
func(_soundtrack: Soundtrack) -> void: search_popup.get_list_of_soundtracks())
soundtrack_stats_popup.cover_changed.connect(statistics_view.update_cover)
# An update leaves the old version next to the game, to delete once the new one runs
if !OS.has_feature("editor"):
Updater.remove_old_files(OS.get_executable_path())
# A saved quiz may have been played from a music folder
top_bar.update_offline_state()
search_popup.get_list_of_soundtracks()
show_about_if_new_version()
## Deletes the cached character pictures and covers. The characters are loaded again at
## once, so the character select has its pictures; covers come when they are shown.
func clear_cache() -> void:
quiz.character_select_popup.character_cache.clear()
statistics_view.covers.clear()
quiz.character_select_popup.load_characters()
# The quiz finds a soundtrack's cover by the cover times in the soundtrack list
func _remember_cover_times(soundtracks: Array[Soundtrack]) -> void:
var times: Dictionary = {}
for soundtrack: Soundtrack in soundtracks:
times[soundtrack.uuid] = soundtrack.cover_modified
statistics_view.covers.set_cover_times(times)
# After another server or a music folder was picked. Statistics need the server.
func _on_music_source_changed() -> void:
top_bar.update_offline_state()
@@ -79,7 +116,7 @@ func _input(event: InputEvent) -> void:
func _popups() -> Array[BasePopup]:
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup,
soundtrack_stats_popup, settings_popup]
soundtrack_stats_popup, settings_popup, update_popup]
# How many songs to download ahead can be changed in the settings. Waiting until the
@@ -114,8 +151,14 @@ func show_qr() -> void:
func show_tab(tab: StringName) -> void:
GameLog.debug("show_tab ", tab)
top_bar.select_tab(tab)
var was_statistics: bool = statistics_view.visible
statistics_view.visible = tab == &"statistics"
quiz.visible = !statistics_view.visible
# The tab that comes in slides from its side: statistics from the right
if statistics_view.visible && !was_statistics:
Anim.slide_in(statistics_view, Vector2(TAB_SLIDE, 0))
elif quiz.visible && was_statistics:
Anim.slide_in(quiz, Vector2(-TAB_SLIDE, 0))
if statistics_view.visible:
statistics_view.refresh()
+30 -6
View File
@@ -16,10 +16,19 @@ signal about_pressed
signal qr_pressed
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
signal tab_selected(tab: StringName)
## Emitted when "Version X is available" is clicked, with the newest version.
signal update_pressed(latest_version: String)
## Space between the message in the middle and the tabs and buttons around it.
const MIDDLE_GAP: float = 16.0
## The newest release the server knows of, once asked, or empty.
var latest_version: String = ""
@onready var sync_button: Button = %SyncButton
@onready var debug_label: Label = $DebugLabel
@onready var update_label: Label = $UpdateLabel
@onready var update_box: HBoxContainer = $UpdateBox
@onready var update_button: Button = %UpdateButton
@onready var offline_label: Label = $OfflineLabel
@onready var qr_button: Button = %QRButton
@onready var quiz_tab: Button = %QuizTab
@@ -36,6 +45,11 @@ func _ready() -> void:
qr_button.pressed.connect(qr_pressed.emit)
quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz"))
statistics_tab.pressed.connect(tab_selected.emit.bind(&"statistics"))
update_button.pressed.connect(func() -> void: update_pressed.emit(latest_version))
# The buttons are as wide as the theme makes them, so the message fits between
statistics_tab.item_rect_changed.connect(_fit_middle)
sync_button.item_rect_changed.connect(_fit_middle)
_fit_middle()
update_offline_state()
check_for_update()
@@ -51,14 +65,14 @@ func update_offline_state() -> void:
if offline:
offline_label.text = "Offline: " + Settings.music_folder.display_name()
offline_label.tooltip_text = Settings.music_folder.path
update_label.visible = false
update_box.visible = false
## Asks the server for the newest MusicPlayer release and tells the user when it is newer
## than this one. Not asked while a music folder is used.
func check_for_update() -> void:
if Settings.is_offline():
update_label.visible = false
update_box.visible = false
return
MusicServerClient.fetch_latest_client_version(show_latest_version)
@@ -68,10 +82,20 @@ func check_for_update() -> void:
func show_latest_version(latest: Variant) -> void:
var newer: bool = latest is String && is_newer_version(latest, Settings.version) \
&& !Settings.is_offline()
update_label.visible = newer
update_box.visible = newer
if newer:
update_label.text = "Version %s is available" % latest
update_label.tooltip_text = "You have %s" % Settings.version
latest_version = latest
update_button.text = "Version %s is available" % latest
update_button.tooltip_text = "You have %s. Click to update." % Settings.version
# Fits "Version X is available" and the offline label between the tabs and the buttons
func _fit_middle() -> void:
var left: float = statistics_tab.position.x + statistics_tab.size.x + MIDDLE_GAP
var right: float = sync_button.global_position.x - global_position.x - MIDDLE_GAP
for middle: Control in [update_box, offline_label]:
middle.position.x = left
middle.size.x = maxf(right - left, 0.0)
## Whether version [param latest] is newer than [param current], comparing numbers such as
+83 -36
View File
@@ -5,6 +5,11 @@ extends Control
##
## The buttons grow a little under the mouse and the play button pops when it switches
## between play and pause, see [ButtonBounce].
##
## With Short songs on ([member Settings.short_songs]) only a part of each quiz song plays:
## [member Settings.short_song_seconds] from a random point between
## [constant SHORT_START_MIN] and [constant SHORT_START_MAX] of the song. The progress bar,
## the time and seeking all stay within that part, and its end counts as the song ending.
signal update_song_list
signal play_next_song
@@ -16,6 +21,15 @@ var playback_position: float
var stream: AudioStream
var song_finished: bool = false
## Where in the song the part played with Short songs may start, as a share of its length.
## Many songs have a quiet intro, so the start is never right at the beginning.
const SHORT_START_MIN: float = 0.1
const SHORT_START_MAX: float = 0.6
# The part of the song that plays: its start in seconds, and its length (0: to the end)
var _clip_start: float = 0.0
var _clip_length: float = 0.0
@onready var play_button: Button = %PlayButton
@onready var restart_button: Button = %RestartButton
@onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer
@@ -38,14 +52,46 @@ func _ready() -> void:
func _process(_delta: float) -> void:
if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused:
if !Settings.hide_beginning \
|| audio_player.get_playback_position() > Settings.hide_for_seconds:
progress_slider.value = audio_player.get_playback_position()
if stream != null:
music_time_label.text = format_text(progress_slider.value, stream.get_length())
else:
music_time_label.text = format_text(progress_slider.value, 0.0)
if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused \
&& audio_player.playing:
var position: float = position_in_part()
if _clip_length > 0.0 && position >= _clip_length:
# The short part has played, which counts as the song ending
audio_player.stop()
progress_slider.value = _clip_length
_on_finished()
return
if !Settings.hide_beginning || position > Settings.hide_for_seconds:
progress_slider.value = position
music_time_label.text = format_text(progress_slider.value, part_length())
## How long the part of the song that plays is: the short part, or the whole song.
func part_length() -> float:
if _clip_length > 0.0:
return _clip_length
return stream.get_length() if stream != null else 0.0
## How far into the part that plays the song is, in seconds.
func position_in_part() -> float:
return audio_player.get_playback_position() - _clip_start
## Plays only part of [param song_object]'s song when Short songs is on and the song is
## longer than the part. The part's start is picked once per song.
func choose_part(song_object: SongObject, song: AudioStream) -> void:
_clip_start = 0.0
_clip_length = 0.0
var length: float = song.get_length()
if !Settings.short_songs || song_object == null || length <= Settings.short_song_seconds:
return
if song_object.short_start < 0.0:
var latest: float = minf(length * SHORT_START_MAX, length - Settings.short_song_seconds)
var earliest: float = minf(length * SHORT_START_MIN, latest)
song_object.short_start = randf_range(earliest, latest)
_clip_start = minf(song_object.short_start, length - Settings.short_song_seconds)
_clip_length = Settings.short_song_seconds
## Whether the play button shows pause, because a song is playing.
@@ -61,6 +107,14 @@ func _show_playing(playing: bool) -> void:
ButtonBounce.pop(play_button)
# The progress bar and time show the length of the part that plays
func _show_length() -> void:
var length: float = part_length()
progress_slider.max_value = round(length)
progress_slider.tick_count = 0 if Settings.hide_ticks else round(length / 60)
music_time_label.text = format_text(progress_slider.value, length)
func format_time(time: float) -> String:
var total_seconds: int = roundi(time)
@warning_ignore("integer_division")
@@ -87,11 +141,7 @@ func play_or_pause() -> void:
song_finished = false
audio_player.seek(playback_position)
GameLog.debug("continue")
progress_slider.max_value = round(stream.get_length())
if Settings.hide_ticks:
progress_slider.tick_count = 0
else:
progress_slider.tick_count = round(stream.get_length() / 60)
_show_length()
else:
pause()
@@ -102,15 +152,15 @@ func pause() -> void:
_show_playing(false)
## Plays the song, or its short part, from the beginning.
func restart() -> void:
audio_player.stop()
audio_player.stream_paused = false
song_finished = false
progress_slider.value = 0
playback_position = audio_player.get_playback_position()
audio_player.seek(playback_position)
playback_position = _clip_start
_show_playing(true)
audio_player.play()
audio_player.play(_clip_start)
func _on_finished() -> void:
@@ -127,16 +177,18 @@ func _on_drag_started() -> void:
func _on_drag_ended(_changed: bool) -> void:
audio_player.seek(progress_slider.value)
playback_position = progress_slider.value
audio_player.seek(_clip_start + progress_slider.value)
playback_position = _clip_start + progress_slider.value
is_changing = false
## Jumps [param new_position] seconds forward, or back when negative, staying within the
## part that plays.
func seek(new_position: float) -> void:
progress_slider.value += new_position
is_changing = true
audio_player.seek(progress_slider.value)
playback_position = progress_slider.value
audio_player.seek(_clip_start + progress_slider.value)
playback_position = _clip_start + progress_slider.value
is_changing = false
@@ -173,6 +225,9 @@ func play_sound(sound_name: AudioStream) -> void:
func play_song(song: AudioStream) -> void:
# Sounds such as the winning song always play whole
_clip_start = 0.0
_clip_length = 0.0
audio_player.stream = song
audio_player.play()
sound_player.stop()
@@ -180,12 +235,7 @@ func play_song(song: AudioStream) -> void:
_show_playing(true)
stream = audio_player.stream
progress_slider.value = 0
progress_slider.max_value = round(stream.get_length())
if Settings.hide_ticks:
progress_slider.tick_count = 0
else:
progress_slider.tick_count = round(stream.get_length() / 60)
music_time_label.text = format_text(progress_slider.value, stream.get_length())
_show_length()
## Puts song [param song_object_no] in the player without playing it, e.g. the song that
@@ -196,12 +246,11 @@ func load_song_object(song_object_no: int) -> void:
return
audio_player.stream = song
stream = song
choose_part(Playlist.song_object_array[song_object_no], song)
song_finished = true
_show_playing(false)
progress_slider.value = 0
progress_slider.max_value = round(stream.get_length())
progress_slider.tick_count = 0 if Settings.hide_ticks else round(stream.get_length() / 60)
music_time_label.text = format_text(progress_slider.value, stream.get_length())
_show_length()
func play_song_object(song_object_no: int) -> void:
@@ -214,18 +263,16 @@ func play_song_object(song_object_no: int) -> void:
audio_player.stop()
await get_tree().create_timer(0.5).timeout
audio_player.stream = song
choose_part(Playlist.song_object_array[song_object_no], song)
sound_player.stop()
audio_player.play()
audio_player.play(_clip_start)
song_finished = false
_show_playing(true)
# A new song makes the play button pop, also when it was already playing
ButtonBounce.pop(play_button)
stream = audio_player.stream
progress_slider.value = 0
progress_slider.max_value = round(stream.get_length())
if Settings.hide_ticks:
progress_slider.tick_count = 0
else:
progress_slider.tick_count = round(stream.get_length() / 60)
music_time_label.text = format_text(progress_slider.value, stream.get_length())
_show_length()
Playlist.set_currently_playing_song(song_object_no)
Playlist.unset_is_playing()
if !Settings.hide_next_track:
+51 -4
View File
@@ -164,6 +164,13 @@ layout_mode = 2
focus_mode = 0
text = "Fullscreen"
[node name="ReduceAnimationsButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "Reduce animations"
[node name="CacheRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer" unique_id=658210492]
layout_mode = 2
@@ -198,12 +205,31 @@ action_mode = 0
theme_type_variation = &"StepButton"
text = "+1"
[node name="LowPlayedCheckButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true
[node name="RandomModeRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/RandomModeRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Random songs"
[node name="RandomModeButton" type="OptionButton" parent="SettingsPanel/VBoxContainer/RandomModeRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(180, 0)
layout_mode = 2
tooltip_text = "Normal: a random soundtrack, then one of its songs.
Low played: the same, but soundtracks played less than average come up more often.
Classic: every song has the same chance, whatever its soundtrack."
focus_mode = 0
action_mode = 0
text = "Prefer less played soundtracks"
selected = 0
item_count = 3
popup/item_0/text = "Normal"
popup/item_0/id = 0
popup/item_1/text = "Low played"
popup/item_1/id = 1
popup/item_2/text = "Classic"
popup/item_2/id = 2
[node name="AddToDatabaseCheckButton" type="CheckButton" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true
@@ -227,6 +253,27 @@ layout_mode = 2
focus_mode = 0
text = "Open folder"
[node name="ClearCacheRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/ClearCacheRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Downloaded pictures and songs"
[node name="CacheSizeLabel" type="Label" parent="SettingsPanel/VBoxContainer/ClearCacheRow"]
unique_name_in_owner = true
layout_mode = 2
[node name="ClearCacheButton" type="Button" parent="SettingsPanel/VBoxContainer/ClearCacheRow"]
unique_name_in_owner = true
layout_mode = 2
tooltip_text = "Deletes the downloaded character pictures, covers and songs.
They are downloaded again when needed. Songs in the current quiz are kept."
focus_mode = 0
text = "Clear cache"
[node name="FolderDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Choose a music folder"
+256 -10
View File
@@ -30,19 +30,87 @@ theme_type_variation = &"Title"
horizontal_alignment = 1
text_overrun_behavior = 3
[node name="SummaryLabel" type="Label" parent="StatsPanel" unique_id=1213000004]
[node name="CoverFrame" type="Panel" parent="StatsPanel"]
unique_name_in_owner = true
clip_children = 2
layout_mode = 0
offset_left = 20.0
offset_top = 64.0
offset_right = 160.0
offset_bottom = 274.0
mouse_filter = 2
theme_type_variation = &"SoundtrackCover"
[node name="CoverPlaceholder" type="Label" parent="StatsPanel/CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_type_variation = &"TitleMedium"
horizontal_alignment = 1
vertical_alignment = 1
[node name="CoverPicture" type="TextureRect" parent="StatsPanel/CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="Info" type="VBoxContainer" parent="StatsPanel"]
layout_mode = 0
offset_left = 180.0
offset_top = 64.0
offset_right = 680.0
offset_bottom = 264.0
[node name="SummaryLabel" type="Label" parent="StatsPanel/Info" unique_id=1213000004]
unique_name_in_owner = true
layout_mode = 2
autowrap_mode = 3
[node name="NamesTitle" type="Label" parent="StatsPanel/Info"]
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "Also known as"
[node name="NamesLabel" type="Label" parent="StatsPanel/Info"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"TextMedium"
autowrap_mode = 3
text_overrun_behavior = 3
max_lines_visible = 3
[node name="InfoButtons" type="HBoxContainer" parent="StatsPanel/Info"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="EditNamesButton" type="Button" parent="StatsPanel/Info/InfoButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Edit names"
[node name="ChangeCoverButton" type="Button" parent="StatsPanel/Info/InfoButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Change cover"
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
unique_name_in_owner = true
layout_mode = 0
offset_left = 20.0
offset_top = 54.0
offset_right = 680.0
offset_bottom = 80.0
horizontal_alignment = 1
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
layout_mode = 0
offset_left = 20.0
offset_top = 92.0
offset_top = 280.0
offset_right = 680.0
offset_bottom = 784.0
horizontal_scroll_mode = 0
@@ -51,3 +119,181 @@ horizontal_scroll_mode = 0
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="NamesEditor" type="VBoxContainer" parent="StatsPanel"]
unique_name_in_owner = true
visible = false
layout_mode = 0
offset_left = 20.0
offset_top = 280.0
offset_right = 680.0
offset_bottom = 784.0
theme_override_constants/separation = 10
[node name="EditorTitle" type="Label" parent="StatsPanel/NamesEditor"]
layout_mode = 2
theme_type_variation = &"Title"
text = "Alternative names"
[node name="EditorHelp" type="Label" parent="StatsPanel/NamesEditor"]
layout_mode = 2
autowrap_mode = 3
text = "Other names the soundtrack goes by, e.g. short names or the name in another language. Search finds it by any of them."
[node name="EditorScroll" type="ScrollContainer" parent="StatsPanel/NamesEditor"]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="EditorNames" type="VBoxContainer" parent="StatsPanel/NamesEditor/EditorScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="AddRow" type="HBoxContainer" parent="StatsPanel/NamesEditor"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="NewNameField" type="LineEdit" parent="StatsPanel/NamesEditor/AddRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Another name"
[node name="AddNameButton" type="Button" parent="StatsPanel/NamesEditor/AddRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Add"
[node name="EditorStatus" type="Label" parent="StatsPanel/NamesEditor"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
autowrap_mode = 3
[node name="EditorButtons" type="HBoxContainer" parent="StatsPanel/NamesEditor"]
layout_mode = 2
theme_override_constants/separation = 12
alignment = 2
[node name="CancelNamesButton" type="Button" parent="StatsPanel/NamesEditor/EditorButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Cancel"
[node name="SaveNamesButton" type="Button" parent="StatsPanel/NamesEditor/EditorButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Save"
[node name="CoverEditor" type="VBoxContainer" parent="StatsPanel"]
unique_name_in_owner = true
visible = false
layout_mode = 0
offset_left = 20.0
offset_top = 280.0
offset_right = 680.0
offset_bottom = 784.0
theme_override_constants/separation = 10
[node name="CoverEditorTitle" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_type_variation = &"Title"
text = "Cover"
[node name="CoverEditorHelp" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
autowrap_mode = 3
text = "The server finds covers by itself. When it found the wrong one, or none, choose a picture of your own or the cover of a game on SteamGridDB or an anime on AniList. The server keeps a cover set here until it is removed."
[node name="PictureRow" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="ChoosePictureButton" type="Button" parent="StatsPanel/CoverEditor/PictureRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Choose picture…"
[node name="RemoveCoverButton" type="Button" parent="StatsPanel/CoverEditor/PictureRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Remove cover"
[node name="SourceTitle" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "Pick by ID"
[node name="SourceHelp" type="Label" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_type_variation = &"TextMedium"
autowrap_mode = 3
text = "The ID is the number in the game's or anime's web address."
[node name="SourceRow" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="CoverSourceButton" type="OptionButton" parent="StatsPanel/CoverEditor/SourceRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
selected = 0
item_count = 2
popup/item_0/text = "SteamGridDB"
popup/item_0/id = 0
popup/item_1/text = "AniList"
popup/item_1/id = 1
[node name="CoverIdField" type="LineEdit" parent="StatsPanel/CoverEditor/SourceRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "ID"
max_length = 50
[node name="UseSourceButton" type="Button" parent="StatsPanel/CoverEditor/SourceRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Use"
[node name="CoverEditorSpace" type="Control" parent="StatsPanel/CoverEditor"]
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
[node name="CoverStatus" type="Label" parent="StatsPanel/CoverEditor"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
autowrap_mode = 3
[node name="CoverEditorButtons" type="HBoxContainer" parent="StatsPanel/CoverEditor"]
layout_mode = 2
alignment = 2
[node name="CloseCoverButton" type="Button" parent="StatsPanel/CoverEditor/CoverEditorButtons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Done"
[node name="PictureDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Choose a cover picture"
initial_position = 2
size = Vector2i(800, 500)
ok_button_text = "Open"
file_mode = 0
access = 2
filters = PackedStringArray("*.jpg, *.jpeg, *.png, *.webp ; Pictures")
use_native_dialog = true
+4 -3
View File
@@ -1,6 +1,7 @@
[gd_scene format=3 uid="uid://dpdem7pdxweb5"]
[ext_resource type="Script" uid="uid://c3ejukwth512a" path="res://popups/sync_popup.gd" id="1_8ba47"]
[ext_resource type="Script" path="res://common/animated_progress_bar.gd" id="progress_bar_script"]
[node name="SyncPopup" type="Control" unique_id=1302914356 node_paths=PackedStringArray("panel")]
layout_mode = 3
@@ -62,11 +63,11 @@ layout_mode = 2
[node name="SyncProgressBar" type="ProgressBar" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer" unique_id=719135231]
unique_name_in_owner = true
custom_minimum_size = Vector2(1300, 0)
custom_minimum_size = Vector2(1300, 32)
layout_mode = 2
size_flags_horizontal = 6
step = 1.0
rounded = true
theme_type_variation = &"SyncProgressBar"
script = ExtResource("progress_bar_script")
[node name="SyncHBoxContainer" type="HBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=1156646398]
unique_name_in_owner = true
+95
View File
@@ -0,0 +1,95 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://popups/update_popup.gd" id="popup_script"]
[ext_resource type="Script" path="res://common/updater.gd" id="updater_script"]
[node name="UpdatePopup" type="Control" node_paths=PackedStringArray("panel")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("popup_script")
panel = NodePath("UpdatePanel")
[node name="UpdatePanel" type="Panel" parent="."]
unique_name_in_owner = true
layout_mode = 0
offset_right = 640.0
offset_bottom = 300.0
theme_type_variation = &"PopupBackground"
[node name="Rows" type="VBoxContainer" parent="UpdatePanel"]
unique_name_in_owner = true
layout_mode = 0
offset_left = 24.0
offset_top = 16.0
offset_right = 616.0
offset_bottom = 284.0
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"Title"
text = "Version 0.0.0 is available"
horizontal_alignment = 1
[node name="InfoLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
layout_mode = 2
autowrap_mode = 3
[node name="ReasonLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
visible = false
layout_mode = 2
autowrap_mode = 3
[node name="DownloadProgress" type="ProgressBar" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
visible = false
layout_mode = 2
max_value = 1.0
show_percentage = false
[node name="StatusLabel" type="Label" parent="UpdatePanel/Rows"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
autowrap_mode = 3
[node name="Space" type="Control" parent="UpdatePanel/Rows"]
custom_minimum_size = Vector2(0, 4)
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
[node name="Buttons" type="HBoxContainer" parent="UpdatePanel/Rows"]
layout_mode = 2
theme_override_constants/separation = 12
alignment = 2
[node name="NotNowButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Not now"
[node name="BrowserButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Download in browser"
[node name="UpdateNowButton" type="Button" parent="UpdatePanel/Rows/Buttons"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Update and restart"
[node name="Updater" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("updater_script")
+51 -4
View File
@@ -4,32 +4,79 @@ extends Control
##
## Clicking outside [member panel] closes it. Popups that need to prepare or clean up
## override [method open] or [method close] and call [code]super()[/code].
##
## The panel fades in while growing from [constant START_SCALE] of its size, and closing
## does the reverse; the popup is hidden once that has finished. See [Anim].
## Emitted when the popup opens, also when it is opened again while closing.
signal opening
## Emitted when the popup starts closing, before it is hidden, so the backdrop can fade
## out with it.
signal closing
## How big the panel starts when it opens, and ends when it closes.
const START_SCALE: float = 0.95
## The visible part of the popup. Clicks outside it close the popup.
@export var panel: Control
## True while the popup is fading out after [method close].
var is_closing: bool = false
## Shows the popup.
func open() -> void:
is_closing = false
visible = true
opening.emit()
var open_tween: Tween = Anim.tween(panel, &"popup")
panel.modulate.a = 1.0
panel.scale = Vector2.ONE
if open_tween == null:
return
Anim.center_pivot(panel)
panel.modulate.a = 0.0
panel.scale = Vector2.ONE * START_SCALE
open_tween.set_parallel()
open_tween.tween_property(panel, "modulate:a", 1.0, Anim.NORMAL)
open_tween.tween_property(panel, "scale", Vector2.ONE, Anim.NORMAL) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
## Hides the popup.
func close() -> void:
visible = false
if !visible || is_closing:
return
is_closing = true
closing.emit()
var close_tween: Tween = Anim.tween(panel, &"popup")
if close_tween == null:
_hide()
return
Anim.center_pivot(panel)
close_tween.set_parallel()
close_tween.tween_property(panel, "modulate:a", 0.0, Anim.FAST)
close_tween.tween_property(panel, "scale", Vector2.ONE * START_SCALE, Anim.FAST)
close_tween.chain().tween_callback(_hide)
## Opens the popup if it is closed and closes it if it is open.
func toggle() -> void:
if visible:
if visible && !is_closing:
close()
else:
open()
func _hide() -> void:
is_closing = false
visible = false
panel.modulate.a = 1.0
panel.scale = Vector2.ONE
func _input(event: InputEvent) -> void:
if !visible || !(event is InputEventMouseButton) || !event.pressed:
if !visible || is_closing || !(event is InputEventMouseButton) || !event.pressed:
return
var click: InputEventMouseButton = panel.make_input_local(event)
if !Rect2(Vector2.ZERO, panel.size).has_point(click.position):
+23
View File
@@ -23,6 +23,8 @@ static func add_to(parent: Control, popups: Array[BasePopup]) -> PopupBackdrop:
for popup: BasePopup in popups:
parent.move_child(popup, -1)
popup.visibility_changed.connect(backdrop._update)
popup.closing.connect(backdrop._on_popup_closing)
popup.opening.connect(backdrop._on_popup_opening)
backdrop._update()
return backdrop
@@ -34,4 +36,25 @@ func _notification(what: int) -> void:
func _update() -> void:
var was_visible: bool = visible
visible = _popups.any(func(popup: BasePopup) -> bool: return popup.visible)
if visible && !was_visible:
Anim.fade_in(self)
# A popup opened again while closing stops the backdrop fading out
func _on_popup_opening() -> void:
var fade_tween: Tween = Anim.tween(self, &"fade")
if fade_tween == null || modulate.a >= 1.0:
modulate.a = 1.0
return
fade_tween.tween_property(self, "modulate:a", 1.0, Anim.FAST)
# Fades out with the last open popup, which hides the backdrop when it has closed
func _on_popup_closing() -> void:
if _popups.any(func(popup: BasePopup) -> bool: return popup.visible && !popup.is_closing):
return
var fade_tween: Tween = Anim.tween(self, &"fade")
if fade_tween != null:
fade_tween.tween_property(self, "modulate:a", 0.0, Anim.FAST)
+43 -3
View File
@@ -15,6 +15,13 @@ const MARGIN_BOTTOM: float = 44.0
## About how many song numbers fit along the bottom.
const MAX_SONG_LABELS: int = 20
## How much of the lines is drawn, from 0 (none) to 1 (all), so they can be drawn from
## left to right.
var progress: float = 1.0:
set(value):
progress = value
queue_redraw()
var _history: ScoreHistory
var _winning_score: int = 0
@@ -33,6 +40,32 @@ func show_history(history: ScoreHistory, winning_score: int) -> void:
queue_redraw()
## Draws the lines from left to right over [param duration] seconds.
func draw_lines_in(duration: float = 1.2) -> void:
var draw_tween: Tween = Anim.tween(self, &"draw")
progress = 1.0
if draw_tween == null:
return
progress = 0.0
draw_tween.tween_property(self, "progress", 1.0, duration) \
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
## The part of [param points] left of [param limit_x], cut where the line crosses it.
static func clip_line(points: PackedVector2Array, limit_x: float) -> PackedVector2Array:
var clipped: PackedVector2Array = []
for i: int in points.size():
if points[i].x <= limit_x:
clipped.append(points[i])
continue
if i > 0 && points[i - 1].x < limit_x:
var before: Vector2 = points[i - 1]
var weight: float = (limit_x - before.x) / (points[i].x - before.x)
clipped.append(before.lerp(points[i], weight))
break
return clipped
## The colour of the player at [param index] in the player list.
func player_color(index: int) -> Color:
return get_theme_color(StringName("player_%d" % (index % 7 + 1)), THEME_TYPE)
@@ -63,13 +96,20 @@ func _draw() -> void:
var line_width: int = get_theme_constant(&"line_width", THEME_TYPE)
var dot_radius: int = get_theme_constant(&"dot_radius", THEME_TYPE)
var limit_x: float = lerpf(MARGIN_LEFT, size.x - MARGIN_RIGHT, progress)
for index: int in _history.players.size():
var color: Color = player_color(index)
var scores: Array[int] = _history.scores_after_songs(_history.players[index].id)
draw_polyline(line_points(index), color, line_width, true)
var points: PackedVector2Array = clip_line(line_points(index), limit_x)
if points.size() >= 2:
draw_polyline(points, color, line_width, true)
for song: int in range(1, scores.size()):
if scores[song] > scores[song - 1]:
draw_circle(_point(song, scores[song], index), dot_radius, color)
var dot: Vector2 = _point(song, scores[song], index)
if scores[song] > scores[song - 1] && dot.x <= limit_x:
draw_circle(dot, dot_radius, color)
# The final scores are written once the lines have reached them
if progress < 1.0:
return
var label_positions: Array[Vector2] = score_label_positions()
for index: int in _history.players.size():
draw_string(font, label_positions[index],
+43 -11
View File
@@ -1,7 +1,11 @@
class_name SearchPopup
extends BasePopup
## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and
## highlights the letters that matched.
## highlights the letters that matched. Soundtracks are also found by their alternative
## names; the alternative name that matched is shown after the real name.
## Emitted with every soundtrack on the server when the list has loaded.
signal soundtracks_loaded(list: Array[Soundtrack])
## Every soundtrack on the server, sorted by name.
var soundtracks: Array = []
@@ -10,6 +14,8 @@ var _matcher: SoundtrackSearch = SoundtrackSearch.new()
# One row per soundtrack, in the order of soundtracks. Rows are reused between searches.
var _rows: Array[PanelContainer] = []
var _index_of: Dictionary = {}
# The other names of each soundtrack, by its real name
var _alternatives: Dictionary = {}
@onready var search_panel: Panel = %SearchPanel
@onready var search_field: LineEdit = %SearchField
@@ -54,6 +60,9 @@ func search() -> void:
var row: PanelContainer = _rows[shown[position]]
if row.get_index() != position:
search_results.move_child(row, position)
# Rows that come back with the search fade in
if !row.visible:
Anim.fade_in(row, Anim.FAST)
row.visible = true
row.theme_type_variation = &"ListRowOdd" if position % 2 != 0 else &"ListRowEven"
_show_name(row, soundtracks[shown[position]], search_text)
@@ -83,19 +92,26 @@ func get_list_of_soundtracks() -> void:
if Settings.is_offline():
set_soundtracks(Settings.music_folder.soundtrack_names())
return
var handle_soundtracks: Callable = func handle_soundtracks(array: Variant) -> void:
if typeof(array) == TYPE_ARRAY:
set_soundtracks(array)
else:
GameLog.warning("Unexpected data")
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
var with_names: Callable = func with_names(list: Array[Soundtrack]) -> void:
var names: Array[String] = []
var alternatives: Dictionary = {}
for soundtrack: Soundtrack in list:
names.append(soundtrack.name)
alternatives[soundtrack.name] = soundtrack.alternative_names
names.sort_custom(func(a: String, b: String) -> bool:
return a.naturalnocasecmp_to(b) < 0)
set_soundtracks(names, alternatives)
soundtracks_loaded.emit(list)
MusicServerClient.fetch_soundtracks(with_names, _load_names_only)
## Replaces the searchable soundtracks and shows them, keeping the current search.
func set_soundtracks(list: Array) -> void:
## [param alternatives] has the other names of a soundtrack, by its real name.
func set_soundtracks(list: Array, alternatives: Dictionary = {}) -> void:
soundtracks = []
soundtracks.append_array(list)
_matcher.set_names(soundtracks)
_alternatives = alternatives
_matcher.set_names(soundtracks, alternatives)
_index_of.clear()
for index: int in range(soundtracks.size() - 1, -1, -1):
_index_of[soundtracks[index]] = index
@@ -142,9 +158,25 @@ func _make_row() -> PanelContainer:
return row
# Servers without /soundtracks only list the names
func _load_names_only() -> void:
MusicServerClient.fetch_all_soundtracks_ordered(func(array: Variant) -> void:
if typeof(array) == TYPE_ARRAY:
set_soundtracks(array)
else:
GameLog.warning("Unexpected data"))
func _show_name(row: PanelContainer, soundtrack_name: String, search_text: String) -> void:
var text: String = highlighted_name(soundtrack_name, search_text,
get_theme_color(&"font_color", &"HighlightLarge"))
var highlight: Color = get_theme_color(&"font_color", &"HighlightLarge")
var text: String = highlighted_name(soundtrack_name, search_text, highlight)
# Found by another name: show which, dimmer, after the real name
var alternative: String = _matcher.matched_alternative(soundtrack_name) \
if !search_text.is_empty() else ""
if !alternative.is_empty():
text += " [color=#%s]%s[/color]" % [
get_theme_color(&"default_color", &"SongStatsSecondary").to_html(false),
highlighted_name(alternative, search_text, highlight)]
var label: RichTextLabel = row.get_child(0)
# Setting the same text again would make the label lay it out again for nothing
if label.text != text:
+57 -8
View File
@@ -1,9 +1,9 @@
class_name SettingsPopup
extends BasePopup
## The application settings: which MusicServer or music folder to play from, the theme,
## fullscreen, how many songs to download ahead, whether to prefer less played soundtracks
## and whether 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.
## fullscreen, fewer animations, how many songs to download ahead, how random songs are
## picked and whether 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
## pressed or the game is closed. The settings that need the server are off meanwhile.
@@ -18,6 +18,8 @@ signal server_changed
## Emitted when a folder of characters is picked or Use server is pressed for them.
signal characters_changed
## Emitted when Clear cache is pressed, for the caches to delete their files.
signal clear_cache_requested
## Space between the rows and the panel's edges.
const PADDING: Vector2 = Vector2(20, 12)
@@ -25,11 +27,12 @@ const PADDING: Vector2 = Vector2(20, 12)
@onready var settings_panel: Panel = %SettingsPanel
@onready var select_server_button: OptionButton = %SelectServerButton
@onready var fullscreen_button: CheckButton = %FullscreenButton
@onready var reduce_animations_button: CheckButton = %ReduceAnimationsButton
@onready var theme_button: OptionButton = %ThemeButton
@onready var cache_label: Label = %CacheLabel
@onready var lower_cache_button: Button = %LowerCacheButton
@onready var increase_cache_button: Button = %IncreaseCacheButton
@onready var low_played_button: CheckButton = %LowPlayedCheckButton
@onready var random_mode_button: OptionButton = %RandomModeButton
@onready var add_to_database_button: CheckButton = %AddToDatabaseCheckButton
@onready var rows: VBoxContainer = %VBoxContainer
@onready var folder_label: Label = %FolderLabel
@@ -45,6 +48,8 @@ const PADDING: Vector2 = Vector2(20, 12)
@onready var characters_error_label: Label = %CharactersErrorLabel
@onready var character_folder_dialog: FileDialog = %CharacterFolderDialog
@onready var open_config_folder_button: Button = %OpenConfigFolderButton
@onready var cache_size_label: Label = %CacheSizeLabel
@onready var clear_cache_button: Button = %ClearCacheButton
## Shows a folder in the system's file manager. The tests replace it, so no file manager
## opens while they run.
@@ -66,6 +71,8 @@ func _ready() -> void:
cache_label.text = str(Playlist.number_of_tracks_to_preload)
fullscreen_button.pressed.connect(fullscreen)
reduce_animations_button.button_pressed = Settings.reduce_animations
reduce_animations_button.toggled.connect(set_reduce_animations)
choose_folder_button.pressed.connect(folder_dialog.popup_centered)
folder_dialog.dir_selected.connect(use_music_folder)
use_server_button.pressed.connect(use_server)
@@ -74,12 +81,13 @@ func _ready() -> void:
use_server_characters_button.pressed.connect(use_server_characters)
open_config_folder_button.pressed.connect(open_config_folder)
open_config_folder_button.tooltip_text = Settings.config_dir()
clear_cache_button.pressed.connect(clear_cache)
play_order_button.item_selected.connect(_on_play_order_selected)
lower_cache_button.pressed.connect(lower_cache)
increase_cache_button.pressed.connect(increase_cache)
select_server_button.item_selected.connect(select_server)
low_played_button.toggled.connect(
func(on: bool) -> void: Settings.use_low_played_mode = on)
random_mode_button.item_selected.connect(
func(mode: int) -> void: Settings.random_mode = mode as Settings.RandomMode)
add_to_database_button.toggled.connect(func(on: bool) -> void: Settings.add_to_stats = on)
visibility_changed.connect(_on_visibility_changed)
# Alt+Enter switches fullscreen from the main window, which resizes the window
@@ -93,6 +101,12 @@ func select_theme(index: int) -> void:
Settings.apply_theme(Settings.THEMES[index].id)
## Turns every animation off or on, and saves it.
func set_reduce_animations(on: bool) -> void:
Settings.reduce_animations = on
Settings.save_settings()
func fullscreen() -> void:
Settings.set_fullscreen(!Settings.fullscreen)
@@ -127,6 +141,40 @@ func open_config_folder() -> void:
show_folder.call(dir)
## Deletes the downloaded character pictures, covers and songs, except the songs the
## quiz uses, and shows how much space that freed.
func clear_cache() -> void:
var freed: int = cache_size()
clear_cache_requested.emit()
Playlist.clear_song_files(Playlist.song_files_in_use())
cache_size_label.text = "Cleared " + size_text(freed)
clear_cache_button.disabled = true
## Shows how much space the downloaded files take.
func show_cache_size() -> void:
var size: int = cache_size()
cache_size_label.text = size_text(size)
clear_cache_button.disabled = size == 0
## How many bytes the downloaded character pictures, covers and songs take, leaving out
## the songs the quiz uses, which clearing keeps.
static func cache_size() -> int:
return FileCache.folder_size(CharacterCache.folder()) \
+ FileCache.folder_size(CoverCache.folder()) \
+ FileCache.folder_size(Playlist.songs_folder(), Playlist.song_files_in_use())
## [param bytes] for people, e.g. "350 KB" or "14.2 MB".
static func size_text(bytes: int) -> String:
if bytes == 0:
return "Nothing"
if bytes < 1 << 20:
return "%d KB" % ceili(bytes / 1024.0)
return "%.1f MB" % (bytes / float(1 << 20))
## Reads the characters from the folder at [param path] instead of the server. A folder
## without pictures is not used, and the popup says so.
func use_character_folder(path: String) -> void:
@@ -180,7 +228,8 @@ func _use_server(new_server: int) -> void:
func _on_visibility_changed() -> void:
if visible:
_update_fullscreen_button()
low_played_button.set_pressed_no_signal(Settings.use_low_played_mode)
show_cache_size()
random_mode_button.select(Settings.random_mode)
add_to_database_button.set_pressed_no_signal(Settings.add_to_stats)
else:
folder_error_label.visible = false
@@ -215,7 +264,7 @@ func _show_music_source() -> void:
characters_label.text = "From " + folder.display_name()
else:
characters_label.text = "From the server"
for control: BaseButton in [select_server_button, low_played_button, add_to_database_button]:
for control: BaseButton in [select_server_button, random_mode_button, add_to_database_button]:
control.disabled = folder != null
_fit_to_rows()
+288 -10
View File
@@ -1,27 +1,305 @@
class_name SoundtrackStatsPopup
extends BasePopup
## Shows a soundtrack's play statistics and every song in it, most played first.
## Opened by clicking a card in the statistics view.
## Shows a soundtrack's cover, play statistics and alternative names, and every song in
## it, most played first. Opened by clicking a card in the statistics view.
##
## Edit names opens an editor for the alternative names in place of the song list. Saving
## sends them to the server, which search then finds the soundtrack by.
##
## Change cover opens the cover menu there instead: upload a picture, pick a game's or
## anime's cover by its ID on SteamGridDB or AniList, or remove the cover so the server
## looks for one again.
## Emitted when the alternative names of [param soundtrack] have been saved.
signal alternative_names_changed(soundtrack: Soundtrack)
## Emitted when the cover of the soundtrack [param uuid] changed, with its new cover time
## [param modified], 0 when it was removed.
signal cover_changed(uuid: String, modified: int)
## The largest picture the server takes as a cover.
const MAX_COVER_BYTES: int = 10 << 20
## The covers on disk, shared with the statistics view. No cover is shown without it.
var covers: CoverCache
## The soundtrack shown.
var soundtrack: SoundtrackWithSongs
# The names in the editor, saved only when Save is pressed
var _edited_names: Array[String] = []
@onready var title_label: Label = %TitleLabel
@onready var summary_label: Label = %SummaryLabel
@onready var cover_picture: TextureRect = %CoverPicture
@onready var cover_placeholder: Label = %CoverPlaceholder
@onready var names_label: Label = %NamesLabel
@onready var edit_names_button: Button = %EditNamesButton
@onready var song_scroll: ScrollContainer = %ScrollContainer
@onready var song_list: VBoxContainer = %SongList
@onready var names_editor: VBoxContainer = %NamesEditor
@onready var editor_names: VBoxContainer = %EditorNames
@onready var new_name_field: LineEdit = %NewNameField
@onready var add_name_button: Button = %AddNameButton
@onready var editor_status: Label = %EditorStatus
@onready var save_names_button: Button = %SaveNamesButton
@onready var cancel_names_button: Button = %CancelNamesButton
@onready var change_cover_button: Button = %ChangeCoverButton
@onready var cover_editor: VBoxContainer = %CoverEditor
@onready var choose_picture_button: Button = %ChoosePictureButton
@onready var remove_cover_button: Button = %RemoveCoverButton
@onready var cover_source_button: OptionButton = %CoverSourceButton
@onready var cover_id_field: LineEdit = %CoverIdField
@onready var use_source_button: Button = %UseSourceButton
@onready var cover_status: Label = %CoverStatus
@onready var close_cover_button: Button = %CloseCoverButton
@onready var picture_dialog: FileDialog = %PictureDialog
## Shows [param soundtrack] and opens the popup.
func show_soundtrack(soundtrack: SoundtrackWithSongs) -> void:
title_label.text = soundtrack.soundtrack_name
var summary: PackedStringArray = [SoundtrackCard.played_text(soundtrack.soundtrack_played)]
var last_played: String = SoundtrackCard.last_played_text(soundtrack.soundtrack_last_played)
func _ready() -> void:
edit_names_button.pressed.connect(edit_names)
add_name_button.pressed.connect(add_name)
new_name_field.text_submitted.connect(func(_text: String) -> void: add_name())
save_names_button.pressed.connect(save_names)
cancel_names_button.pressed.connect(stop_editing)
change_cover_button.pressed.connect(change_cover)
choose_picture_button.pressed.connect(picture_dialog.popup_centered)
picture_dialog.file_selected.connect(upload_picture)
remove_cover_button.pressed.connect(remove_cover)
use_source_button.pressed.connect(use_cover_source)
cover_id_field.text_submitted.connect(func(_text: String) -> void: use_cover_source())
close_cover_button.pressed.connect(stop_editing)
## Shows [param shown] and opens the popup.
func show_soundtrack(shown: SoundtrackWithSongs) -> void:
soundtrack = shown
stop_editing()
title_label.text = shown.soundtrack_name
var summary: PackedStringArray = [SoundtrackCard.played_text(shown.soundtrack_played)]
var last_played: String = SoundtrackCard.last_played_text(shown.soundtrack_last_played)
if !last_played.is_empty():
summary.append(last_played)
summary.append("%d %s" % [soundtrack.songs.size(),
"song" if soundtrack.songs.size() == 1 else "songs"])
summary.append("%d %s" % [shown.songs.size(),
"song" if shown.songs.size() == 1 else "songs"])
summary_label.text = " · ".join(summary)
_show_names()
cover_placeholder.text = SoundtrackCard.initials(shown.soundtrack_name)
_load_cover()
Settings.delete_children(song_list)
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(soundtrack.songs)
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(shown.songs)
for row: int in songs.size():
song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row,
&"TextLarge"))
open()
## Opens the editor for the alternative names, in place of the song list.
func edit_names() -> void:
_edited_names.assign(soundtrack.alternative_names)
new_name_field.text = ""
editor_status.visible = false
_show_edited_names()
_show_editor(names_editor)
new_name_field.grab_focus()
## Opens the cover menu, in place of the song list.
func change_cover() -> void:
cover_id_field.text = ""
cover_status.visible = false
_set_cover_busy(false)
_show_editor(cover_editor)
## Closes the editors, without saving the names.
func stop_editing() -> void:
_show_editor(null)
_set_saving(false)
_set_cover_busy(false)
## Uploads the picture at [param path] as the soundtrack's cover.
func upload_picture(path: String) -> void:
var picture: PackedByteArray = FileAccess.get_file_as_bytes(path)
var content_type: String = picture_type(picture)
if picture.size() > MAX_COVER_BYTES:
_show_cover_status("The picture is larger than %d MB." % (MAX_COVER_BYTES >> 20))
return
if content_type.is_empty() || CharacterCard.image_from_bytes(picture) == null:
_show_cover_status("Only JPEG, PNG and WebP pictures can be used.")
return
_set_cover_busy(true, "Uploading the picture…")
MusicServerClient.upload_soundtrack_cover(soundtrack.soundtrack_uuid, picture,
content_type, _on_cover_saved.bind("The new cover is saved."), _on_cover_not_saved)
## Makes the cover of the game or anime with the ID in the field, on the site picked,
## the soundtrack's cover.
func use_cover_source() -> void:
var id: String = cover_id_field.text.strip_edges()
if id.is_empty() || use_source_button.disabled:
return
var source: String = MusicServerClient.COVER_SOURCES[cover_source_button.selected]
_set_cover_busy(true, "Getting the cover from %s…"
% cover_source_button.get_item_text(cover_source_button.selected))
MusicServerClient.set_soundtrack_cover_source(soundtrack.soundtrack_uuid, source, id,
_on_cover_saved.bind("The new cover is saved."), _on_cover_not_saved)
## Removes the soundtrack's cover, so the server looks for one again.
func remove_cover() -> void:
_set_cover_busy(true, "Removing the cover…")
MusicServerClient.delete_soundtrack_cover(soundtrack.soundtrack_uuid,
_on_cover_saved.bind("The cover is removed. The server will look for a new one."),
_on_cover_not_saved)
## The type of [param picture] to upload it as, e.g. [code]"image/png"[/code], or empty
## when it is not a JPEG, PNG or WebP picture.
static func picture_type(picture: PackedByteArray) -> String:
if picture.size() >= 4 && picture.slice(0, 4) == PackedByteArray([0x89, 0x50, 0x4E, 0x47]):
return "image/png"
if picture.size() >= 3 && picture.slice(0, 3) == PackedByteArray([0xFF, 0xD8, 0xFF]):
return "image/jpeg"
if picture.size() >= 12 && picture.slice(8, 12).get_string_from_ascii() == "WEBP":
return "image/webp"
return ""
## Adds the name in the field to the edited names, unless it is empty, the soundtrack's
## own name or already there.
func add_name() -> void:
var new_name: String = new_name_field.text.strip_edges()
if new_name.is_empty() || new_name.to_lower() == soundtrack.soundtrack_name.to_lower() \
|| _edited_names.any(func(name: String) -> bool:
return name.to_lower() == new_name.to_lower()):
return
_edited_names.append(new_name)
new_name_field.text = ""
_show_edited_names()
## Takes [param removed] out of the edited names.
func remove_name(removed: String) -> void:
_edited_names.erase(removed)
_show_edited_names()
## Sends the edited names to the server. The editor closes when they are saved.
func save_names() -> void:
# A name typed but not added yet is meant to be saved too
add_name()
_set_saving(true)
MusicServerClient.set_alternative_names(soundtrack.soundtrack_uuid, _edited_names,
_on_names_saved, _on_names_not_saved)
func _on_names_saved(updated: Soundtrack) -> void:
soundtrack.alternative_names = updated.alternative_names
_show_names()
stop_editing()
alternative_names_changed.emit(updated)
func _on_names_not_saved() -> void:
_set_saving(false)
editor_status.text = "Could not save the names. Try again."
editor_status.visible = true
func _set_saving(saving: bool) -> void:
save_names_button.disabled = saving
cancel_names_button.disabled = saving
save_names_button.text = "Saving…" if saving else "Save"
func _show_names() -> void:
var names: Array[String] = soundtrack.alternative_names
names_label.text = ", ".join(names) if !names.is_empty() else "No other names yet"
func _show_edited_names() -> void:
Settings.delete_children(editor_names)
for row: int in _edited_names.size():
var line: HBoxContainer = HBoxContainer.new()
var label: Label = Label.new()
label.text = _edited_names[row]
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
line.add_child(label)
var remove: Button = Button.new()
remove.text = "Remove"
remove.focus_mode = Control.FOCUS_NONE
remove.pressed.connect(remove_name.bind(_edited_names[row]))
line.add_child(remove)
var list_row: PanelContainer = PanelContainer.new()
list_row.theme_type_variation = &"ListRowOdd" if row % 2 != 0 else &"ListRowEven"
list_row.add_child(line)
editor_names.add_child(list_row)
# Shows [param editor] in place of the song list, or the song list when it is null
func _show_editor(editor: Control) -> void:
names_editor.visible = editor == names_editor
cover_editor.visible = editor == cover_editor
song_scroll.visible = editor == null
edit_names_button.disabled = editor != null
change_cover_button.disabled = editor != null
# The cover changed on the server; its new time tells whether it has one and fetches it
func _on_cover_saved(message: String) -> void:
var uuid: String = soundtrack.soundtrack_uuid
var with_time: Callable = func with_time(modified: int) -> void:
if soundtrack == null || soundtrack.soundtrack_uuid != uuid:
return
soundtrack.cover_modified = modified
_load_cover()
_set_cover_busy(false)
_show_cover_status(message)
cover_changed.emit(uuid, modified)
if covers != null:
covers.refresh(uuid, with_time)
else:
with_time.call(soundtrack.cover_modified)
func _on_cover_not_saved() -> void:
_set_cover_busy(false)
var reason: String = MusicServerClient.last_error
_show_cover_status("Could not change the cover: %s."
% (reason if !reason.is_empty() else "the server could not be reached"))
func _set_cover_busy(busy: bool, message: String = "") -> void:
for button: Button in [choose_picture_button, use_source_button, close_cover_button]:
button.disabled = busy
remove_cover_button.disabled = busy || soundtrack == null \
|| soundtrack.cover_modified <= 0
cover_source_button.disabled = busy
if busy:
_show_cover_status(message)
func _show_cover_status(message: String) -> void:
cover_status.text = message
cover_status.visible = true
func _load_cover() -> void:
_show_cover(null)
if covers != null:
covers.fetch_cover(soundtrack.soundtrack_uuid, soundtrack.cover_modified,
_show_cover_of.bind(soundtrack.soundtrack_uuid))
func _show_cover(cover: Image) -> void:
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
cover_placeholder.visible = cover == null
# Covers arrive later; one for a soundtrack no longer shown is left out
func _show_cover_of(cover: Image, uuid: String) -> void:
if soundtrack != null && soundtrack.soundtrack_uuid == uuid:
_show_cover(cover)
+16 -4
View File
@@ -17,7 +17,7 @@ var has_synced: bool = false
@onready var no_changes_label: Label = %NoChangesLabel
@onready var progress_hbox: HBoxContainer = %ProgressHBoxContainer
@onready var sync_hbox: HBoxContainer = %SyncHBoxContainer
@onready var sync_progress_bar: ProgressBar = %SyncProgressBar
@onready var sync_progress_bar: AnimatedProgressBar = %SyncProgressBar
@onready var soundtracks_added_vbox: VBoxContainer = %SoundtracksAddedVBox
@onready var soundtracks_added_separator: VSeparator = %SoundtracksAddedSeparator
@onready var soundtracks_readded_vbox: VBoxContainer = %SoundtracksReAddedVBox
@@ -56,7 +56,7 @@ func show_section_if_has_data(data: Variant, vbox: VBoxContainer, separator: VSe
func start_sync() -> void:
GameLog.debug("start_sync")
visible = true
open()
if has_synced:
# Already synced, just show the results
show_sync_result()
@@ -77,13 +77,24 @@ func _on_sync_timeout() -> void:
GameLog.debug("data_received: ", data_received)
if data_received != null:
if data_received.has("progress"):
sync_progress_bar.value = int(data_received.progress)
sync_progress_bar.move_to(int(data_received.progress))
sync_time.text = data_received.time_spent if data_received.has("time_spent") else ""
else:
on_sync_finished(data_received)
_finish_progress(data_received)
MusicServerClient.fetch_sync_progress(get_progress)
# Fills the bar and lets it pop before the result takes its place
func _finish_progress(data_received: Dictionary) -> void:
sync_timer.stop()
if Anim.enabled() && progress_hbox.visible:
sync_progress_bar.move_to(sync_progress_bar.max_value)
await get_tree().create_timer(Anim.SLOW).timeout
Anim.pop(sync_progress_bar, 1.04, Anim.SLOW)
await get_tree().create_timer(Anim.SLOW).timeout
on_sync_finished(data_received)
func on_sync_finished(data_received: Dictionary) -> void:
has_synced = true
reset_sync_result()
@@ -218,6 +229,7 @@ func show_sync_result() -> void:
func show_sync_progress() -> void:
sync_title_label.text = "Sync in Progress"
progress_hbox.visible = true
sync_progress_bar.wait()
no_changes_label.visible = false
sync_hbox.visible = false
caught_errors_vbox.visible = false
+156
View File
@@ -0,0 +1,156 @@
class_name UpdatePopup
extends BasePopup
## Gets the newest version of the game, opened from "Version X is available" in the top
## bar.
##
## Update and restart downloads the new game, puts it in place of this one and starts it;
## the quiz is saved first, so it continues where it was. Where the game can't update
## itself, e.g. when it runs from the editor, only Download in browser is offered, which
## opens the download (or, on other platforms, the release page) in the browser.
## Emitted just before the game quits to start the new version, so the quiz can be saved.
signal restarting
## Space between the rows and the panel's edges.
const PADDING: Vector2 = Vector2(24, 16)
## Opens a link in the browser. The tests replace it, so no browser opens.
var open_url: Callable = OS.shell_open
## The version offered.
var latest_version: String = ""
var _downloading: bool = false
# Where the panel's middle is, so it stays centred as it grows and shrinks
var _panel_middle_y: float = 0.0
@onready var title_label: Label = %TitleLabel
@onready var info_label: Label = %InfoLabel
@onready var reason_label: Label = %ReasonLabel
@onready var download_progress: ProgressBar = %DownloadProgress
@onready var status_label: Label = %StatusLabel
@onready var not_now_button: Button = %NotNowButton
@onready var browser_button: Button = %BrowserButton
@onready var update_now_button: Button = %UpdateNowButton
@onready var updater: Updater = %Updater
@onready var rows: VBoxContainer = %Rows
func _ready() -> void:
not_now_button.pressed.connect(close)
browser_button.pressed.connect(download_in_browser)
update_now_button.pressed.connect(update_now)
_panel_middle_y = panel.position.y + panel.size.y / 2
## Offers version [param latest] and opens the popup.
func show_update(latest: String) -> void:
latest_version = latest
title_label.text = "Version %s is available" % latest
info_label.text = "You have %s. The game restarts with the new version, and the quiz " \
% Settings.version + "continues where it was."
var reason: String = updater.can_update_in_place()
update_now_button.visible = reason.is_empty()
reason_label.visible = !reason.is_empty()
reason_label.text = reason + " Download it in the browser instead."
info_label.visible = reason.is_empty()
download_progress.visible = false
status_label.visible = false
_set_busy(false)
_fit_to_rows()
open()
## Stops a download that is under way when the popup is closed.
func close() -> void:
if _downloading:
updater.cancel()
_downloading = false
_set_busy(false)
super()
## Downloads the new version, puts it in place and restarts the game.
func update_now() -> void:
_set_busy(true)
_show_status("Getting the download link…")
MusicServerClient.fetch_client_download_url(Updater.asset_for(updater.os_name, true),
_download, _on_failed.bind("The server has no download for this version."))
## Opens the new version's download in the browser, or the release page when there is
## none for this platform.
func download_in_browser() -> void:
var asset: String = Updater.asset_for(updater.os_name, false)
if asset.is_empty():
open_url.call(Updater.RELEASES_PAGE)
return
_show_status("Opening the download in the browser…")
MusicServerClient.fetch_client_download_url(asset,
func(url: String) -> void: open_url.call(url),
func() -> void: open_url.call(Updater.RELEASES_PAGE))
func _download(url: String) -> void:
_downloading = true
download_progress.value = 0.0
download_progress.visible = true
_show_status("Downloading…")
_fit_to_rows()
updater.download(url, _on_progress, _install, _on_failed)
func _on_progress(done: int, total: int) -> void:
if total > 0:
download_progress.value = float(done) / float(total)
status_label.text = "Downloading… %s of %s" % [SettingsPopup.size_text(done),
SettingsPopup.size_text(total)]
func _install(path: String) -> void:
_downloading = false
_show_status("Installing…")
var error: Error = Updater.install(path, updater.executable_path, updater.os_name)
if error != OK:
DirAccess.remove_absolute(path)
_on_failed("The game could not be replaced: %s." % error_string(error))
return
_show_status("Restarting…")
restarting.emit()
updater.restart()
func _on_failed(reason: String) -> void:
_downloading = false
download_progress.visible = false
_set_busy(false)
_show_status(reason)
_fit_to_rows()
func _set_busy(busy: bool) -> void:
update_now_button.disabled = busy
browser_button.disabled = busy
# Closing while busy stops the download
not_now_button.text = "Cancel" if busy else "Not now"
func _show_status(message: String) -> void:
status_label.text = message
if !status_label.visible:
status_label.visible = true
_fit_to_rows()
# Makes the panel as tall as its rows, around the same middle
func _fit_to_rows() -> void:
# A popup that was never shown hasn't laid out its rows, and wrapped text measures
# its height from its width
var width: float = panel.size.x - PADDING.x * 2
for label: Label in [info_label, reason_label, status_label]:
label.size = Vector2(width, 0)
label.update_minimum_size()
var height: float = rows.get_combined_minimum_size().y + PADDING.y * 2
panel.size.y = height
panel.position.y = floorf(_panel_middle_y - height / 2)
rows.position = PADDING
rows.size = Vector2(panel.size.x - PADDING.x * 2, height - PADDING.y * 2)
+1
View File
@@ -0,0 +1 @@
uid://dupwb11ovvn7j
+52 -1
View File
@@ -2,7 +2,12 @@ class_name WinnerPopup
extends BasePopup
## Shows who won, with their character, where the other players placed, and how the game
## went: a graph of everyone's score after each song and a few facts about it.
##
## The winners' pictures bounce in, confetti falls over the panel and the graph draws its
## lines from left to right. See [Anim].
## How many pieces of confetti fly when someone wins.
const CONFETTI_AMOUNT: int = 160
## Size of the coloured square in front of each name in the legend.
const LEGEND_SQUARE_SIZE: Vector2 = Vector2(14, 14)
@@ -29,7 +34,7 @@ func show_winner(winner: PlayerObject) -> void:
## Shows [param winners], who share the top score, with everyone else's placing below.
## Tied players get the same placing. With no players, it says the quiz is over.
func show_winners(winners: Array[PlayerObject]) -> void:
visible = true
open()
winner_label.text = winners_text(winners)
_show_pictures(winners)
@@ -49,6 +54,7 @@ func show_winners(winners: Array[PlayerObject]) -> void:
players_list.add_child(label)
show_history(ScoreHistory.from_playlist(Settings.player_array))
_celebrate()
## The headline for [param winners], e.g. "Crono won with 5 points!!" or "Crono and
@@ -80,6 +86,51 @@ static func placing(player: PlayerObject) -> int:
return place
# The winners' pictures bounce in, confetti flies and the graph draws itself
func _celebrate() -> void:
for picture: Node in winner_picture.get_parent().get_children():
if picture is TextureRect && (picture as TextureRect).visible:
Anim.bounce_in(picture)
score_graph.draw_lines_in()
if Anim.enabled():
_throw_confetti()
# A burst of confetti from the top of the panel, in the players' graph colours
func _throw_confetti() -> void:
var old: Node = winner_panel.get_node_or_null("Confetti")
if old != null:
old.free()
var confetti: CPUParticles2D = CPUParticles2D.new()
confetti.name = "Confetti"
confetti.position = Vector2(winner_panel.size.x / 2, 0)
confetti.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE
confetti.emission_rect_extents = Vector2(winner_panel.size.x / 2, 4)
confetti.amount = CONFETTI_AMOUNT
confetti.lifetime = 2.6
confetti.one_shot = true
confetti.explosiveness = 0.85
confetti.direction = Vector2.DOWN
confetti.spread = 35.0
confetti.gravity = Vector2(0, 260)
confetti.initial_velocity_min = 80.0
confetti.initial_velocity_max = 260.0
confetti.angular_velocity_min = -360.0
confetti.angular_velocity_max = 360.0
confetti.scale_amount_min = 5.0
confetti.scale_amount_max = 9.0
var colours: Gradient = Gradient.new()
colours.interpolation_mode = Gradient.GRADIENT_INTERPOLATE_CONSTANT
colours.offsets = PackedFloat32Array()
colours.colors = PackedColorArray()
for i: int in 7:
colours.add_point(i / 7.0, score_graph.player_color(i))
confetti.color_initial_ramp = colours
confetti.finished.connect(confetti.queue_free)
winner_panel.add_child(confetti)
confetti.emitting = true
# One picture per winner, next to each other
func _show_pictures(winners: Array[PlayerObject]) -> void:
var header: Node = winner_picture.get_parent()
+38 -3
View File
@@ -2,22 +2,57 @@
[ext_resource type="Script" path="res://quiz/now_playing.gd" id="part_script"]
[node name="NowPlaying" type="VBoxContainer" unique_id=42468498]
[node name="NowPlaying" type="HBoxContainer" unique_id=42468498]
layout_mode = 3
offset_right = 1088.0
offset_bottom = 320.0
theme_override_constants/separation = 32
alignment = 1
script = ExtResource("part_script")
[node name="SoundtrackLabel" type="Label" parent="." unique_id=173147793]
[node name="CoverFrame" type="Panel" parent="."]
unique_name_in_owner = true
visible = false
clip_children = 2
custom_minimum_size = Vector2(180, 270)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
theme_type_variation = &"SoundtrackCover"
[node name="CoverPicture" type="TextureRect" parent="CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="Titles" type="VBoxContainer" parent="."]
unique_name_in_owner = true
layout_mode = 2
alignment = 1
[node name="SoundtrackLabel" type="Label" parent="Titles" unique_id=173147793]
unique_name_in_owner = true
custom_minimum_size = Vector2(1, 1)
layout_mode = 2
theme_type_variation = &"TitleLarge"
horizontal_alignment = 1
autowrap_mode = 2
text_overrun_behavior = 3
max_lines_visible = 2
[node name="SongLabel" type="Label" parent="." unique_id=1687490027]
[node name="SongLabel" type="Label" parent="Titles" unique_id=1687490027]
unique_name_in_owner = true
custom_minimum_size = Vector2(1, 1)
layout_mode = 2
theme_type_variation = &"TitleMedium"
horizontal_alignment = 1
autowrap_mode = 2
text_overrun_behavior = 3
max_lines_visible = 2
+27 -19
View File
@@ -14,25 +14,6 @@ script = ExtResource("1_if4kc")
layout_mode = 2
theme_override_constants/separation = 8
[node name="Name" type="Label" parent="HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 1
theme_type_variation = &"TextMedium"
vertical_alignment = 1
clip_text = true
text_overrun_behavior = 3
[node name="Score" type="Label" parent="HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(40, 0)
layout_mode = 2
theme_type_variation = &"PlayerScore"
text = "0"
horizontal_alignment = 2
vertical_alignment = 1
[node name="CharacterFrame" type="PanelContainer" parent="HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
@@ -50,6 +31,33 @@ texture_normal = ExtResource("2_hpj3s")
ignore_texture_size = true
stretch_mode = 5
[node name="Name" type="Label" parent="HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 1
theme_type_variation = &"TextMedium"
vertical_alignment = 1
clip_text = true
text_overrun_behavior = 3
[node name="MatchPointTag" type="Label" parent="HBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "Match point!"
vertical_alignment = 1
[node name="Score" type="Label" parent="HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(40, 0)
layout_mode = 2
theme_type_variation = &"PlayerScore"
text = "0"
horizontal_alignment = 2
vertical_alignment = 1
[node name="AddPoint" type="Button" parent="HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(40, 36)
+1 -1
View File
@@ -41,7 +41,7 @@ offset_bottom = 512.0
[node name="NowPlaying" parent="." unique_id=42468498 instance=ExtResource("scene_NowPlaying")]
layout_mode = 0
offset_left = 64.0
offset_top = 64.0
offset_top = 56.0
offset_right = 1152.0
offset_bottom = 384.0
+43
View File
@@ -50,6 +50,49 @@ focus_mode = 0
action_mode = 0
text = "Hide next track"
[node name="ShortSongsCheckButton" type="CheckButton" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
tooltip_text = "Play only a part of each song, from a random point, so more songs fit in a game"
focus_mode = 0
action_mode = 0
text = "Short songs"
[node name="ShortSongSecondsRow" type="HBoxContainer" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
[node name="Title" type="Label" parent="CardPanel/VBox/ShortSongSecondsRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Seconds per song"
[node name="ShortSongSecondsLabel" type="Label" parent="CardPanel/VBox/ShortSongSecondsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "30"
horizontal_alignment = 1
[node name="LowerShortSongSecondsButton" type="Button" parent="CardPanel/VBox/ShortSongSecondsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 0)
layout_mode = 2
focus_mode = 0
action_mode = 0
theme_type_variation = &"StepButton"
text = "-5"
[node name="IncreaseShortSongSecondsButton" type="Button" parent="CardPanel/VBox/ShortSongSecondsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 0)
layout_mode = 2
focus_mode = 0
action_mode = 0
theme_type_variation = &"StepButton"
text = "+5"
[node name="HideBeginningCheckButton" type="CheckButton" parent="CardPanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
+19 -1
View File
@@ -1,12 +1,18 @@
class_name MusicListPanel
extends PanelContainer
## The list of songs in the playlist, one row per song.
## The list of songs in the playlist, one row per song. A song's row flashes when the
## song starts playing and when it is answered.
## Emitted when the row of song number [param song_no] is clicked.
signal song_clicked(song_no: int)
var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
# Whether each song was playing and answered when the list was last shown, so a row
# can flash when its song starts or is answered
var _last_state: Dictionary = {}
# When each flash ends, in msec, so a flash goes on when the list is shown again
var _flash_ends: Dictionary = {}
@onready var _rows: VBoxContainer = %MusicList
@@ -15,6 +21,7 @@ var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn")
func refresh() -> void:
Settings.delete_children(_rows)
var songs: Array[SongObject] = Playlist.get_playlist()
var state: Dictionary = {}
for i: int in songs.size():
var s: SongObject = songs[i]
var new_song_list_object: SongRow = _song_list_object.instantiate()
@@ -22,6 +29,17 @@ func refresh() -> void:
new_song_list_object.index = i
new_song_list_object.gui_input.connect(_on_row_input.bind(s.song_number))
_rows.add_child(new_song_list_object)
state[s] = [s.is_playing, s.is_answered]
# Only songs that were listed before flash, not a whole list loaded at once
if _last_state.has(s) && _last_state[s] != state[s] \
&& (s.is_playing || s.is_answered):
_flash_ends[s] = Time.get_ticks_msec() + int(Anim.SLOW * 1000)
var left: float = (_flash_ends.get(s, 0) - Time.get_ticks_msec()) / 1000.0
if left > 0:
Anim.flash(new_song_list_object, get_theme_color(&"flash", &"Palette"), left)
else:
_flash_ends.erase(s)
_last_state = state
func _on_row_input(event: InputEvent, song_no: int) -> void:
+101 -6
View File
@@ -1,10 +1,105 @@
class_name NowPlaying
extends VBoxContainer
## The soundtrack and song that are playing, in large text at the top of the quiz.
extends HBoxContainer
## The soundtrack and song that are playing, in large text at the top of the quiz. They
## fade in when they change. Once the answer is shown, the soundtrack's cover shows on
## their left, when it has one, and the cover and titles are centered together. Both are
## centered up and down too, so the titles only move sideways when a cover comes. Each
## title has at most two lines.
## The covers on disk. No cover is shown without it.
var covers: CoverCache:
set(value):
covers = value
if covers != null:
covers.cover_times_changed.connect(_load_cover)
_load_cover()
# The soundtrack whose cover is shown or on its way, and the cover time asked for
var _cover_uuid: String = ""
var _cover_time: int = 0
@onready var soundtrack_label: Label = %SoundtrackLabel
@onready var song_label: Label = %SongLabel
@onready var cover_frame: Panel = %CoverFrame
@onready var cover_picture: TextureRect = %CoverPicture
@onready var titles: VBoxContainer = %Titles
func _ready() -> void:
resized.connect(_fit_titles)
_fit_titles()
## Shows [param soundtrack_title] and [param song_title]. The playlist hides them as
## question marks until the answer is shown.
func show_song(soundtrack_title: String, song_title: String) -> void:
$SoundtrackLabel.text = soundtrack_title
$SongLabel.text = song_title
## question marks until the answer is shown, and only then gives the
## [param soundtrack_uuid] whose cover is shown.
func show_song(soundtrack_title: String, song_title: String,
soundtrack_uuid: String = "") -> void:
var changed: bool = soundtrack_label.text != soundtrack_title \
|| song_label.text != song_title
soundtrack_label.text = soundtrack_title
song_label.text = song_title
_fit_titles()
if soundtrack_uuid != _cover_uuid:
_cover_uuid = soundtrack_uuid
_cover_time = 0
_show_cover(null)
_load_cover()
# A new song, or the answer being shown, fades in
if changed:
Anim.fade_in(self, Anim.SLOW)
## Whether a cover is shown.
func has_cover() -> bool:
return cover_frame.visible
# Asks for the cover, again when its time changed, e.g. once the soundtrack list loaded
func _load_cover() -> void:
if covers == null || _cover_uuid.is_empty() || !is_node_ready():
return
var time: int = covers.cover_time(_cover_uuid)
if time == _cover_time:
return
_cover_time = time
if time <= 0:
_show_cover(null)
return
covers.fetch_cover(_cover_uuid, time, _show_cover_of.bind(_cover_uuid, time))
# Covers arrive later; one for a song no longer shown is left out
func _show_cover_of(cover: Image, uuid: String, time: int) -> void:
if uuid == _cover_uuid && time == _cover_time:
_show_cover(cover)
func _show_cover(cover: Image) -> void:
var was_shown: bool = cover_frame.visible
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
cover_frame.visible = cover != null
_fit_titles()
if cover_frame.visible && !was_shown:
Anim.fade_in(cover_frame, Anim.SLOW)
# The titles take the width of the longer one, so they stay next to the cover, but no
# more than there is room for; longer titles wrap. Beside a cover they line up with it
# on the left.
func _fit_titles() -> void:
var room: float = size.x
if cover_frame.visible:
room -= cover_frame.custom_minimum_size.x + get_theme_constant(&"separation")
var widest: float = maxf(_text_width(soundtrack_label), _text_width(song_label))
titles.custom_minimum_size.x = clampf(ceilf(widest), 1.0, maxf(room, 1.0))
var alignment: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT if cover_frame.visible \
else HORIZONTAL_ALIGNMENT_CENTER
soundtrack_label.horizontal_alignment = alignment
song_label.horizontal_alignment = alignment
static func _text_width(label: Label) -> float:
var font: Font = label.get_theme_font(&"font")
return font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1,
label.get_theme_font_size(&"font_size")).x
+37
View File
@@ -2,6 +2,9 @@ class_name PlayerRow
extends PanelContainer
## One player in the player list: their name, points and character, and buttons for
## giving and taking points. Clicking the character picks another one.
##
## The score pops when it changes. The first point of a game makes the row flash, and a
## player one point from winning glows and says "Match point!" until that changes.
signal change_character_clicked
signal player_removed
@@ -15,6 +18,7 @@ var list_index: int
@onready var player_name_field: Label = %Name
@onready var score_label: Label = %Score
@onready var match_point_tag: Label = %MatchPointTag
@onready var character_frame: PanelContainer = %CharacterFrame
@onready var add_button: Button = %AddPoint
@onready var minus_button: Button = %RemovePoint
@@ -29,6 +33,9 @@ func _ready() -> void:
minus_button.set_shortcut(create_button_shortcut(list_index, true))
character.pressed.connect(change_character)
remove_player.pressed.connect(func() -> void: player_removed.emit())
player_object.player_point_given.connect(_on_score_changed)
player_object.player_point_taken.connect(_on_score_changed)
player_object.first_point_triggered.connect(_on_first_point)
character.mouse_entered.connect(_set_character_hovered.bind(true))
character.mouse_exited.connect(_set_character_hovered.bind(false))
# Rows take turns being lighter and darker, like the other lists
@@ -81,6 +88,36 @@ func set_player_character() -> void:
func update_score() -> void:
score_label.text = str(player_object.player_score)
_show_match_point(is_match_point())
## Whether the player is one point from winning. A prepared quiz has no winning score.
func is_match_point() -> bool:
return !Settings.is_prepared_quiz() && Settings.winning_score > 1 \
&& player_object.player_score == Settings.winning_score - 1
func _on_score_changed() -> void:
Anim.pop(score_label)
func _on_first_point() -> void:
Anim.flash(self, get_theme_color(&"first_point", &"Palette"), Anim.SLOW * 2)
Anim.pop(score_label, 1.8, Anim.SLOW)
# The glow and the tag show while the player is on match point
func _show_match_point(on: bool) -> void:
var glow: Node = get_node_or_null("Pulse")
if on == match_point_tag.visible && on == (glow != null):
return
match_point_tag.visible = on
if glow != null:
glow.queue_free()
glow.name = "PulseEnding"
if on:
Anim.pulse(self, get_theme_color(&"match_point", &"Palette"))
Anim.pop(match_point_tag, 1.4, Anim.SLOW)
func _set_character_hovered(hovered: bool) -> void:
+4 -3
View File
@@ -171,7 +171,7 @@ func update_song_list() -> void:
GameLog.debug("update_song_list")
if Playlist.currently_playing_song >= 0:
now_playing.show_song(Playlist.get_current_soundtrack_title(),
Playlist.get_current_song_title())
Playlist.get_current_song_title(), Playlist.get_current_soundtrack_uuid())
music_list_panel.refresh()
save_soon()
@@ -181,10 +181,11 @@ func save_soon() -> void:
if _restoring || _save_queued:
return
_save_queued = true
_save_now.call_deferred()
save_now.call_deferred()
func _save_now() -> void:
## Saves the quiz at once, e.g. before the game restarts with a new version.
func save_now() -> void:
_save_queued = false
if is_inside_tree():
QuizSave.capture(stats_paused_by_win).write()
+34
View File
@@ -13,6 +13,10 @@ extends BasePopup
## The most seconds of a song's beginning that can be hidden.
const MAX_HIDE_SECONDS: float = 10.0
## The shortest and longest part of a song that Short songs plays, and the step between.
const MIN_SHORT_SONG_SECONDS: int = 10
const MAX_SHORT_SONG_SECONDS: int = 120
const SHORT_SONG_STEP: int = 5
## Space between the rows and the card's edges.
const PADDING: Vector2 = Vector2(16, 12)
@@ -21,6 +25,11 @@ const PADDING: Vector2 = Vector2(16, 12)
@onready var auto_repeat_button: CheckButton = %AutoRepeatCheckButton
@onready var stop_after_current_button: CheckButton = %StopAfterCurrentCheckButton
@onready var hide_next_track_button: CheckButton = %HideNextTrackCheckButton
@onready var short_songs_button: CheckButton = %ShortSongsCheckButton
@onready var short_song_seconds_row: HBoxContainer = %ShortSongSecondsRow
@onready var short_song_seconds_label: Label = %ShortSongSecondsLabel
@onready var lower_short_song_seconds_button: Button = %LowerShortSongSecondsButton
@onready var increase_short_song_seconds_button: Button = %IncreaseShortSongSecondsButton
@onready var hide_beginning_button: CheckButton = %HideBeginningCheckButton
@onready var hide_for_seconds_row: HBoxContainer = %HideForSecondsRow
@onready var hide_for_seconds_label: Label = %HideForSecondsLabel
@@ -41,6 +50,11 @@ func _ready() -> void:
auto_repeat_button.toggled.connect(_save_toggle.bind(&"auto_repeat_song"))
stop_after_current_button.toggled.connect(_save_toggle.bind(&"stop_after_current"))
hide_next_track_button.toggled.connect(_save_toggle.bind(&"hide_next_track"))
short_songs_button.toggled.connect(_on_short_songs_toggled)
lower_short_song_seconds_button.pressed.connect(
set_short_song_seconds.bind(-SHORT_SONG_STEP, true))
increase_short_song_seconds_button.pressed.connect(
set_short_song_seconds.bind(SHORT_SONG_STEP, true))
hide_beginning_button.toggled.connect(_on_hide_beginning_toggled)
hide_length_button.toggled.connect(_save_toggle.bind(&"hide_length"))
hide_ticks_button.toggled.connect(_save_toggle.bind(&"hide_ticks"))
@@ -60,6 +74,9 @@ func show_settings() -> void:
auto_repeat_button.set_pressed_no_signal(Settings.auto_repeat_song)
stop_after_current_button.set_pressed_no_signal(Settings.stop_after_current)
hide_next_track_button.set_pressed_no_signal(Settings.hide_next_track)
short_songs_button.set_pressed_no_signal(Settings.short_songs)
short_song_seconds_row.visible = Settings.short_songs
short_song_seconds_label.text = str(Settings.short_song_seconds)
hide_beginning_button.set_pressed_no_signal(Settings.hide_beginning)
hide_length_button.set_pressed_no_signal(Settings.hide_length)
hide_ticks_button.set_pressed_no_signal(Settings.hide_ticks)
@@ -117,6 +134,23 @@ func _save_toggle(on: bool, setting: StringName) -> void:
Settings.save_settings()
## Sets how many seconds of each song Short songs plays, between
## [constant MIN_SHORT_SONG_SECONDS] and [constant MAX_SHORT_SONG_SECONDS]. With
## [param relative] the seconds are added to the current length.
func set_short_song_seconds(seconds: int, relative: bool = false) -> void:
var length: int = Settings.short_song_seconds + seconds if relative else seconds
Settings.short_song_seconds = clampi(length, MIN_SHORT_SONG_SECONDS,
MAX_SHORT_SONG_SECONDS)
short_song_seconds_label.text = str(Settings.short_song_seconds)
Settings.save_settings()
func _on_short_songs_toggled(on: bool) -> void:
_save_toggle(on, &"short_songs")
short_song_seconds_row.visible = on
_fit_to_rows()
func _on_hide_beginning_toggled(on: bool) -> void:
_save_toggle(on, &"hide_beginning")
hide_for_seconds_row.visible = on
+3 -1
View File
@@ -52,6 +52,7 @@ func load_song_list(saved: QuizSave = null) -> void:
new_song_object.song_number = info.song_no
new_song_object.soundtrack_title = info.soundtrack
new_song_object.song_title = info.song
new_song_object.soundtrack_uuid = info.soundtrack_uuid
new_song_object.is_answered = true
new_song_object.has_played = true
Playlist.add_to_playlist(new_song_object)
@@ -122,7 +123,7 @@ func _download_random_song(new_song_object: SongObject) -> void:
return
Playlist.store_song(new_song_object, body)
_add_song_to_queue(new_song_object)
MusicServerClient.fetch_random_song(Settings.use_low_played_mode, song_downloaded,
MusicServerClient.fetch_random_song(Settings.random_mode, song_downloaded,
_retry_later.bind(_download_random_song.bind(new_song_object)))
@@ -139,6 +140,7 @@ func _fetch_song_info(new_song_object: SongObject) -> void:
new_song_object.song_number = info.song_no
new_song_object.soundtrack_title = info.soundtrack
new_song_object.song_title = info.song
new_song_object.soundtrack_uuid = info.soundtrack_uuid
GameLog.debug("new_song_object: ", new_song_object.to_string())
Playlist.add_to_playlist(new_song_object)
if Playlist.should_preload_more_songs():
+22
View File
@@ -0,0 +1,22 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name AlternativeNames
extends RefCounted
## The [code]AlternativeNames[/code] schema of the MusicServer API.
## The new list of alternative names. Replaces the old list; send [] to remove them all.
var names: Array[String]
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> AlternativeNames:
var result: AlternativeNames = AlternativeNames.new()
result.names.assign(ApiJson.get_array(data, "names"))
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"names": names,
}
@@ -0,0 +1 @@
uid://bpdabeiw5wupm
+26
View File
@@ -0,0 +1,26 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name CoverSource
extends RefCounted
## The [code]CoverSource[/code] schema of the MusicServer API.
## The game's ID on SteamGridDB, or the anime's ID on AniList
var id: String
## Where the cover comes from
var source: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> CoverSource:
var result: CoverSource = CoverSource.new()
result.id = ApiJson.get_string(data, "id", "")
result.source = ApiJson.get_string(data, "source", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"id": id,
"source": source,
}
+1
View File
@@ -0,0 +1 @@
uid://06q8sppe2evn
+1
View File
@@ -5,6 +5,7 @@ extends RefCounted
## The [code]ProgressResponse[/code] schema of the MusicServer API.
var progress: String
## How long the sync has been running, as minutes and seconds, e.g. "02:15"
var time_spent: String
+8
View File
@@ -5,6 +5,10 @@ extends RefCounted
## The [code]Soundtrack[/code] schema of the MusicServer API.
var added: String
## Other names the soundtrack is known by
var alternative_names: Array[String]
## When the cover last changed, in Unix seconds. Missing when there is no cover.
var cover_modified: int
var last_played: String
var name: String
var times_played: int
@@ -15,6 +19,8 @@ var uuid: String
static func from_dict(data: Dictionary) -> Soundtrack:
var result: Soundtrack = Soundtrack.new()
result.added = ApiJson.get_string(data, "added", "")
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
result.last_played = ApiJson.get_string(data, "last_played", "")
result.name = ApiJson.get_string(data, "name", "")
result.times_played = ApiJson.get_int(data, "times_played", 0)
@@ -26,6 +32,8 @@ static func from_dict(data: Dictionary) -> Soundtrack:
func to_dict() -> Dictionary:
return {
"added": added,
"alternative_names": alternative_names,
"cover_modified": cover_modified,
"last_played": last_played,
"name": name,
"times_played": times_played,
@@ -5,6 +5,10 @@ extends RefCounted
## The [code]SoundtrackWithSongList[/code] schema of the MusicServer API.
var added: String
## Other names the soundtrack is known by
var alternative_names: Array[String]
## When the cover last changed, in Unix seconds. Missing when there is no cover.
var cover_modified: int
var last_played: String
var name: String
var songs: Array[Song]
@@ -16,6 +20,8 @@ var uuid: String
static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
var result: SoundtrackWithSongList = SoundtrackWithSongList.new()
result.added = ApiJson.get_string(data, "added", "")
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
result.last_played = ApiJson.get_string(data, "last_played", "")
result.name = ApiJson.get_string(data, "name", "")
for item: Variant in ApiJson.get_array(data, "songs"):
@@ -30,6 +36,8 @@ static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
func to_dict() -> Dictionary:
return {
"added": added,
"alternative_names": alternative_names,
"cover_modified": cover_modified,
"last_played": last_played,
"name": name,
"songs": songs.map(func(item: Song) -> Dictionary: return item.to_dict()),
@@ -4,6 +4,10 @@ class_name SoundtrackWithSongs
extends RefCounted
## The [code]SoundtrackWithSongs[/code] schema of the MusicServer API.
## Other names the soundtrack is known by
var alternative_names: Array[String]
## When the cover last changed, in Unix seconds. Missing when there is no cover.
var cover_modified: int
var songs: Array[SongInfoForStats]
var soundtrack_id: int
var soundtrack_last_played: String
@@ -15,6 +19,8 @@ var soundtrack_uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
var result: SoundtrackWithSongs = SoundtrackWithSongs.new()
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
for item: Variant in ApiJson.get_array(data, "songs"):
if item is Dictionary:
result.songs.append(SongInfoForStats.from_dict(item))
@@ -29,6 +35,8 @@ static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"alternative_names": alternative_names,
"cover_modified": cover_modified,
"songs": songs.map(func(item: SongInfoForStats) -> Dictionary: return item.to_dict()),
"soundtrack_id": soundtrack_id,
"soundtrack_last_played": soundtrack_last_played,
+1
View File
@@ -10,6 +10,7 @@ var soundtracks_changed_content: Array[String]
var soundtracks_changed_title: Dictionary[String, String]
var soundtracks_re_added: Array[String]
var soundtracks_removed: Array[String]
## How long the sync took, as minutes and seconds, e.g. "02:15"
var total_time: String
+8 -7
View File
@@ -18,17 +18,21 @@ func _init(owner: Node) -> void:
## Requests [param url] and calls [param on_complete] with
## [code](response_code: int, body: PackedByteArray, headers: PackedStringArray)[/code],
## the headers as [code]"Name: value"[/code] lines. Connection errors and timeouts are
## reported with response code 0 and no headers. [param headers] are sent as-is; a
## [param body] is sent as JSON.
## reported with response code 0 and no headers. [param headers], which name the
## [param body]'s type, are sent as-is. Without [param follow_redirects] a redirect is
## answered as it is, with its [code]Location[/code] header.
func request(
url: String,
method: int,
on_complete: Callable,
headers: PackedStringArray = PackedStringArray(),
body: String = "",
body: PackedByteArray = PackedByteArray(),
follow_redirects: bool = true,
) -> void:
var http_request: HTTPRequest = HTTPRequest.new()
http_request.timeout = timeout_seconds
if !follow_redirects:
http_request.max_redirects = 0
_owner.add_child(http_request)
http_request.request_completed.connect(
func(
@@ -43,10 +47,7 @@ func request(
return
on_complete.call(response_code, response_body, response_headers)
)
var all_headers: PackedStringArray = headers.duplicate()
if method == HTTPClient.METHOD_PUT || !body.is_empty():
all_headers.append("Content-Type: application/json")
var error: int = http_request.request(url, all_headers, method, body)
var error: int = http_request.request_raw(url, headers, method, body)
if error != OK:
http_request.queue_free()
push_error("Could not send request to %s (error %d)" % [url, error])
+146 -38
View File
@@ -19,7 +19,7 @@ extends Node
## JSON objects are handed to [code]on_done[/code] as the classes generated from the
## server's OpenAPI spec (see [code]server/generated/[/code]), lists of them as typed
## Arrays. [code]on_failed[/code] is called with no arguments when a request fails,
## including when no token could be had.
## including when no token could be had; [member last_error] then has the server's reason.
## Path of the API on the server, added between [code]Settings.default_path[/code] and
## each endpoint.
@@ -28,9 +28,20 @@ const API_PATH: String = "/api/v1"
const CLIENT_TYPE: String = "godot"
## Header with the ID of the client's play queue, in requests and responses.
const QUEUE_HEADER: String = "X-Queue-Id"
## The type of the JSON bodies sent.
const JSON_TYPE: String = "application/json"
## The files of a MusicPlayer release that can be downloaded: the Windows game, the Linux
## game, and the Linux package with an installer.
const DOWNLOAD_ASSETS: Array[String] = ["windows", "linux", "linux-package"]
## Where the cover of a soundtrack can be picked from, by the game's or anime's ID there.
const COVER_SOURCES: Array[String] = ["steamgriddb", "anilist"]
## Sends the actual HTTP requests. Swap it out in tests.
var transport: HttpTransport
## The server's message for the request that failed last, e.g. [code]"covers are
## disabled"[/code], or empty when it gave none. Read it in an [code]on_failed[/code]
## callback, before another request can fail.
var last_error: String = ""
var _token: String = ""
# The server the token is for; switching server in the settings needs a new token
@@ -51,17 +62,22 @@ func fetch_song_list(on_done: Callable, on_failed: Callable = Callable()) -> voi
get_json_list("/music/list", SongInfo, on_done, on_failed)
## Downloads a random song as MP3 bytes. With [param low_played] the server prefers
## soundtracks that have been played the least.
func fetch_random_song(
low_played: bool, on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/music/rand/low" if low_played else "/music/rand", on_done, on_failed)
## Downloads a random song as MP3 bytes, picked the way [param mode] (a
## [code]Settings.RandomMode[/code]) says: a random soundtrack and then one of its songs,
## the same but preferring soundtracks played less than average, or every song with the
## same chance.
func fetch_random_song(mode: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes(random_song_path(mode), on_done, on_failed)
## Downloads a random song as MP3 bytes, picked the way the server did before low played
## mode existed: any soundtrack is as likely as any other.
func fetch_random_song_classic(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/music/rand/classic", on_done, on_failed)
## The endpoint for random songs picked the way [param mode] says.
static func random_song_path(mode: int) -> String:
match mode:
Settings.RandomMode.LOW_PLAYED:
return "/music/rand/low"
Settings.RandomMode.CLASSIC:
return "/music/rand/classic"
return "/music/rand"
## Adds the most recently fetched random song to the server playlist.
@@ -126,6 +142,61 @@ func fetch_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> v
get_json_list("/soundtracks", Soundtrack, on_done, on_failed)
## Downloads the cover picture of the soundtrack with [param uuid] as bytes (JPEG or
## PNG). The server answers 404 while it has no cover for it.
func fetch_soundtrack_cover(
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes(_cover_path(uuid), on_done, on_failed)
## Stores [param image], a JPEG, PNG or WebP picture of [param content_type] such as
## [code]"image/png"[/code], as the cover of the soundtrack with [param uuid]. The
## server's automatic search never replaces it.
func upload_soundtrack_cover(uuid: String, image: PackedByteArray, content_type: String,
on_done: Callable, on_failed: Callable = Callable()) -> void:
_request(_cover_path(uuid), HTTPClient.METHOD_PUT, _call_ignoring_body.bind(on_done),
on_failed, true, image, content_type)
## Makes the cover of the game or anime with [param id] on [param source], one of
## [constant COVER_SOURCES], the cover of the soundtrack with [param uuid]. The server
## downloads it before answering.
func set_soundtrack_cover_source(uuid: String, source: String, id: String,
on_done: Callable, on_failed: Callable = Callable()) -> void:
var request: CoverSource = CoverSource.new()
request.source = source
request.id = id
_request(_cover_path(uuid) + "/source", HTTPClient.METHOD_PUT,
_call_ignoring_body.bind(on_done), on_failed, true,
JSON.stringify(request.to_dict()).to_utf8_buffer())
## Deletes the cover of the soundtrack with [param uuid], also one set by hand, so the
## server's automatic search looks for a new one.
func delete_soundtrack_cover(
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
_request(_cover_path(uuid), HTTPClient.METHOD_DELETE, _call_ignoring_body.bind(on_done),
on_failed)
## Replaces the alternative names of the soundtrack with [param uuid] with
## [param names]. The server trims them, drops duplicates and the soundtrack's own name,
## and calls [param on_done] with the updated [Soundtrack].
func set_alternative_names(uuid: String, names: Array[String], on_done: Callable,
on_failed: Callable = Callable()) -> void:
var request: AlternativeNames = AlternativeNames.new()
request.names = names
var path: String = "/soundtracks/%s/alternative-names" % uuid.uri_encode()
var saved: Callable = func saved(data: Variant) -> void:
if !(data is Dictionary):
push_warning("Expected a JSON object from " + path)
_call_if_valid(on_failed)
return
on_done.call(Soundtrack.from_dict(data))
_request(path, HTTPClient.METHOD_PUT, _parse_json.bind(path, saved, on_failed), on_failed,
true, JSON.stringify(request.to_dict()).to_utf8_buffer())
## Fetches a soundtrack and its songs as a [SoundtrackWithSongList].
func fetch_soundtrack(
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
@@ -278,14 +349,14 @@ func fetch_client_assets(on_done: Callable, on_failed: Callable = Callable()) ->
get_json("/download/list", on_done, on_failed)
## Downloads the newest MusicPlayer for Windows.
func download_client_windows(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/download/windows", on_done, on_failed)
## Downloads the newest MusicPlayer for Linux.
func download_client_linux(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/download/linux", on_done, on_failed)
## Calls [param on_done] with the link to download the newest MusicPlayer file
## [param asset] from, one of [constant DOWNLOAD_ASSETS]. The server answers with a
## redirect to the release on Gitea, which is not followed: the link needs no token, so
## the browser can open it.
func fetch_client_download_url(
asset: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
_request("/download/" + asset, HTTPClient.METHOD_GET, on_done, on_failed, true,
PackedByteArray(), JSON_TYPE, false)
# Tokens
@@ -323,14 +394,9 @@ func queue_id() -> String:
# Keeps the queue ID in [param headers] from [param server] when it is new
func _remember_queue_id(server: String, headers: PackedStringArray) -> void:
var prefix: String = QUEUE_HEADER.to_lower() + ":"
for header: String in headers:
if !header.to_lower().begins_with(prefix):
continue
var id: String = header.substr(prefix.length()).strip_edges()
if !id.is_empty() && Settings.queue_ids.get(server, "") != id:
Settings.set_queue_id(server, id)
return
var id: String = header_value(headers, QUEUE_HEADER)
if !id.is_empty() && Settings.queue_ids.get(server, "") != id:
Settings.set_queue_id(server, id)
# Generic requests
@@ -414,24 +480,36 @@ func _parse_json(
# Sends the request with the token, and once more with a new token if the server
# answers 401
# answers 401. A [param body] of the type [param content_type] is sent with it. Without
# [param follow_redirects], a redirect is a success and [param on_success] gets where it
# points to.
func _request(
path: String,
method: int,
on_success: Callable,
on_failed: Callable,
may_retry: bool = true,
body: PackedByteArray = PackedByteArray(),
content_type: String = JSON_TYPE,
follow_redirects: bool = true,
) -> void:
_with_token(func(token: String) -> void:
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
_send(path, method, token, func(response_code: int, response: PackedByteArray,
headers: PackedStringArray) -> void:
if response_code == 401 && may_retry:
GameLog.debug("Token refused by the server, getting a new one")
if _token == token:
clear_token()
_request(path, method, on_success, on_failed, false)
_request(path, method, on_success, on_failed, false, body, content_type,
follow_redirects)
return
_handle_response(path, response_code, body, on_success, on_failed)
)
var location: String = header_value(headers, "Location")
if !follow_redirects && response_code >= 300 && response_code < 400 \
&& !location.is_empty():
on_success.call(location)
return
_handle_response(path, response_code, response, on_success, on_failed)
, body, content_type, follow_redirects)
, on_failed)
@@ -439,18 +517,27 @@ func _request(
func _send_once(
path: String, method: int, token: String, on_success: Callable, on_failed: Callable,
) -> void:
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
_send(path, method, token, func(response_code: int, body: PackedByteArray,
_headers: PackedStringArray) -> void:
_handle_response(path, response_code, body, on_success, on_failed)
)
func _send(
path: String, method: int, token: String, on_complete: Callable, body: String = "",
path: String,
method: int,
token: String,
on_complete: Callable,
body: PackedByteArray = PackedByteArray(),
content_type: String = JSON_TYPE,
follow_redirects: bool = true,
) -> void:
var server: String = Settings.default_path
var headers: PackedStringArray = []
if !token.is_empty():
headers.append("Authorization: Bearer " + token)
if !body.is_empty() || method == HTTPClient.METHOD_PUT:
headers.append("Content-Type: " + content_type)
var queue: String = Settings.queue_ids.get(server, "")
if !queue.is_empty():
headers.append("%s: %s" % [QUEUE_HEADER, queue])
@@ -458,8 +545,8 @@ func _send(
func(response_code: int, response_body: PackedByteArray,
response_headers: PackedStringArray) -> void:
_remember_queue_id(server, response_headers)
on_complete.call(response_code, response_body)
, headers, body)
on_complete.call(response_code, response_body, response_headers)
, headers, body, follow_redirects)
func _handle_response(
@@ -469,7 +556,9 @@ func _handle_response(
on_success: Callable,
on_failed: Callable,
) -> void:
if response_code != 200:
# Requests that change something may answer 204 No Content
if response_code < 200 || response_code >= 300:
last_error = _error_message(body).trim_prefix(": ")
push_warning("Request to %s failed with response code %d%s"
% [path, response_code, _error_message(body)])
_call_if_valid(on_failed)
@@ -496,7 +585,7 @@ func _request_token() -> void:
token_request.client_type = CLIENT_TYPE
var server: String = Settings.default_path
_send("/token", HTTPClient.METHOD_POST, "",
func(response_code: int, body: PackedByteArray) -> void:
func(response_code: int, body: PackedByteArray, _headers: PackedStringArray) -> void:
var waiters: Array[Array] = _token_waiters.duplicate()
_token_waiters.clear()
var token: String = _read_token(response_code, body)
@@ -508,7 +597,7 @@ func _request_token() -> void:
_token_server = server
for waiter: Array in waiters:
waiter[0].call(token)
, JSON.stringify(token_request.to_dict()))
, JSON.stringify(token_request.to_dict()).to_utf8_buffer())
func _read_token(response_code: int, body: PackedByteArray) -> String:
@@ -537,6 +626,25 @@ static func _parse_or_null(body: PackedByteArray) -> Variant:
return json.data if json.parse(body.get_string_from_utf8()) == OK else null
## The value of the header [param header_name] in [param headers], given as
## [code]"Name: value"[/code] lines, or empty when it isn't there. Names are matched
## without regard to case.
static func header_value(headers: PackedStringArray, header_name: String) -> String:
for header: String in headers:
var colon: int = header.find(":")
if colon > 0 && header.left(colon).strip_edges().to_lower() == header_name.to_lower():
return header.substr(colon + 1).strip_edges()
return ""
static func _cover_path(uuid: String) -> String:
return "/soundtracks/%s/cover" % uuid.uri_encode()
static func _call_ignoring_body(_body: PackedByteArray, callback: Callable) -> void:
_call_if_valid(callback)
static func _call_if_valid(callback: Callable) -> void:
if callback.is_valid():
callback.call()
+659
View File
@@ -17,6 +17,23 @@
],
"type": "object"
},
"AlternativeNames": {
"additionalProperties": false,
"properties": {
"names": {
"description": "The new list of alternative names. Replaces the old list; send [] to remove them all.",
"items": {
"type": "string"
},
"maxItems": 50,
"type": "array"
}
},
"required": [
"names"
],
"type": "object"
},
"CharacterDetails": {
"additionalProperties": false,
"properties": {
@@ -36,6 +53,30 @@
],
"type": "object"
},
"CoverSource": {
"additionalProperties": false,
"properties": {
"id": {
"description": "The game's ID on SteamGridDB, or the anime's ID on AniList",
"maxLength": 50,
"minLength": 1,
"type": "string"
},
"source": {
"description": "Where the cover comes from",
"enum": [
"steamgriddb",
"anilist"
],
"type": "string"
}
},
"required": [
"source",
"id"
],
"type": "object"
},
"ProgressResponse": {
"additionalProperties": false,
"properties": {
@@ -43,6 +84,7 @@
"type": "string"
},
"time_spent": {
"description": "How long the sync has been running, as minutes and seconds, e.g. \"02:15\"",
"type": "string"
}
},
@@ -175,6 +217,18 @@
"format": "date-time",
"type": "string"
},
"alternative_names": {
"description": "Other names the soundtrack is known by",
"items": {
"type": "string"
},
"type": "array"
},
"cover_modified": {
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
"format": "int64",
"type": "integer"
},
"last_played": {
"format": "date-time",
"type": "string"
@@ -193,6 +247,7 @@
"required": [
"uuid",
"name",
"alternative_names",
"times_played",
"added"
],
@@ -205,6 +260,18 @@
"format": "date-time",
"type": "string"
},
"alternative_names": {
"description": "Other names the soundtrack is known by",
"items": {
"type": "string"
},
"type": "array"
},
"cover_modified": {
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
"format": "int64",
"type": "integer"
},
"last_played": {
"format": "date-time",
"type": "string"
@@ -230,6 +297,7 @@
"songs",
"uuid",
"name",
"alternative_names",
"times_played",
"added"
],
@@ -238,6 +306,18 @@
"SoundtrackWithSongs": {
"additionalProperties": false,
"properties": {
"alternative_names": {
"description": "Other names the soundtrack is known by",
"items": {
"type": "string"
},
"type": "array"
},
"cover_modified": {
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
"format": "int64",
"type": "integer"
},
"songs": {
"items": {
"$ref": "#/components/schemas/SongInfoForStats"
@@ -267,6 +347,7 @@
"soundtrack_id",
"soundtrack_uuid",
"soundtrack_name",
"alternative_names",
"soundtrack_played",
"songs"
],
@@ -367,6 +448,7 @@
"type": "array"
},
"total_time": {
"description": "How long the sync took, as minutes and seconds, e.g. \"02:15\"",
"type": "string"
}
},
@@ -805,6 +887,74 @@
]
}
},
"/api/v1/download/linux-package": {
"get": {
"description": "Redirects to the download URL of the latest MusicPlayer release for Linux (package with installer).",
"operationId": "download-client-linux-package",
"responses": {
"302": {
"description": "Found",
"headers": {
"Location": {
"schema": {
"description": "Download URL of the asset",
"type": "string"
}
}
}
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Bad Gateway"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Download the latest client for Linux (package with installer)",
"tags": [
"download"
]
}
},
"/api/v1/download/list": {
"get": {
"description": "Returns the file names of the latest MusicPlayer release.",
@@ -2663,6 +2813,515 @@
]
}
},
"/api/v1/soundtracks/{uuid}/alternative-names": {
"put": {
"description": "Replaces the soundtrack's alternative names, which search and the cover search also use. Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.",
"operationId": "set-alternative-names",
"parameters": [
{
"description": "UUID of the soundtrack",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack",
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlternativeNames"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Soundtrack"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Set alternative names",
"tags": [
"library"
]
}
},
"/api/v1/soundtracks/{uuid}/cover": {
"delete": {
"description": "Deletes the soundtrack's cover, including one set by hand, so the automatic search looks for a new one.",
"operationId": "delete-soundtrack-cover",
"parameters": [
{
"description": "UUID of the soundtrack or song",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack or song",
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Service Unavailable"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Remove the cover",
"tags": [
"covers"
]
},
"get": {
"description": "Returns the soundtrack's cover image. cover_modified in the soundtrack responses tells when it last changed.",
"operationId": "get-soundtrack-cover",
"parameters": [
{
"description": "UUID of the soundtrack or song",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack or song",
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"image/jpeg": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/png": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/webp": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
}
},
"description": "The cover image"
},
"206": {
"content": {
"image/jpeg": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/png": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/webp": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
}
},
"description": "Part of the file, for a Range request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Soundtrack cover",
"tags": [
"covers"
]
},
"put": {
"description": "Stores the image as the soundtrack's cover. The automatic search never replaces it; delete it to go back to automatic.",
"operationId": "upload-soundtrack-cover",
"parameters": [
{
"description": "UUID of the soundtrack",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack",
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"image/*": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Bad Request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Service Unavailable"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Upload a cover",
"tags": [
"covers"
]
}
},
"/api/v1/soundtracks/{uuid}/cover/source": {
"put": {
"description": "Downloads the cover of the given SteamGridDB game or AniList anime and stores it as the soundtrack's cover. The automatic search never replaces it. SteamGridDB needs STEAMGRIDDB_API_KEY on the server.",
"operationId": "set-soundtrack-cover-source",
"parameters": [
{
"description": "UUID of the soundtrack",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack",
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CoverSource"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Bad Request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Bad Gateway"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Service Unavailable"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Pick a cover",
"tags": [
"covers"
]
}
},
"/api/v1/statistics/songs/least-played": {
"get": {
"description": "Returns the least played songs with their soundtrack.",
+47 -4
View File
@@ -12,9 +12,52 @@ script = ExtResource("card_script")
layout_mode = 2
mouse_filter = 2
[node name="NameLabel" type="Label" parent="VBox" unique_id=1211000003]
[node name="Header" type="HBoxContainer" parent="VBox"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 10
[node name="CoverFrame" type="Panel" parent="VBox/Header"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 62)
clip_children = 2
custom_minimum_size = Vector2(68, 102)
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"SoundtrackCover"
[node name="CoverPlaceholder" type="Label" parent="VBox/Header/CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_type_variation = &"TextLarge"
horizontal_alignment = 1
vertical_alignment = 1
[node name="CoverPicture" type="TextureRect" parent="VBox/Header/CoverFrame"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="Info" type="VBoxContainer" parent="VBox/Header"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="NameLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000003]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 50)
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"TextLarge"
@@ -22,12 +65,12 @@ autowrap_mode = 3
text_overrun_behavior = 3
max_lines_visible = 2
[node name="PlayedLabel" type="Label" parent="VBox" unique_id=1211000004]
[node name="PlayedLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000004]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
[node name="LastPlayedLabel" type="Label" parent="VBox" unique_id=1211000005]
[node name="LastPlayedLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000005]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
+51
View File
@@ -4,6 +4,9 @@ extends PanelContainer
## played, and its most and least played songs. Songs never played are not listed, and
## the least played are only shown when there are enough played songs to not repeat
## any. Clicking it asks for all its songs.
##
## Its cover shows on the left once [method load_cover] has fetched it; until then, and
## when the soundtrack has no cover, the soundtrack's initials stand in for it.
## Emitted when the card is clicked.
signal pressed(soundtrack: SoundtrackWithSongs)
@@ -15,6 +18,11 @@ const SONGS_SHOWN: int = 3
## The soundtrack the card shows.
var soundtrack: SoundtrackWithSongs
# The cover time asked for, so a cover is only asked for again when it changed
var _cover_asked: int = 0
@onready var cover_picture: TextureRect = %CoverPicture
@onready var cover_placeholder: Label = %CoverPlaceholder
@onready var name_label: Label = %NameLabel
@onready var played_label: Label = %PlayedLabel
@onready var last_played_label: Label = %LastPlayedLabel
@@ -32,7 +40,14 @@ func _ready() -> void:
## Shows [param data] on the card.
func show_soundtrack(data: SoundtrackWithSongs) -> void:
var cover_changed: bool = soundtrack == null \
|| soundtrack.soundtrack_uuid != data.soundtrack_uuid \
|| soundtrack.cover_modified != data.cover_modified
soundtrack = data
if cover_changed:
show_cover(null)
_cover_asked = 0
cover_placeholder.text = initials(data.soundtrack_name)
name_label.text = data.soundtrack_name
tooltip_text = data.soundtrack_name
played_label.text = played_text(data.soundtrack_played)
@@ -55,6 +70,42 @@ func show_soundtrack(data: SoundtrackWithSongs) -> void:
_fill_song_list(least_played_list, least_played)
## Asks [param covers] for the soundtrack's cover, unless it already has, and shows it
## when it comes. Soundtracks without a cover keep their initials.
func load_cover(covers: CoverCache) -> void:
if soundtrack == null || soundtrack.cover_modified <= 0 \
|| _cover_asked == soundtrack.cover_modified:
return
_cover_asked = soundtrack.cover_modified
covers.fetch_cover(soundtrack.soundtrack_uuid, soundtrack.cover_modified, show_cover)
## Takes the soundtrack's new cover time [param modified], 0 when its cover was removed,
## and shows the initials until [method load_cover] fetches the new cover.
func set_cover_time(modified: int) -> void:
soundtrack.cover_modified = modified
show_cover(null)
_cover_asked = 0
## Shows [param cover], or the initials when it is null.
func show_cover(cover: Image) -> void:
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
cover_placeholder.visible = cover == null
## Up to two initials of [param soundtrack_name], e.g. "FF" for "Final Fantasy VI".
static func initials(soundtrack_name: String) -> String:
var letters: String = ""
for word: String in soundtrack_name.split(" ", false):
var first: String = word.left(1)
if first.to_upper() != first.to_lower():
letters += first.to_upper()
if letters.length() == 2:
break
return letters if !letters.is_empty() else soundtrack_name.left(1)
## "Played 3 times", or "Never played" for [code]0[/code].
static func played_text(times_played: int) -> String:
if times_played == 0:
+63 -3
View File
@@ -5,6 +5,9 @@ extends Panel
## cards filtered on whether the soundtrack has been played.
##
## Shown instead of the quiz when the Statistics tab is chosen.
##
## Covers are loaded for the cards on screen as they scroll into view, a few at a time,
## not for every soundtrack when the tab opens. See [CoverCache].
## Emitted when a card is clicked, to show all songs of [param soundtrack].
signal soundtrack_selected(soundtrack: SoundtrackWithSongs)
@@ -19,12 +22,18 @@ enum ShowFilter { ALL, PLAYED, NEVER_PLAYED }
enum Mode { SOUNDTRACKS, SONGS }
const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn")
## How far outside the visible part cards still get their covers, so they are ready when
## scrolled to.
const COVER_MARGIN: float = 400.0
## The cards in the order they are shown, hidden ones included.
var cards: Array[SoundtrackCard] = []
## Whether soundtracks or songs are shown. Change it with [method set_mode].
var mode: Mode = Mode.SOUNDTRACKS
## The covers on disk, shared by the cards.
var covers: CoverCache = CoverCache.new()
var _search: SoundtrackSearch = SoundtrackSearch.new()
var _loading: bool = false
# The soundtracks last loaded, by name
@@ -61,6 +70,11 @@ func _ready() -> void:
sort_option.item_selected.connect(func(_index: int) -> void: apply_sort())
retry_button.pressed.connect(refresh)
retry_button.visible = false
# The cards have moved by the end of the frame
card_scroll.get_v_scroll_bar().value_changed.connect(
func(_value: float) -> void: load_covers_on_screen.call_deferred())
card_scroll.resized.connect(load_covers_on_screen)
visibility_changed.connect(load_covers_on_screen)
## Loads the statistics from the server. The cards already shown stay until the new
@@ -108,6 +122,7 @@ func apply_sort() -> void:
return _comes_before(a.soundtrack, b.soundtrack, order))
for i: int in cards.size():
card_grid.move_child(cards[i], i)
load_covers_on_screen.call_deferred()
## Shows only the cards or songs that match the search text and the show menu.
@@ -124,14 +139,20 @@ func apply_filter() -> void:
var filter: ShowFilter = show_option.selected as ShowFilter
var shown: int = 0
for card: SoundtrackCard in cards:
var was_visible: bool = card.visible
card.visible = (search_text.is_empty() || matching.has(card.soundtrack.soundtrack_name)) \
&& _passes_filter(card.soundtrack, filter)
if card.visible:
shown += 1
# Cards that come back with the filter fade in
if !was_visible:
Anim.fade_in(card)
if shown == cards.size():
count_label.text = "%d soundtracks" % cards.size()
else:
count_label.text = "%d of %d soundtracks" % [shown, cards.size()]
# The grid has moved the cards by the end of the frame
load_covers_on_screen.call_deferred()
func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
@@ -145,19 +166,58 @@ func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
cards.append(card)
while cards.size() > soundtracks.size():
cards.pop_back().free()
var names: Array[String] = []
_soundtracks.clear()
var cover_times: Dictionary = {}
for i: int in soundtracks.size():
cards[i].show_soundtrack(soundtracks[i])
names.append(soundtracks[i].soundtrack_name)
_soundtracks[soundtracks[i].soundtrack_name] = soundtracks[i]
_search.set_names(names)
cover_times[soundtracks[i].soundtrack_uuid] = soundtracks[i].cover_modified
covers.keep_only(cover_times)
_update_search_names()
_songs_ready = false
apply_sort()
if mode == Mode.SOUNDTRACKS:
apply_filter()
## Asks for the covers of the cards that are on screen, or nearly.
func load_covers_on_screen() -> void:
if !is_visible_in_tree() || mode != Mode.SOUNDTRACKS:
return
var view: Rect2 = card_scroll.get_global_rect().grow(COVER_MARGIN)
for card: SoundtrackCard in cards:
if card.visible && view.intersects(card.get_global_rect()):
card.load_cover(covers)
## Shows the new alternative names of the soundtrack [param updated] and finds it by
## them, without loading the statistics again.
func update_alternative_names(updated: Soundtrack) -> void:
for soundtrack: SoundtrackWithSongs in _soundtracks.values():
if soundtrack.soundtrack_uuid == updated.uuid:
soundtrack.alternative_names = updated.alternative_names
_update_search_names()
apply_filter()
## Shows the new cover of the soundtrack [param uuid], with the cover time
## [param modified], or its initials when [param modified] is 0.
func update_cover(uuid: String, modified: int) -> void:
for card: SoundtrackCard in cards:
if card.soundtrack.soundtrack_uuid == uuid:
card.set_cover_time(modified)
load_covers_on_screen()
func _update_search_names() -> void:
var names: Array[String] = []
var alternatives: Dictionary = {}
for soundtrack: SoundtrackWithSongs in _soundtracks.values():
names.append(soundtrack.soundtrack_name)
alternatives[soundtrack.soundtrack_name] = soundtrack.alternative_names
_search.set_names(names, alternatives)
# Typing in the song list waits for a pause, since searching every song takes a moment
func _on_search_text_changed(_text: String) -> void:
if mode == Mode.SONGS:
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+11799 -2357
View File
File diff suppressed because it is too large. Load diff
+35 -7
View File
@@ -25,6 +25,15 @@ var requested_paths: Array[String] = []
var requested_methods: Array[int] = []
## Headers of each request, in the same order as [member requested_paths].
var requested_headers: Array[PackedStringArray] = []
## The body of each request as text, e.g. its JSON, empty for none, in the same order as
## [member requested_paths].
var requested_bodies: Array[String] = []
## The body of each request as bytes, e.g. an uploaded picture, in the same order as
## [member requested_paths].
var requested_raw_bodies: Array[PackedByteArray] = []
## Whether each request followed redirects, in the same order as
## [member requested_paths].
var requested_follow_redirects: Array[bool] = []
## How many tokens have been asked for.
var token_requests: int = 0
## The JSON body of the last token request.
@@ -61,6 +70,14 @@ func uninstall() -> void:
Settings.queue_ids = _previous_queue_ids
## Answers requests to [param path] with a redirect to [param location], like the
## server's download routes. Requests that follow redirects get an empty 200 instead, as
## if they had followed it.
func respond_redirect(path: String, location: String) -> void:
_responses[path] = {"code": 302, "body": PackedByteArray(), "location": location}
_successes_left.erase(path)
## Answers requests to [param path] with [param data] encoded as JSON.
func respond_json(path: String, data: Variant, response_code: int = 200) -> void:
respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code)
@@ -107,30 +124,35 @@ func request(
method: int,
on_complete: Callable,
headers: PackedStringArray = PackedStringArray(),
body: String = "",
body: PackedByteArray = PackedByteArray(),
follow_redirects: bool = true,
) -> void:
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
if path == "/token" && method == HTTPClient.METHOD_POST:
token_requests += 1
if _held_paths.has(path):
_held_paths[path].append(_answer_token_request.bind(body, on_complete))
_held_paths[path].append(
_answer_token_request.bind(body.get_string_from_utf8(), on_complete))
return
_answer_token_request(body, on_complete)
_answer_token_request(body.get_string_from_utf8(), on_complete)
return
requested_paths.append(path)
requested_methods.append(method)
requested_headers.append(headers)
requested_bodies.append(body.get_string_from_utf8())
requested_raw_bodies.append(body)
requested_follow_redirects.append(follow_redirects)
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
PackedStringArray())
return
if _held_paths.has(path):
_held_paths[path].append(_answer.bind(path, on_complete))
_held_paths[path].append(_answer.bind(path, on_complete, follow_redirects))
return
_answer(path, on_complete)
_answer(path, on_complete, follow_redirects)
func _answer(path: String, on_complete: Callable) -> void:
func _answer(path: String, on_complete: Callable, follow_redirects: bool = true) -> void:
var headers: PackedStringArray = []
if !queue_id.is_empty() && path != "/token":
headers.append("X-Queue-Id: " + queue_id)
@@ -139,7 +161,13 @@ func _answer(path: String, on_complete: Callable) -> void:
on_complete.call(503, PackedByteArray(), headers)
return
_successes_left[path] -= 1
if _responses.has(path):
if _responses.has(path) && _responses[path].has("location"):
if follow_redirects:
on_complete.call(200, PackedByteArray(), headers)
else:
headers.append("Location: " + _responses[path].location)
on_complete.call(302, PackedByteArray(), headers)
elif _responses.has(path):
on_complete.call(_responses[path].code, _responses[path].body, headers)
else:
on_complete.call(404, PackedByteArray(), headers)
+3 -1
View File
@@ -26,10 +26,12 @@ func before_test() -> void:
QuizSave.delete()
Settings.add_to_stats = true
Settings.hide_next_track = true
Settings.use_low_played_mode = false
Settings.random_mode = Settings.RandomMode.NORMAL
Settings.winning_score = 20
# Otherwise the About popup opens at start, as for a new version
Settings.last_seen_version = Settings.version
# Popups open and close at once, so the tests don't wait for animations
Settings.reduce_animations = true
_server = FakeHttpTransport.new().install()
var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
+56 -4
View File
@@ -15,9 +15,10 @@ const SOUNDTRACKS: Array[String] = [
"Donkey Kong Country 2", "Lufia II", "Illusion of Gaia",
]
const SONG_INFO: Dictionary = {
"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time",
"SongNo": 0, "Soundtrack": "Chrono Trigger", "SoundtrackUuid": "soundtrack-0",
"Song": "Corridors of Time",
}
const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00:00:02"}
const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00:02"}
const SYNC_RESULT: Dictionary = {
"soundtracks_added": ["Terranigma", "Lufia II"],
"soundtracks_re_added": [],
@@ -25,7 +26,7 @@ const SYNC_RESULT: Dictionary = {
"soundtracks_changed_content": ["Chrono Trigger"],
"soundtracks_removed": ["Old Soundtrack"],
"catched_errors": [],
"total_time": "00:00:03",
"total_time": "00:03",
}
## The server's version history, newest first.
@@ -48,6 +49,8 @@ const JAPANESE_SONGS: Array[String] = [
## The character pictures the fake server hands out.
const CHARACTERS_DIR: String = "res://characters"
## Small copies of covers from the MusicServer, 2:3 like all its covers.
const COVERS_DIR: String = "res://tests/data/covers"
## Popups in the order the tour opens them: the state name, then the popup's node. The
## state names are what the popups were first called, so screenshots taken before they
@@ -91,6 +94,18 @@ static func make_server() -> FakeHttpTransport:
server.respond_bytes("/sync", PackedByteArray())
server.respond_json("/sync/progress", SYNC_PROGRESS)
server.respond_json("/statistics/soundtracks", soundtrack_statistics())
# Real covers from the server, made smaller, for every other soundtrack
for i: int in range(0, SOUNDTRACKS.size(), 2):
server.respond_bytes("/soundtracks/soundtrack-%d/cover" % i,
FileAccess.get_file_as_bytes(COVERS_DIR.path_join("cover_%d.jpg" % (i >> 1))))
# The soundtrack list tells the quiz which soundtracks have covers
var soundtracks: Array[Dictionary] = []
for soundtrack: Dictionary in soundtrack_statistics():
soundtracks.append({"uuid": soundtrack.soundtrack_uuid,
"name": soundtrack.soundtrack_name,
"cover_modified": soundtrack.get("cover_modified", 0),
"alternative_names": soundtrack.get("alternative_names", [])})
server.respond_json("/soundtracks", soundtracks)
server.respond_json("/version/history", SERVER_HISTORY)
return server
@@ -118,6 +133,11 @@ static func soundtrack_statistics() -> Array[Dictionary]:
}
if played > 0:
soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1)
# Every other soundtrack has a cover, the rest show their initials
if i % 2 == 0:
soundtrack.cover_modified = 1700000000
if i == 0:
soundtrack.alternative_names = ["CT", "クロノ・トリガー"]
statistics.append(soundtrack)
return statistics
@@ -172,9 +192,11 @@ static func prepare_autoloads() -> void:
Settings.character_select_open = false
Settings.add_to_stats = true
Settings.hide_next_track = true
Settings.use_low_played_mode = false
Settings.random_mode = Settings.RandomMode.NORMAL
Settings.winning_score = 20
Settings.last_seen_version = Settings.version
# The same picture every time, without half-finished animations
Settings.reduce_animations = true
# The inspiration list scrolls by itself, which would make every picture different
Settings.inspiration_list_speed = 0
@@ -254,6 +276,27 @@ func run(on_state: Callable) -> void:
top_bar.show_latest_version("99.10.10")
await _settle()
await on_state.call("update_available", null)
var update_popup: UpdatePopup = _node("UpdatePopup")
var updater: Updater = update_popup.updater
updater.in_editor = false
updater.os_name = "Windows"
DirAccess.make_dir_recursive_absolute(Settings.config_dir())
updater.executable_path = Settings.config_dir().path_join("MusicPlayer.exe")
update_popup.show_update("99.10.10")
await _settle()
await on_state.call("popup_UpdateWindow", update_popup)
update_popup.download_progress.visible = true
update_popup._set_busy(true)
update_popup._show_status("")
update_popup._on_progress(31 << 20, 80 << 20)
await _settle()
await on_state.call("popup_UpdateWindow_downloading", update_popup)
updater.in_editor = true
update_popup.show_update("99.10.10")
await _settle()
await on_state.call("popup_UpdateWindow_browser_only", update_popup)
update_popup.close()
await _settle()
top_bar.show_latest_version("1.0.0")
await _settle()
@@ -290,6 +333,15 @@ func run(on_state: Callable) -> void:
break
await _settle()
await on_state.call("popup_SoundtrackStats", stats_popup)
stats_popup.edit_names()
stats_popup.new_name_field.text = "Chrono"
await _settle()
await on_state.call("popup_SoundtrackStats_names", stats_popup)
stats_popup.change_cover()
stats_popup.cover_id_field.text = "5248061"
await _settle()
await on_state.call("popup_SoundtrackStats_cover", stats_popup)
stats_popup.stop_editing()
stats_popup.close()
_main.show_tab(&"quiz")
await _settle()
+215
View File
@@ -0,0 +1,215 @@
extends MainWindowTestSuite
## The animations, with Reduce animations off: popups, points, songs starting, the
## winner, filtering, the tabs and the buttons. The other tests run with animations off.
var _settings: SettingsPopup
func before_test() -> void:
await super()
Settings.reduce_animations = false
_settings = _window.get_node("SettingsPopup")
func _wait(seconds: float) -> void:
await await_millis(int(seconds * 1000) + 60)
func _layers(container: Node) -> Array[Node]:
return container.get_children().filter(func(node: Node) -> bool: return node is ColorRect)
func test_popup_fades_in_while_growing() -> void:
_settings.open()
assert_float(_settings.settings_panel.modulate.a).is_less(1.0)
assert_float(_settings.settings_panel.scale.x).is_less(1.0)
await _wait(Anim.NORMAL)
assert_float(_settings.settings_panel.modulate.a).is_equal(1.0)
assert_vector(_settings.settings_panel.scale).is_equal(Vector2.ONE)
func test_closing_popup_hides_after_fading_out() -> void:
_settings.open()
await _wait(Anim.NORMAL)
_settings.close()
assert_bool(_settings.visible).is_true()
assert_bool(_settings.is_closing).is_true()
await _wait(Anim.FAST)
assert_bool(_settings.visible).is_false()
assert_bool((_window.get_node("PopupBackdrop") as Control).visible).is_false()
# Ready to open again at full size
assert_float(_settings.settings_panel.modulate.a).is_equal(1.0)
func test_popup_opened_again_while_closing_stays_open() -> void:
_settings.open()
await _wait(Anim.NORMAL)
_settings.close()
_settings.open()
await _wait(Anim.NORMAL)
assert_bool(_settings.visible).is_true()
var backdrop: Control = _window.get_node("PopupBackdrop")
assert_bool(backdrop.visible).is_true()
assert_float(backdrop.modulate.a).is_equal(1.0)
func test_reduce_animations_opens_and_closes_at_once() -> void:
Settings.reduce_animations = true
_settings.open()
assert_float(_settings.settings_panel.modulate.a).is_equal(1.0)
_settings.close()
assert_bool(_settings.visible).is_false()
func test_reduce_animations_switch_is_saved() -> void:
# The switch shows the setting the window started with, which the tests turn on
assert_bool(_settings.reduce_animations_button.button_pressed).is_true()
_settings.reduce_animations_button.button_pressed = false
assert_bool(Settings.reduce_animations).is_false()
_settings.reduce_animations_button.button_pressed = true
assert_bool(Settings.reduce_animations).is_true()
var saved: ConfigFile = ConfigFile.new()
assert_int(saved.load(Settings.saved_settings_path())).is_equal(OK)
assert_bool(saved.get_value("app", "reduce_animations")).is_true()
func test_first_point_flashes_the_row_and_pops_the_score() -> void:
var crono: PlayerObject = _add_player("Crono")
await _play_first_song()
var row: PlayerRow = _player_row(crono)
row.add_point()
assert_array(_layers(row)).has_size(1)
assert_float(row.score_label.scale.x).is_greater(1.0)
await _wait(Anim.SLOW * 2)
assert_array(_layers(row)).is_empty()
assert_vector(row.score_label.scale).is_equal(Vector2.ONE)
func test_later_points_only_pop_the_score() -> void:
var crono: PlayerObject = _add_player("Crono")
await _play_first_song()
var row: PlayerRow = _player_row(crono)
row.add_point()
await _wait(Anim.SLOW * 2)
row.add_point()
assert_array(_layers(row)).is_empty()
assert_float(row.score_label.scale.x).is_greater(1.0)
func test_match_point_glows_and_says_so_until_it_changes() -> void:
Settings.winning_score = 3
var crono: PlayerObject = _add_player("Crono")
await _play_first_song()
var row: PlayerRow = _player_row(crono)
row.add_point()
row.add_point()
assert_bool(row.match_point_tag.visible).is_true()
assert_object(row.get_node_or_null("Pulse")).is_not_null()
row.minus_point()
assert_bool(row.match_point_tag.visible).is_false()
assert_object(row.get_node_or_null("Pulse")).is_null()
func test_match_point_glows_still_with_reduced_animations() -> void:
Settings.reduce_animations = true
Settings.winning_score = 2
var crono: PlayerObject = _add_player("Crono")
await _play_first_song()
_player_row(crono).add_point()
assert_object(_player_row(crono).get_node_or_null("Pulse")).is_not_null()
func test_song_start_fades_in_the_title_and_flashes_its_row() -> void:
Settings.hide_next_track = false
# The next song is downloaded while the first plays
_next_song_is({"SongNo": 1, "Soundtrack": "Final Fantasy VI", "Song": "Dancing Mad"})
await _play_first_song()
await _wait(Anim.SLOW)
_press("SongControls/NextButton")
await _wait(0.55)
var now_playing: Control = _quiz.get_node("NowPlaying")
assert_float(now_playing.modulate.a).is_less(1.0)
var rows: Array[Node] = _quiz.music_list_panel.get_node("%MusicList").get_children()
var flashing: Array = rows.filter(func(row: Node) -> bool: return !_layers(row).is_empty())
assert_array(flashing).is_not_empty()
func test_winner_gets_confetti_and_a_graph_drawn_from_the_left() -> void:
Settings.winning_score = 1
var crono: PlayerObject = _add_player("Crono")
await _play_first_song()
_player_row(crono).add_point()
await _runner.simulate_frames(1)
var winner: WinnerPopup = _quiz.winner_popup
assert_bool(winner.visible).is_true()
assert_object(winner.winner_panel.get_node_or_null("Confetti")).is_not_null()
assert_float(winner.score_graph.progress).is_less(1.0)
await _wait(1.3)
assert_float(winner.score_graph.progress).is_equal(1.0)
func test_graph_lines_are_cut_where_the_drawing_has_reached() -> void:
var points: PackedVector2Array = [Vector2(0, 0), Vector2(10, 0), Vector2(10, 10),
Vector2(30, 10)]
assert_array(Array(ScoreGraph.clip_line(points, 20))) \
.contains_exactly([Vector2(0, 0), Vector2(10, 0), Vector2(10, 10), Vector2(20, 10)])
assert_array(Array(ScoreGraph.clip_line(points, 100))).has_size(4)
assert_array(Array(ScoreGraph.clip_line(points, 5))) \
.contains_exactly([Vector2(0, 0), Vector2(5, 0)])
func test_statistics_tab_slides_in_and_ends_in_place() -> void:
var statistics: Control = _window.get_node("StatisticsView")
var home: Vector2 = statistics.position
_window.show_tab(&"statistics")
assert_float(statistics.position.x).is_greater(home.x)
await _wait(Anim.NORMAL)
assert_vector(statistics.position).is_equal(home)
assert_float(statistics.modulate.a).is_equal(1.0)
func test_every_button_bounces() -> void:
var sync: Button = _window.get_node("TopBar/%SyncButton")
var reset: Button = _quiz.get_node("QuizToolbar/ResetPointsButton")
var crono: PlayerObject = _add_player("Crono")
for button: BaseButton in [sync, reset, _player_row(crono).add_button]:
assert_bool(button.has_meta(&"button_bounce_added")).override_failure_message(
button.name).is_true()
func test_helpers_do_nothing_with_reduced_animations() -> void:
Settings.reduce_animations = true
var label: Label = _quiz.get_node("NowPlaying/Titles/SongLabel")
Anim.pop(label)
Anim.fade_in(label)
assert_vector(label.scale).is_equal(Vector2.ONE)
assert_float(label.modulate.a).is_equal(1.0)
+1
View File
@@ -0,0 +1 @@
uid://cuukc27kr8360
+100
View File
@@ -0,0 +1,100 @@
extends MainWindowTestSuite
## Clear cache in the settings: deletes the downloaded character pictures, covers and
## songs, but not the songs the quiz uses, the settings or the saved quiz.
var _popup: SettingsPopup
func before_test() -> void:
# The cache is kept next to the settings, which the tests keep in a folder of their own
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("clear_cache_%d" % randi()))
await super()
_popup = _window.get_node("SettingsPopup")
func _write(path: String, size: int) -> void:
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
var bytes: PackedByteArray = PackedByteArray()
bytes.resize(size)
file.store_buffer(bytes)
file.close()
func _files_in(dir: String) -> PackedStringArray:
return DirAccess.get_files_at(dir) if DirAccess.dir_exists_absolute(dir) \
else PackedStringArray()
func _fill_cache() -> String:
_write(CharacterCache.folder().path_join("Crono.png"), 3000)
_write(CoverCache.folder().path_join("st-1"), 2000)
var old_song: String = Playlist.songs_folder().path_join("old.mp3")
_write(old_song, 5000)
return old_song
func test_shows_how_much_the_cache_takes() -> void:
_fill_cache()
_popup.open()
assert_str(_popup.cache_size_label.text).is_equal(
SettingsPopup.size_text(SettingsPopup.cache_size()))
assert_int(SettingsPopup.cache_size()).is_greater_equal(10000)
assert_bool(_popup.clear_cache_button.disabled).is_false()
func test_clear_cache_deletes_pictures_covers_and_old_songs() -> void:
var old_song: String = _fill_cache()
var playing: PackedStringArray = Playlist.song_files_in_use()
assert_array(playing).is_not_empty()
var lists_before: int = _server.count("/character/list")
_popup.open()
_popup.clear_cache()
assert_array(_files_in(CharacterCache.folder())).is_empty()
assert_array(_files_in(CoverCache.folder())).is_empty()
assert_bool(FileAccess.file_exists(old_song)).is_false()
# The quiz's songs stay, so they can still be played
for path: String in playing:
assert_bool(FileAccess.file_exists(path)).is_true()
assert_str(_popup.cache_size_label.text).starts_with("Cleared ")
assert_bool(_popup.clear_cache_button.disabled).is_true()
# The characters are loaded again at once
assert_int(_server.count("/character/list")).is_greater(lists_before)
func test_clear_cache_keeps_the_settings_and_the_saved_quiz() -> void:
Settings.save_settings()
_quiz.save_soon()
await _runner.simulate_frames(1)
_popup.open()
_popup.clear_cache()
assert_bool(FileAccess.file_exists(Settings.saved_settings_path())).is_true()
assert_bool(FileAccess.file_exists(QuizSave.path())).is_true()
func test_nothing_to_clear() -> void:
for dir: String in [CharacterCache.folder(), CoverCache.folder()]:
for file_name: String in _files_in(dir):
DirAccess.remove_absolute(dir.path_join(file_name))
Playlist.clear_song_files(Playlist.song_files_in_use())
_popup.open()
assert_str(_popup.cache_size_label.text).is_equal("Nothing")
assert_bool(_popup.clear_cache_button.disabled).is_true()
@warning_ignore("unused_parameter")
func test_size_text(bytes: int, text: String, test_parameters: Array = [
[0, "Nothing"],
[1, "1 KB"],
[350 * 1024, "350 KB"],
[int(14.2 * 1024 * 1024), "14.2 MB"],
]) -> void:
assert_str(SettingsPopup.size_text(bytes)).is_equal(text)
@@ -0,0 +1 @@
uid://da5wh1me3wncq
+2 -2
View File
@@ -55,8 +55,8 @@ func _play_a_little() -> Array[PlayerObject]:
func _now_playing() -> Array[String]:
var now_playing: Node = _quiz.get_node("NowPlaying")
return [(now_playing.get_node("SoundtrackLabel") as Label).text,
(now_playing.get_node("SongLabel") as Label).text]
return [(now_playing.get_node("Titles/SoundtrackLabel") as Label).text,
(now_playing.get_node("Titles/SongLabel") as Label).text]
# Saving
+392
View File
@@ -0,0 +1,392 @@
extends MainWindowTestSuite
## Soundtrack covers on the statistics cards, in the popup and next to the answer in the
## quiz, loaded for the cards on screen; changing a cover from the popup; and editing a
## soundtrack's alternative names, which search then finds it by.
const STATISTICS: Array[Dictionary] = [
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", "soundtrack_played": 5,
"cover_modified": 100, "alternative_names": [], "songs": [
{"song_name": "Corridors of Time", "times_played": 1},
]},
{"soundtrack_uuid": "st-2", "soundtrack_name": "Terranigma", "soundtrack_played": 0,
"songs": []},
]
var _view: StatisticsView
var _popup: SoundtrackStatsPopup
var _png: PackedByteArray
func _before_window_starts() -> void:
_server.respond_json("/statistics/soundtracks", STATISTICS)
var image: Image = Image.create(6, 8, false, Image.FORMAT_RGB8)
_png = image.save_png_to_buffer()
_server.respond_bytes("/soundtracks/st-1/cover", _png)
# The soundtrack list tells the quiz which soundtracks have covers
_server.respond_json("/soundtracks", [
{"uuid": "st-1", "name": "Chrono Trigger", "cover_modified": 100},
{"uuid": "st-2", "name": "Terranigma"},
])
_next_song_is({"SongNo": 0, "Soundtrack": "Chrono Trigger", "SoundtrackUuid": "st-1",
"Song": "Corridors of Time"})
func before_test() -> void:
# Covers are kept next to the settings, which the tests keep in a folder of their own
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("covers_%d" % randi()))
await super()
_view = _window.get_node("StatisticsView")
_popup = _window.get_node("SoundtrackStatsPopup")
func _open_statistics() -> void:
_window.show_tab(&"statistics")
await _runner.simulate_frames(3)
func _card(soundtrack_name: String) -> SoundtrackCard:
for card: SoundtrackCard in _view.cards:
if card.soundtrack.soundtrack_name == soundtrack_name:
return card
return null
func test_card_shows_its_cover() -> void:
await _open_statistics()
var card: SoundtrackCard = _card("Chrono Trigger")
assert_object(card.cover_picture.texture).is_not_null()
assert_bool(card.cover_placeholder.visible).is_false()
func test_card_without_a_cover_shows_initials_and_asks_nothing() -> void:
await _open_statistics()
var card: SoundtrackCard = _card("Terranigma")
assert_object(card.cover_picture.texture).is_null()
assert_bool(card.cover_placeholder.visible).is_true()
assert_str(card.cover_placeholder.text).is_equal("T")
assert_int(_server.count("/soundtracks/st-2/cover")).is_equal(0)
func test_cover_missing_on_the_server_keeps_the_initials() -> void:
_server.respond_bytes("/soundtracks/st-1/cover", PackedByteArray(), 404)
await _open_statistics()
assert_bool(_card("Chrono Trigger").cover_placeholder.visible).is_true()
assert_str(_card("Chrono Trigger").cover_placeholder.text).is_equal("CT")
func test_covers_load_for_cards_on_screen_as_they_scroll_in() -> void:
var many: Array[Dictionary] = []
for i: int in 40:
many.append({"soundtrack_uuid": "many-%02d" % i, "soundtrack_name": "Game %02d" % i,
"soundtrack_played": 1, "cover_modified": 100, "songs": []})
_server.respond_bytes("/soundtracks/many-%02d/cover" % i, _png)
_server.respond_json("/statistics/soundtracks", many)
await _open_statistics()
var asked: Callable = func() -> int:
return _server.requested_paths.filter(
func(path: String) -> bool: return path.begins_with("/soundtracks/many-")).size()
var on_screen: int = asked.call()
assert_int(on_screen).is_greater(0)
assert_int(on_screen).is_less(40)
var scroll_bar: VScrollBar = _view.card_scroll.get_v_scroll_bar()
scroll_bar.value = scroll_bar.max_value
await _runner.simulate_frames(2)
assert_int(asked.call()).is_greater(on_screen)
func test_popup_shows_the_cover_large() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
assert_object(_popup.cover_picture.texture).is_not_null()
assert_bool(_popup.cover_placeholder.visible).is_false()
assert_str(_popup.names_label.text).is_equal("No other names yet")
func test_editing_names_saves_them_and_search_finds_the_new_name() -> void:
_server.respond_json("/soundtracks/st-1/alternative-names", {"uuid": "st-1",
"name": "Chrono Trigger", "alternative_names": ["CT", "クロノ・トリガー"]})
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
assert_bool(_popup.names_editor.visible).is_true()
_popup.new_name_field.text = "CT"
_popup.add_name()
_popup.new_name_field.text = "クロノ・トリガー"
_popup.save_names()
var index: int = _server.requested_paths.find("/soundtracks/st-1/alternative-names")
assert_int(index).is_not_equal(-1)
assert_int(_server.requested_methods[index]).is_equal(HTTPClient.METHOD_PUT)
var body: Dictionary = JSON.parse_string(_server.requested_bodies[index])
assert_array(body.names).contains_exactly(["CT", "クロノ・トリガー"])
assert_bool(_popup.names_editor.visible).is_false()
assert_str(_popup.names_label.text).is_equal("CT, クロノ・トリガー")
_view.search_field.text = "クロノ"
_view.apply_filter()
var shown: Array = _view.cards.filter(func(card: SoundtrackCard) -> bool: return card.visible)
assert_array(shown.map(func(card: SoundtrackCard) -> String:
return card.soundtrack.soundtrack_name)).contains_exactly(["Chrono Trigger"])
func test_editor_skips_empty_repeated_and_own_names() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
for name: String in [" ", "CT", "ct", "Chrono Trigger"]:
_popup.new_name_field.text = name
_popup.add_name()
assert_int(_popup.editor_names.get_child_count()).is_equal(1)
func test_removing_a_name_in_the_editor() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
_popup.new_name_field.text = "CT"
_popup.add_name()
_popup.remove_name("CT")
assert_int(_popup.editor_names.get_child_count()).is_equal(0)
func test_failed_save_keeps_the_editor_open() -> void:
_server.respond_json("/soundtracks/st-1/alternative-names", {}, 500)
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.edit_names()
_popup.new_name_field.text = "CT"
_popup.save_names()
assert_bool(_popup.names_editor.visible).is_true()
assert_bool(_popup.editor_status.visible).is_true()
assert_bool(_popup.save_names_button.disabled).is_false()
func test_search_popup_finds_soundtracks_by_alternative_names() -> void:
_server.respond_json("/soundtracks", [
{"uuid": "st-1", "name": "Chrono Trigger", "alternative_names": ["CT"]},
{"uuid": "st-2", "name": "Terranigma", "alternative_names": []},
])
var search: SearchPopup = _window.get_node("SearchPopup")
search.get_list_of_soundtracks()
search.open()
search.search_field.text = "ct"
search.search()
assert_array(search.shown_names()).contains(["Chrono Trigger"])
var row: PanelContainer = search.search_results.get_child(0)
assert_str((row.get_child(0) as RichTextLabel).get_parsed_text()).contains("CT")
func _open_cover_menu() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
_popup.change_cover()
# The server's answer for the soundtrack once its cover changed
func _cover_time_becomes(modified: int) -> void:
_server.respond_json("/soundtracks/st-1", {"uuid": "st-1", "name": "Chrono Trigger",
"cover_modified": modified, "songs": []})
func _picture_file(bytes: PackedByteArray) -> String:
var path: String = create_temp_dir("pictures_%d" % randi()).path_join("cover.jpg")
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
file.store_buffer(bytes)
file.close()
return path
func _downloads(path: String) -> int:
var count: int = 0
for i: int in _server.requested_paths.size():
if _server.requested_paths[i] == path \
&& _server.requested_methods[i] == HTTPClient.METHOD_GET:
count += 1
return count
func _now_playing() -> NowPlaying:
return _quiz.get_node("NowPlaying")
func test_cover_menu_opens_in_place_of_the_songs() -> void:
await _open_cover_menu()
assert_bool(_popup.cover_editor.visible).is_true()
assert_bool(_popup.song_scroll.visible).is_false()
assert_bool(_popup.edit_names_button.disabled).is_true()
assert_bool(_popup.remove_cover_button.disabled).is_false()
_popup.stop_editing()
assert_bool(_popup.cover_editor.visible).is_false()
assert_bool(_popup.song_scroll.visible).is_true()
func test_soundtrack_without_a_cover_has_nothing_to_remove() -> void:
await _open_statistics()
_popup.show_soundtrack(_card("Terranigma").soundtrack)
_popup.change_cover()
assert_bool(_popup.remove_cover_button.disabled).is_true()
func test_uploaded_picture_becomes_the_cover() -> void:
var bytes: PackedByteArray = FileAccess.get_file_as_bytes(
"res://tests/data/covers/cover_0.jpg")
_cover_time_becomes(200)
await _open_cover_menu()
_popup.upload_picture(_picture_file(bytes))
var index: int = _server.requested_methods.find(HTTPClient.METHOD_PUT)
assert_str(_server.requested_paths[index]).is_equal("/soundtracks/st-1/cover")
assert_array(_server.requested_raw_bodies[index]).is_equal(bytes)
assert_array(_server.requested_headers[index]).contains(["Content-Type: image/jpeg"])
# The new cover is downloaded again for the popup and the card
assert_int(_card("Chrono Trigger").soundtrack.cover_modified).is_equal(200)
await _runner.simulate_frames(2)
# Downloaded once more, for the popup and the card together
assert_int(_downloads("/soundtracks/st-1/cover")).is_equal(2)
assert_object(_card("Chrono Trigger").cover_picture.texture).is_not_null()
assert_str(_popup.cover_status.text).is_equal("The new cover is saved.")
func test_file_that_is_not_a_picture_is_not_uploaded() -> void:
await _open_cover_menu()
_popup.upload_picture(_picture_file("not a picture".to_utf8_buffer()))
assert_int(_server.requested_methods.count(HTTPClient.METHOD_PUT)).is_equal(0)
assert_str(_popup.cover_status.text).contains("JPEG, PNG and WebP")
func test_cover_picked_by_id() -> void:
_server.respond_bytes("/soundtracks/st-1/cover/source", PackedByteArray(), 204)
_cover_time_becomes(300)
await _open_cover_menu()
_popup.cover_source_button.select(1)
_popup.cover_id_field.text = " 21 "
_popup.use_cover_source()
var index: int = _server.requested_paths.find("/soundtracks/st-1/cover/source")
assert_int(_server.requested_methods[index]).is_equal(HTTPClient.METHOD_PUT)
var body: Dictionary = JSON.parse_string(_server.requested_bodies[index])
assert_str(body.source).is_equal("anilist")
assert_str(body.id).is_equal("21")
assert_int(_popup.soundtrack.cover_modified).is_equal(300)
func test_no_id_asks_nothing() -> void:
await _open_cover_menu()
_popup.use_cover_source()
assert_int(_server.count("/soundtracks/st-1/cover/source")).is_equal(0)
func test_removed_cover_shows_the_initials() -> void:
_cover_time_becomes(0)
await _open_cover_menu()
_popup.remove_cover()
var index: int = _server.requested_methods.find(HTTPClient.METHOD_DELETE)
assert_str(_server.requested_paths[index]).is_equal("/soundtracks/st-1/cover")
assert_bool(_popup.cover_placeholder.visible).is_true()
assert_bool(_card("Chrono Trigger").cover_placeholder.visible).is_true()
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_false()
assert_bool(_popup.remove_cover_button.disabled).is_true()
func test_refused_change_shows_the_servers_reason() -> void:
_server.respond_json("/soundtracks/st-1/cover/source",
{"error": "covers are not enabled on this server"}, 503)
await _open_cover_menu()
_popup.cover_id_field.text = "21"
_popup.use_cover_source()
assert_str(_popup.cover_status.text).is_equal(
"Could not change the cover: covers are not enabled on this server.")
assert_bool(_popup.use_source_button.disabled).is_false()
func test_answer_shows_the_soundtracks_cover() -> void:
await _play_first_song()
assert_bool(_now_playing().has_cover()).is_false()
_press("SongControls/ShowAnswerButton")
assert_bool(_now_playing().has_cover()).is_true()
await _runner.simulate_frames(1)
# The cover sits just left of the titles, and the two are centered together
var area: Rect2 = _now_playing().get_global_rect()
var cover: Rect2 = _now_playing().cover_frame.get_global_rect()
var titles: Rect2 = _now_playing().titles.get_global_rect()
assert_float(titles.position.x - cover.end.x).is_equal(32.0)
assert_float(absf((cover.position.x - area.position.x) - (area.end.x - titles.end.x))) \
.is_less_equal(1.0)
# Up and down, the cover is centered between the top bar and the song list, and the
# titles on the cover
assert_float(absf((cover.position.y - area.position.y) - (area.end.y - cover.end.y))) \
.is_less_equal(1.0)
var text_middle: float = (_now_playing().soundtrack_label.get_global_rect().position.y
+ _now_playing().song_label.get_global_rect().end.y) / 2.0
assert_float(absf(text_middle - cover.get_center().y)).is_less_equal(1.0)
func test_answer_without_a_cover_shows_only_the_titles() -> void:
_view.covers.set_cover_times({"st-1": 0})
await _play_first_song()
_press("SongControls/ShowAnswerButton")
assert_bool(_now_playing().has_cover()).is_false()
assert_int(_server.count("/soundtracks/st-1/cover")).is_equal(0)
func test_next_song_hides_the_cover_until_its_answer() -> void:
await _play_first_song()
_press("SongControls/ShowAnswerButton")
_press("SongControls/NextButton")
# The playing song is stopped first, which takes half a second
await await_millis(700)
assert_int(Playlist.currently_playing_song).is_equal(1)
assert_bool(_now_playing().has_cover()).is_false()
func test_changed_cover_shows_in_the_quiz_too() -> void:
await _play_first_song()
_press("SongControls/ShowAnswerButton")
_cover_time_becomes(0)
_popup.show_soundtrack(SoundtrackWithSongs.from_dict(STATISTICS[0]))
_popup.change_cover()
_popup.remove_cover()
assert_bool(_now_playing().has_cover()).is_false()
@@ -0,0 +1 @@
uid://d3oc3e1fsttoh
+3 -3
View File
@@ -11,7 +11,7 @@ func test_startup_loads_soundtracks_and_preloads_a_song() -> void:
func test_low_played_mode_fetches_from_low_played_endpoint() -> void:
Settings.use_low_played_mode = true
Settings.random_mode = Settings.RandomMode.LOW_PLAYED
_server.respond_bytes(
"/music/rand/low", FileAccess.get_file_as_bytes("res://tests/data/short.mp3"))
@@ -33,7 +33,7 @@ func test_next_plays_preloaded_song_and_fetches_another() -> void:
func test_title_is_hidden_until_answer_is_shown() -> void:
var soundtrack_label: Label = _quiz.get_node("NowPlaying/SoundtrackLabel")
var soundtrack_label: Label = _quiz.get_node("NowPlaying/Titles/SoundtrackLabel")
_press("SongControls/NextButton")
await _runner.simulate_frames(2)
@@ -224,7 +224,7 @@ func test_show_answer_before_first_song_does_not_reveal_next_song() -> void:
await _play_first_song()
assert_str((_quiz.get_node("NowPlaying/SoundtrackLabel") as Label).text) \
assert_str((_quiz.get_node("NowPlaying/Titles/SoundtrackLabel") as Label).text) \
.is_equal("????????")
+14 -1
View File
@@ -11,7 +11,7 @@ func before_test() -> void:
func _label(label_name: String) -> Label:
return _now_playing.get_node(label_name)
return _now_playing.get_node("Titles/" + label_name)
func test_starts_empty() -> void:
@@ -32,3 +32,16 @@ func test_soundtrack_is_above_song() -> void:
assert_float(_label("SoundtrackLabel").get_global_rect().end.y) \
.is_less_equal(_label("SongLabel").get_global_rect().position.y)
func test_long_titles_keep_to_two_lines_each() -> void:
_now_playing.size = Vector2(1088, 328)
_now_playing.show_song("Final Fantasy Dissidia 012 Duodecim (Sista Fantasin "
+ "Meningsskiljaktigheter 012 Tolv) Final Mix Special Edition",
"Cosmos (Arrangement) - Main Theme of the Warriors of Light, Long Version")
await _runner.simulate_frames(2)
for label_name: String in ["SoundtrackLabel", "SongLabel"]:
assert_int(_label(label_name).get_visible_line_count()).is_less_equal(2)
assert_float(_label("SongLabel").get_global_rect().end.y) \
.is_less_equal(_now_playing.get_global_rect().end.y)
+2 -2
View File
@@ -54,8 +54,8 @@ func _next() -> void:
func _now_playing() -> Array[String]:
var now_playing: Node = _quiz.get_node("NowPlaying")
return [(now_playing.get_node("SoundtrackLabel") as Label).text,
(now_playing.get_node("SongLabel") as Label).text]
return [(now_playing.get_node("Titles/SoundtrackLabel") as Label).text,
(now_playing.get_node("Titles/SongLabel") as Label).text]
func test_picking_a_folder_makes_no_server_requests() -> void:
@@ -177,3 +177,33 @@ func test_changes_are_saved_at_once() -> void:
assert_int(saved.load(Settings.saved_settings_path())).is_equal(OK)
assert_bool(saved.get_value("quiz", "hide_next_track")).is_false()
assert_int(saved.get_value("quiz", "winning_score")).is_equal(19)
func test_short_songs_shows_its_seconds_and_is_saved() -> void:
Settings.short_songs = false
await _open()
assert_bool(_card.short_song_seconds_row.visible).is_false()
_card.short_songs_button.button_pressed = true
assert_bool(Settings.short_songs).is_true()
assert_bool(_card.short_song_seconds_row.visible).is_true()
var saved: ConfigFile = ConfigFile.new()
assert_int(saved.load(Settings.saved_settings_path())).is_equal(OK)
assert_bool(saved.get_value("quiz", "short_songs")).is_true()
func test_seconds_per_song_go_in_fives_between_ten_and_two_minutes() -> void:
Settings.short_song_seconds = 30
Settings.short_songs = true
await _open()
_card.increase_short_song_seconds_button.pressed.emit()
assert_int(Settings.short_song_seconds).is_equal(35)
assert_str(_card.short_song_seconds_label.text).is_equal("35")
for i: int in 10:
_card.lower_short_song_seconds_button.pressed.emit()
assert_int(Settings.short_song_seconds).is_equal(QuizSettingsCard.MIN_SHORT_SONG_SECONDS)
for i: int in 40:
_card.increase_short_song_seconds_button.pressed.emit()
assert_int(Settings.short_song_seconds).is_equal(QuizSettingsCard.MAX_SHORT_SONG_SECONDS)
+5 -5
View File
@@ -7,7 +7,7 @@ extends MainWindowTestSuite
const MAIN_WINDOW_CHILDREN: Array[String] = [
"Background", "TopBar", "Quiz", "PopupBackdrop", "SearchPopup", "SyncPopup", "QRPopup",
"ShortcutsPopup", "StatisticsView", "AboutPopup", "LogPopup", "SoundtrackStatsPopup",
"SettingsPopup",
"SettingsPopup", "UpdatePopup",
]
## The parts of the quiz and the scene each one comes from.
@@ -28,7 +28,7 @@ const LOCKED_BY_SYNC: Array[String] = [
]
const TOP_BAR_CHILDREN: Array[String] = [
"TopPanelContainer", "Title", "DebugLabel", "UpdateLabel", "OfflineLabel", "Buttons",
"TopPanelContainer", "Title", "DebugLabel", "UpdateBox", "OfflineLabel", "Buttons",
"QuizTab", "StatisticsTab",
]
## The buttons in the top bar's row on the right, in order.
@@ -121,12 +121,12 @@ func test_clicking_quiz_buttons_plays_and_shows_answer() -> void:
await _click_at(_center(_quiz.get_node("SongControls/NextButton")))
await _runner.simulate_frames(2)
assert_int(Playlist.currently_playing_song).is_equal(0)
assert_str((_quiz.get_node("NowPlaying/SoundtrackLabel") as Label).text) \
assert_str((_quiz.get_node("NowPlaying/Titles/SoundtrackLabel") as Label).text) \
.is_not_equal("Chrono Trigger")
await _click_at(_center(_quiz.get_node("SongControls/ShowAnswerButton")))
assert_str((_quiz.get_node("NowPlaying/SoundtrackLabel") as Label).text) \
assert_str((_quiz.get_node("NowPlaying/Titles/SoundtrackLabel") as Label).text) \
.is_equal("Chrono Trigger")
@@ -226,7 +226,7 @@ func test_click_on_top_bar_while_quiz_popup_is_open_only_closes_it() -> void:
func test_sync_disables_quiz_buttons_until_finished() -> void:
_server.respond_bytes("/sync", PackedByteArray())
_server.respond_json("/sync/progress", {"progress": 10, "time_spent": "00:00:01"})
_server.respond_json("/sync/progress", {"progress": 10, "time_spent": "00:01"})
var sync_popup: BasePopup = _window.get_node("SyncPopup")
_top_bar_button("SyncButton").pressed.emit()
+26 -7
View File
@@ -9,15 +9,33 @@ func before_test() -> void:
_popup = _window.get_node("SettingsPopup")
func test_less_played_toggle_changes_the_setting() -> void:
Settings.use_low_played_mode = false
func test_random_songs_choice_lists_the_three_modes() -> void:
var names: Array[String] = []
for index: int in _popup.random_mode_button.item_count:
names.append(_popup.random_mode_button.get_item_text(index))
assert_array(names).contains_exactly(["Normal", "Low played", "Classic"])
@warning_ignore("unused_parameter")
func test_random_songs_choice_changes_the_setting(mode: Settings.RandomMode,
test_parameters: Array = [[Settings.RandomMode.LOW_PLAYED],
[Settings.RandomMode.CLASSIC], [Settings.RandomMode.NORMAL]]) -> void:
_popup.open()
await _runner.simulate_frames(1)
assert_bool(_popup.low_played_button.button_pressed).is_false()
await _click_at(_popup.low_played_button.get_global_rect().get_center())
_popup.random_mode_button.select(mode)
_popup.random_mode_button.item_selected.emit(mode)
assert_bool(Settings.use_low_played_mode).is_true()
assert_int(Settings.random_mode).is_equal(mode)
func test_random_songs_choice_shows_the_mode_in_use() -> void:
Settings.random_mode = Settings.RandomMode.CLASSIC
_popup.open()
assert_int(_popup.random_mode_button.selected).is_equal(Settings.RandomMode.CLASSIC)
func test_statistics_toggle_changes_the_setting() -> void:
@@ -33,14 +51,15 @@ func test_statistics_toggle_changes_the_setting() -> void:
func test_statistics_settings_are_not_saved_with_the_quiz_settings() -> void:
Settings.add_to_stats = false
Settings.use_low_played_mode = true
Settings.random_mode = Settings.RandomMode.LOW_PLAYED
Settings.save_settings()
var saved: ConfigFile = ConfigFile.new()
assert_int(saved.load(Settings.saved_settings_path())).is_equal(OK)
assert_bool(saved.has_section_key("quiz", "add_to_stats")).is_false()
assert_bool(saved.has_section_key("quiz", "use_low_played_mode")).is_false()
assert_bool(saved.has_section_key("quiz", "random_mode")).is_false()
assert_bool(saved.has_section_key("app", "random_mode")).is_false()
func test_popup_fits_its_rows_and_is_centred() -> void:
+190
View File
@@ -0,0 +1,190 @@
extends GdUnitTestSuite
## Short songs: only a part of each quiz song plays, from a random point picked once per
## song, and the end of the part counts as the song ending.
## How long the test song is, in seconds.
const SONG_SECONDS: float = 60.0
var _snapshot: AutoloadSnapshot
var _runner: GdUnitSceneRunner
var _player: MusicPlayer
func before_test() -> void:
_snapshot = AutoloadSnapshot.take([Settings, Playlist])
Settings.short_songs = true
Settings.short_song_seconds = 20
Settings.hide_beginning = false
Settings.hide_length = false
Settings.auto_repeat_song = false
Settings.stop_after_current = true
Playlist.song_object_array.clear()
_runner = scene_runner("res://player/MusicPlayer.tscn")
_player = _runner.scene()
func after_test() -> void:
_player.audio_player.stop()
_snapshot.restore()
# A silent song of [param seconds], in the playlist
func _add_song(seconds: float = SONG_SECONDS) -> SongObject:
var song: AudioStreamWAV = AudioStreamWAV.new()
song.format = AudioStreamWAV.FORMAT_8_BITS
song.mix_rate = 8000
var silence: PackedByteArray = []
silence.resize(int(seconds * song.mix_rate))
song.data = silence
var song_object: SongObject = SongObject.new()
song_object.song = song
song_object.song_number = Playlist.song_object_array.size()
Playlist.add_to_playlist(song_object)
return song_object
func _play(song_object: SongObject) -> void:
await _player.play_song_object(song_object.song_number)
await _runner.simulate_frames(2)
func test_part_starts_between_ten_and_sixty_percent_in() -> void:
for i: int in 20:
var song_object: SongObject = _add_song()
_player.choose_part(song_object, song_object.song)
assert_float(song_object.short_start).is_between(SONG_SECONDS * 0.1, SONG_SECONDS * 0.6)
assert_float(_player.part_length()).is_equal(20.0)
func test_part_is_picked_once_per_song() -> void:
var song_object: SongObject = _add_song()
_player.choose_part(song_object, song_object.song)
var start: float = song_object.short_start
_player.choose_part(song_object, song_object.song)
assert_float(song_object.short_start).is_equal(start)
func test_part_always_fits_in_the_song() -> void:
Settings.short_song_seconds = 50
for i: int in 20:
var song_object: SongObject = _add_song()
_player.choose_part(song_object, song_object.song)
assert_float(song_object.short_start + 50).is_less_equal(SONG_SECONDS)
func test_song_shorter_than_the_part_plays_whole() -> void:
var song_object: SongObject = _add_song(15.0)
await _play(song_object)
assert_float(_player.part_length()).is_equal_approx(15.0, 0.01)
assert_float(song_object.short_start).is_equal(-1.0)
func test_whole_song_plays_with_short_songs_off() -> void:
Settings.short_songs = false
var song_object: SongObject = _add_song()
await _play(song_object)
assert_float(_player.part_length()).is_equal_approx(SONG_SECONDS, 0.01)
assert_float(_player.position_in_part()).is_less(1.0)
func test_song_starts_at_its_part_and_shows_the_part_length() -> void:
var song_object: SongObject = _add_song()
await _play(song_object)
assert_float(_player.audio_player.get_playback_position()) \
.is_greater_equal(song_object.short_start - 0.01)
assert_float(_player.position_in_part()).is_less(1.0)
assert_float(_player.progress_slider.max_value).is_equal(20.0)
assert_str(_player.music_time_label.text).ends_with("/ 00:20")
func test_seeking_stays_inside_the_part() -> void:
var song_object: SongObject = _add_song()
await _play(song_object)
_player.seek(500)
assert_float(_player.progress_slider.value).is_equal(20.0)
assert_float(_player.playback_position) \
.is_equal_approx(song_object.short_start + 20.0, 0.01)
func test_end_of_the_part_stops_the_song() -> void:
var song_object: SongObject = _add_song()
await _play(song_object)
_player.seek(19.9)
await await_millis(300)
assert_bool(_player.song_finished).is_true()
assert_bool(_player.audio_player.playing).is_false()
func test_end_of_the_part_repeats_it_with_auto_repeat() -> void:
Settings.auto_repeat_song = true
var song_object: SongObject = _add_song()
await _play(song_object)
_player.seek(19.9)
await await_millis(300)
assert_bool(_player.audio_player.playing).is_true()
assert_float(_player.position_in_part()).is_less(1.0)
assert_float(_player.audio_player.get_playback_position()) \
.is_greater_equal(song_object.short_start - 0.01)
func test_end_of_the_part_goes_to_the_next_song_without_stop_after_current() -> void:
Settings.stop_after_current = false
var song_object: SongObject = _add_song()
await _play(song_object)
var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_player))
_player.seek(19.9)
await signals.wait_until(1000).is_emitted("play_next_song")
func test_restart_plays_the_same_part_again() -> void:
var song_object: SongObject = _add_song()
await _play(song_object)
_player.seek(10)
_player.restart()
await _runner.simulate_frames(2)
assert_float(_player.position_in_part()).is_less(1.0)
assert_float(_player.audio_player.get_playback_position()) \
.is_greater_equal(song_object.short_start - 0.01)
func test_hidden_beginning_counts_inside_the_part() -> void:
Settings.hide_beginning = true
Settings.hide_for_seconds = 5.0
var song_object: SongObject = _add_song()
await _play(song_object)
assert_str(_player.music_time_label.text).is_equal("??:?? / 00:20")
_player.seek(6)
await _runner.simulate_frames(2)
assert_str(_player.music_time_label.text).starts_with("00:06")
func test_other_sounds_always_play_whole() -> void:
var song_object: SongObject = _add_song()
await _play(song_object)
_player.play_song(_add_song(30.0).song)
assert_float(_player.part_length()).is_equal_approx(30.0, 0.01)
assert_float(_player.position_in_part()).is_less(1.0)
@@ -0,0 +1 @@
uid://ov2rqiwl8x6s
+14 -7
View File
@@ -15,7 +15,7 @@ func before_test() -> void:
Playlist.reset_playlist()
Playlist.is_song_downloading = false
Playlist.number_of_tracks_to_preload = 1
Settings.use_low_played_mode = false
Settings.random_mode = Settings.RandomMode.NORMAL
_server = FakeHttpTransport.new().install()
_server.respond_json("/music/list", [])
_server.respond_bytes("/music/rand", FileAccess.get_file_as_bytes("res://tests/data/short.mp3"))
@@ -79,15 +79,22 @@ func test_fetch_song_keeps_going_until_the_cache_is_full() -> void:
assert_int(_server.count("/music/rand")).is_equal(3)
func test_low_played_mode_uses_its_endpoint() -> void:
Settings.use_low_played_mode = true
_server.respond_bytes("/music/rand/low",
FileAccess.get_file_as_bytes("res://tests/data/short.mp3"))
@warning_ignore("unused_parameter")
func test_each_random_mode_uses_its_endpoint(mode: Settings.RandomMode, path: String,
test_parameters: Array = [
[Settings.RandomMode.NORMAL, "/music/rand"],
[Settings.RandomMode.LOW_PLAYED, "/music/rand/low"],
[Settings.RandomMode.CLASSIC, "/music/rand/classic"],
]) -> void:
Settings.random_mode = mode
_server.respond_bytes(path, FileAccess.get_file_as_bytes("res://tests/data/short.mp3"))
_loader.fetch_song()
assert_int(_server.count("/music/rand/low")).is_equal(1)
assert_int(_server.count("/music/rand")).is_equal(0)
assert_int(_server.count(path)).is_equal(1)
for other: String in ["/music/rand", "/music/rand/low", "/music/rand/classic"]:
if other != path:
assert_int(_server.count(other)).override_failure_message(other).is_equal(0)
func test_fetch_song_if_idle_does_nothing_while_downloading() -> void:
+23 -4
View File
@@ -10,6 +10,8 @@ var _window: Control
func before_test() -> void:
_snapshot = AutoloadSnapshot.take([Settings])
Settings.currently_syncing = false
# The bar jumps to its values and the result shows at once
Settings.reduce_animations = true
_server = FakeHttpTransport.new().install()
_server.respond_bytes("/sync", PackedByteArray())
_runner = scene_runner("res://popups/SyncPopup.tscn")
@@ -38,7 +40,7 @@ func _empty_result() -> Dictionary:
"soundtracks_changed_content": [],
"soundtracks_removed": [],
"catched_errors": [],
"total_time": "00:00:03",
"total_time": "00:03",
}
@@ -64,12 +66,29 @@ func test_start_sync_does_nothing_while_already_syncing() -> void:
func test_progress_updates_progress_bar() -> void:
_runner.invoke("start_sync")
_server.respond_json("/sync/progress", {"progress": "42", "time_spent": "00:00:12"})
_server.respond_json("/sync/progress", {"progress": "42", "time_spent": "00:12"})
_runner.invoke("_on_sync_timeout")
assert_float(_node("SyncProgressBar").value).is_equal(42.0)
assert_str(_node("SyncTime").text).is_equal("00:00:12")
assert_str(_node("SyncTime").text).is_equal("00:12")
func test_finished_sync_fills_the_bar_before_the_result() -> void:
Settings.reduce_animations = false
_runner.invoke("start_sync")
var bar: AnimatedProgressBar = _node("SyncProgressBar")
assert_bool(bar.indeterminate).is_true()
_finish_sync(_empty_result())
assert_bool(_node("ProgressHBoxContainer").visible).is_true()
await await_millis(int(Anim.SLOW * 1000) + 100)
assert_float(bar.value).is_equal(100.0)
assert_bool(_node("ProgressHBoxContainer").visible).is_true()
await await_millis(int(Anim.SLOW * 1000) + 100)
assert_bool(_node("ProgressHBoxContainer").visible).is_false()
assert_str(_node("SyncTitleLabel").text).is_equal("Sync Result")
func test_finished_sync_shows_only_sections_with_changes() -> void:
@@ -88,7 +107,7 @@ func test_finished_sync_shows_only_sections_with_changes() -> void:
assert_bool(_node("NoChangesLabel").visible).is_false()
# Header plus one label per soundtrack
assert_int(_node("SoundtracksAddedVBox").get_child_count()).is_equal(3)
assert_str(_node("SyncTime").text).is_equal("Total time: 00:00:03")
assert_str(_node("SyncTime").text).is_equal("Total time: 00:03")
assert_bool(Settings.currently_syncing).is_false()
await assert_signal(_window).is_emitted("sync_finished")
+7 -1
View File
@@ -47,9 +47,14 @@ const STYLES: Dictionary = {
"PlayerButtonPrimary": "Button",
"PlayerTime": "Label",
"PlayerIcon": "Button",
"UpdateButton": "Button",
"SyncProgressBar": "ProgressBar",
"SyncProgressBarFill": "Panel",
"SyncProgressBarText": "Label",
"ProgressSlider": "HSlider",
"VolumeBar": "HSlider",
"StatisticsCard": "PanelContainer",
"SoundtrackCover": "Panel",
"StatisticsCardHover": "PanelContainer",
"StatisticsGrid": "GridContainer",
"TabButton": "Button",
@@ -64,7 +69,8 @@ const STYLES: Dictionary = {
## Theme types that only scripts read from, so they are not styles a node can pick.
const CODE_ONLY_TYPES: Dictionary = {
"Palette": ["backdrop"],
"Palette": ["backdrop", "first_point", "match_point", "flash"],
"SyncProgressBar": ["shine"],
"ScoreGraph": [
"player_1", "player_2", "player_3", "player_4", "player_5", "player_6", "player_7",
"grid", "axis_text", "winning_line",
+1 -1
View File
@@ -27,7 +27,7 @@ func test_picker_lists_every_theme_and_shows_the_one_in_use() -> void:
for index: int in _settings.theme_button.item_count:
names.append(_settings.theme_button.get_item_text(index))
assert_array(names).contains_exactly(["Classic", "Original web", "Nord"])
assert_array(names).contains_exactly(["Classic", "Original web", "Nord", "Christmas"])
assert_str(_settings.theme_button.get_item_text(_settings.theme_button.selected)) \
.is_equal("Classic")
+27 -9
View File
@@ -10,7 +10,7 @@ func before_test() -> void:
func test_no_message_when_this_is_the_newest_version() -> void:
assert_bool(_top_bar.update_label.visible).is_false()
assert_bool(_top_bar.update_box.visible).is_false()
assert_int(_server.count("/download")).is_equal(1)
@@ -19,9 +19,9 @@ func test_newer_release_shows_a_message() -> void:
_top_bar.check_for_update()
assert_bool(_top_bar.update_label.is_visible_in_tree()).is_true()
assert_str(_top_bar.update_label.text).is_equal("Version 99.0.0 is available")
assert_str(_top_bar.update_label.tooltip_text).contains(Settings.version)
assert_bool(_top_bar.update_button.is_visible_in_tree()).is_true()
assert_str(_top_bar.update_button.text).is_equal("Version 99.0.0 is available")
assert_str(_top_bar.update_button.tooltip_text).contains(Settings.version)
func test_message_fits_between_the_tabs_and_the_buttons() -> void:
@@ -29,8 +29,10 @@ func test_message_fits_between_the_tabs_and_the_buttons() -> void:
_top_bar.check_for_update()
await _runner.simulate_frames(1)
var label: Rect2 = _top_bar.update_label.get_global_rect()
var text_width: float = _top_bar.update_label.get_minimum_size().x
var label: Rect2 = _top_bar.update_box.get_global_rect()
var button: Button = _top_bar.update_button
var text_width: float = button.get_theme_font(&"font").get_string_size(button.text,
HORIZONTAL_ALIGNMENT_LEFT, -1, button.get_theme_font_size(&"font_size")).x
assert_float(text_width).is_less_equal(label.size.x)
assert_float(label.position.x).is_greater(
(_top_bar.get_node("%StatisticsTab") as Control).get_global_rect().end.x)
@@ -44,7 +46,7 @@ func test_message_stays_on_the_statistics_tab() -> void:
_window.show_tab(&"statistics")
assert_bool(_top_bar.update_label.is_visible_in_tree()).is_true()
assert_bool(_top_bar.update_button.is_visible_in_tree()).is_true()
func test_server_change_checks_again() -> void:
@@ -52,7 +54,7 @@ func test_server_change_checks_again() -> void:
_quiz.on_server_changed()
assert_bool(_top_bar.update_label.visible).is_true()
assert_bool(_top_bar.update_box.visible).is_true()
func test_failed_check_shows_nothing() -> void:
@@ -60,7 +62,7 @@ func test_failed_check_shows_nothing() -> void:
_top_bar.check_for_update()
assert_bool(_top_bar.update_label.visible).is_false()
assert_bool(_top_bar.update_box.visible).is_false()
func test_version_comparison() -> void:
@@ -72,3 +74,19 @@ func test_version_comparison() -> void:
assert_bool(TopBar.is_newer_version("2.0", "2.0.0")).is_false()
assert_bool(TopBar.is_newer_version("1.9.0", "2.0.0")).is_false()
assert_bool(TopBar.is_newer_version("1.9.9", "1.10.0")).is_false()
@warning_ignore("unused_parameter")
func test_message_stays_clear_of_the_buttons_in_every_theme(theme_id: String,
test_parameters: Array = [["classic"], ["original_web"], ["nord"], ["christmas"]]) -> void:
Settings.apply_theme(theme_id)
_server.respond_json("/download", "99.10.10")
_top_bar.check_for_update()
await _runner.simulate_frames(2)
var message: Rect2 = _top_bar.update_box.get_global_rect()
assert_float(message.end.x).is_less(
(_top_bar.get_node("%SyncButton") as Control).get_global_rect().position.x)
assert_float(message.position.x).is_greater(
(_top_bar.get_node("%StatisticsTab") as Control).get_global_rect().end.x)
Settings.apply_theme("classic")
+160
View File
@@ -0,0 +1,160 @@
extends MainWindowTestSuite
## Getting the newest version from "Version X is available" in the top bar: updating in
## place and restarting, or downloading in the browser.
const LINK: String = "https://gitea.example/releases/download/99.0.0/MusicPlayer_99.0.0.exe"
var _top_bar: TopBar
var _popup: UpdatePopup
var _updater: FakeUpdater
var _opened: Array[String] = []
var _dir: String
## Downloads nothing: hands over a prepared file, or fails, at once.
class FakeUpdater:
extends Updater
var downloaded: PackedByteArray = [0x4D, 0x5A, 0x90, 0x00]
var fail_with: String = ""
var urls: Array[String] = []
var started: Array[String] = []
var quits: int = 0
func _init() -> void:
super()
start_process = func(path: String, _arguments: PackedStringArray) -> int:
started.append(path)
return 1
quit = func() -> void: quits += 1
func download(url: String, on_progress: Callable, on_done: Callable,
on_failed: Callable) -> void:
urls.append(url)
if !fail_with.is_empty():
on_failed.call(fail_with)
return
on_progress.call(downloaded.size() / 2, downloaded.size())
var file: FileAccess = FileAccess.open(update_path(), FileAccess.WRITE)
file.store_buffer(downloaded)
file.close()
on_done.call(update_path())
func _before_window_starts() -> void:
_server.respond_json("/download", "99.0.0")
func before_test() -> void:
# The quiz is saved before restarting, next to the settings
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("update_%d" % randi()))
await super()
_top_bar = _window.get_node("TopBar")
_popup = _window.get_node("UpdatePopup")
_opened = []
_popup.open_url = func(url: String) -> Error:
_opened.append(url)
return OK
_dir = create_temp_dir("game_%d" % randi())
_updater = FakeUpdater.new()
_updater.in_editor = false
_updater.os_name = "Windows"
_updater.executable_path = _dir.path_join("MusicPlayer.exe")
var file: FileAccess = FileAccess.open(_updater.executable_path, FileAccess.WRITE)
file.store_string("old game")
file.close()
_popup.add_child(_updater)
_popup.updater = _updater
func _open_popup() -> void:
_top_bar.update_button.pressed.emit()
func test_clicking_the_message_opens_the_popup() -> void:
_open_popup()
assert_bool(_popup.visible).is_true()
assert_str(_popup.title_label.text).is_equal("Version 99.0.0 is available")
assert_str(_popup.info_label.text).contains(Settings.version)
assert_bool(_popup.update_now_button.visible).is_true()
func test_update_downloads_swaps_and_restarts() -> void:
_server.respond_redirect("/download/windows", LINK)
_open_popup()
_popup.update_now()
assert_array(_updater.urls).contains_exactly([LINK])
var exe: String = _updater.executable_path
assert_array(FileAccess.get_file_as_bytes(exe)).is_equal(_updater.downloaded)
assert_str(FileAccess.get_file_as_string(exe + Updater.OLD_SUFFIX)).is_equal("old game")
assert_array(_updater.started).contains_exactly([exe])
assert_int(_updater.quits).is_equal(1)
# The quiz continues after the restart
assert_bool(FileAccess.file_exists(QuizSave.path())).is_true()
func test_failed_download_says_why_and_keeps_the_game() -> void:
_server.respond_redirect("/download/windows", LINK)
_updater.fail_with = "The download failed. Check the connection and try again."
_open_popup()
_popup.update_now()
assert_str(_popup.status_label.text).is_equal(_updater.fail_with)
assert_bool(_popup.update_now_button.disabled).is_false()
assert_bool(_popup.browser_button.disabled).is_false()
assert_str(FileAccess.get_file_as_string(_updater.executable_path)).is_equal("old game")
assert_int(_updater.quits).is_equal(0)
func test_no_download_on_the_server_says_so() -> void:
_open_popup()
_popup.update_now()
assert_array(_updater.urls).is_empty()
assert_str(_popup.status_label.text).contains("no download")
assert_int(_updater.quits).is_equal(0)
func test_browser_gets_the_package_on_linux() -> void:
var package: String = LINK.replace(".exe", "_linux.tar.gz")
_server.respond_redirect("/download/linux-package", package)
_updater.os_name = "Linux"
_open_popup()
_popup.download_in_browser()
assert_array(_opened).contains_exactly([package])
func test_browser_gets_the_release_page_on_other_platforms() -> void:
_updater.os_name = "macOS"
_open_popup()
_popup.download_in_browser()
assert_array(_opened).contains_exactly([Updater.RELEASES_PAGE])
assert_int(_server.count("/download/windows")).is_equal(0)
func test_browser_gets_the_release_page_when_the_server_has_no_link() -> void:
_open_popup()
_popup.download_in_browser()
assert_array(_opened).contains_exactly([Updater.RELEASES_PAGE])
func test_from_the_editor_only_the_browser_is_offered() -> void:
_updater.in_editor = true
_open_popup()
assert_bool(_popup.update_now_button.visible).is_false()
assert_bool(_popup.reason_label.visible).is_true()
assert_str(_popup.reason_label.text).contains("editor")
assert_bool(_popup.browser_button.visible).is_true()
@@ -0,0 +1 @@
uid://d4lsj4jjqht4d
+88
View File
@@ -0,0 +1,88 @@
extends GdUnitTestSuite
## The sync popup's progress bar: busy until there is progress, gliding to new values,
## a highlight on the filled part, and plain jumps with Reduce animations.
var _runner: GdUnitSceneRunner
var _bar: AnimatedProgressBar
var _reduce_before: bool
func before_test() -> void:
_reduce_before = Settings.reduce_animations
Settings.reduce_animations = false
var holder: Control = auto_free(Control.new())
holder.size = Vector2(1000, 100)
_bar = AnimatedProgressBar.new()
_bar.theme_type_variation = &"SyncProgressBar"
_bar.size = Vector2(1000, 32)
holder.add_child(_bar)
_runner = scene_runner(holder)
await _runner.simulate_frames(1)
func after_test() -> void:
Settings.reduce_animations = _reduce_before
func _label() -> Label:
return _bar.get_child(1) as Label
func test_waits_with_the_busy_animation() -> void:
_bar.wait()
assert_bool(_bar.indeterminate).is_true()
assert_str(_label().text).is_empty()
func test_progress_ends_the_wait_and_glides() -> void:
_bar.wait()
_bar.move_to(40)
assert_bool(_bar.indeterminate).is_false()
assert_float(_bar.value).is_less(40.0)
await await_millis(int(Anim.SLOW * 1000) + 150)
assert_float(_bar.value).is_equal(40.0)
assert_str(_label().text).is_equal("40%")
func test_reduce_animations_jumps_and_never_waits() -> void:
Settings.reduce_animations = true
_bar.wait()
assert_bool(_bar.indeterminate).is_false()
_bar.move_to(75)
assert_float(_bar.value).is_equal(75.0)
assert_str(_label().text).is_equal("75%")
func test_highlight_stays_on_the_filled_part() -> void:
Settings.reduce_animations = true
_bar.move_to(50)
Settings.reduce_animations = false
await _runner.simulate_frames(3)
var mask: Control = _bar.get_child(0)
assert_bool(mask.visible).is_true()
assert_float(mask.size.x).is_equal(_bar.fill_width())
assert_float(_bar.fill_width()).is_between(490.0, 510.0)
func test_no_highlight_without_progress_or_animations() -> void:
await _runner.simulate_frames(2)
assert_bool((_bar.get_child(0) as Control).visible).is_false()
Settings.reduce_animations = true
_bar.move_to(50)
await _runner.simulate_frames(2)
assert_bool((_bar.get_child(0) as Control).visible).is_false()
func test_highlight_takes_the_themes_colour() -> void:
var shine: TextureRect = _bar.get_child(0).get_child(0)
var gradient: Gradient = (shine.texture as GradientTexture2D).gradient
assert_object(gradient.get_color(1)).is_equal(_bar.get_theme_color(&"shine"))
assert_float(gradient.get_color(0).a).is_equal(0.0)
@@ -0,0 +1 @@
uid://bgb6no4urwpc7
+8 -5
View File
@@ -118,7 +118,7 @@ func test_sync_response() -> void:
"soundtracks_added": ["Terranigma"], "soundtracks_re_added": [],
"soundtracks_changed_title": {"FF6": "Final Fantasy VI"},
"soundtracks_changed_content": ["Chrono Trigger"], "soundtracks_removed": ["Old"],
"catched_errors": ["Could not read x.mp3"], "total_time": "00:00:03"}"""))
"catched_errors": ["Could not read x.mp3"], "total_time": "00:03"}"""))
assert_array(result.soundtracks_added).contains_exactly(["Terranigma"])
assert_array(result.soundtracks_re_added).is_empty()
@@ -126,15 +126,15 @@ func test_sync_response() -> void:
assert_array(result.soundtracks_changed_content).contains_exactly(["Chrono Trigger"])
assert_array(result.soundtracks_removed).contains_exactly(["Old"])
assert_array(result.catched_errors).contains_exactly(["Could not read x.mp3"])
assert_str(result.total_time).is_equal("00:00:03")
assert_str(result.total_time).is_equal("00:03")
func test_progress_response() -> void:
var progress: ProgressResponse = ProgressResponse.from_dict(_parse("""{
"progress": "40", "time_spent": "00:00:02"}"""))
"progress": "40", "time_spent": "00:02"}"""))
assert_str(progress.progress).is_equal("40")
assert_str(progress.time_spent).is_equal("00:00:02")
assert_str(progress.time_spent).is_equal("00:02")
func test_version_data() -> void:
@@ -182,9 +182,12 @@ func test_to_dict_gives_back_the_json() -> void:
var json: Dictionary = {
"soundtrack_id": 5, "soundtrack_uuid": "st-1", "soundtrack_name": "Earthbound",
"soundtrack_played": 6, "soundtrack_last_played": "2026-10-01T20:00:00Z",
"alternative_names": ["Mother 2"], "cover_modified": 1730000000,
"songs": [{"soundtrack_id": 5, "soundtrack_uuid": "st-1", "song_uuid": "so-1",
"soundtrack_name": "Earthbound", "song_name": "Onett", "path": "/onett.mp3",
"times_played": 2, "file_name": "onett.mp3"}],
}
assert_dict(SoundtrackWithSongs.from_dict(json).to_dict()).is_equal(json)
# Compared as JSON, since the names come back as a typed Array[String]
assert_str(JSON.stringify(SoundtrackWithSongs.from_dict(json).to_dict(), "", true)) \
.is_equal(JSON.stringify(json, "", true))
+142
View File
@@ -0,0 +1,142 @@
extends GdUnitTestSuite
## Soundtrack covers cached on disk: downloaded once, again only when the server's cover
## time changed, a few at a time, and deleted with their soundtrack.
var _server: FakeHttpTransport
var _config_dir_before: String
var _png: PackedByteArray
func before_test() -> void:
_server = FakeHttpTransport.new().install()
_config_dir_before = OS.get_environment(Settings.CONFIG_DIR_ENV)
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("cover_cache_%d" % randi()))
var image: Image = Image.create(4, 6, false, Image.FORMAT_RGB8)
_png = image.save_png_to_buffer()
func after_test() -> void:
OS.set_environment(Settings.CONFIG_DIR_ENV, _config_dir_before)
_server.uninstall()
func _cover_path(uuid: String) -> String:
return "/soundtracks/%s/cover" % uuid
func _serve(uuid: String) -> void:
_server.respond_bytes(_cover_path(uuid), _png)
# Asks a new cache, like opening the statistics after a restart, and returns the covers
func _fetch(uuid: String, modified: int, cache: CoverCache = null) -> Array[Image]:
var covers: CoverCache = cache if cache != null else CoverCache.new()
var images: Array[Image] = []
covers.fetch_cover(uuid, modified, func(image: Image) -> void: images.append(image))
return images
func test_first_fetch_downloads_and_keeps_the_cover() -> void:
_serve("st-1")
var images: Array[Image] = _fetch("st-1", 100)
assert_int(images.size()).is_equal(1)
assert_int(images[0].get_width()).is_equal(4)
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_true()
func test_unchanged_cover_comes_from_disk() -> void:
_serve("st-1")
_fetch("st-1", 100)
var images: Array[Image] = _fetch("st-1", 100)
assert_int(images.size()).is_equal(1)
assert_int(_server.count(_cover_path("st-1"))).is_equal(1)
func test_changed_cover_is_downloaded_again() -> void:
_serve("st-1")
_fetch("st-1", 100)
_fetch("st-1", 200)
assert_int(_server.count(_cover_path("st-1"))).is_equal(2)
func test_soundtrack_without_a_cover_asks_nothing() -> void:
var images: Array[Image] = _fetch("st-1", 0)
assert_array(images).is_empty()
assert_int(_server.count(_cover_path("st-1"))).is_equal(0)
func test_missing_cover_on_the_server_gives_nothing() -> void:
var images: Array[Image] = _fetch("st-1", 100)
assert_array(images).is_empty()
assert_int(_server.count(_cover_path("st-1"))).is_equal(1)
func test_cover_that_cannot_be_read_is_not_kept() -> void:
_server.respond_bytes(_cover_path("st-1"), "not a picture".to_utf8_buffer())
var images: Array[Image] = _fetch("st-1", 100)
assert_array(images).is_empty()
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_false()
func test_covers_of_removed_soundtracks_are_deleted() -> void:
_serve("st-1")
_serve("st-2")
var covers: CoverCache = CoverCache.new()
_fetch("st-1", 100, covers)
_fetch("st-2", 100, covers)
covers.keep_only({"st-1": 100, "st-3": 0})
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_true()
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-2"))).is_false()
func test_cover_removed_on_the_server_is_deleted() -> void:
_serve("st-1")
var covers: CoverCache = CoverCache.new()
_fetch("st-1", 100, covers)
covers.keep_only({"st-1": 0})
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_false()
func test_only_a_few_covers_download_at_a_time() -> void:
var covers: CoverCache = CoverCache.new()
for i: int in 5:
_server.hold(_cover_path("st-%d" % i))
_serve("st-%d" % i)
_fetch("st-%d" % i, 100, covers)
var asked: int = 0
for i: int in 5:
asked += _server.count(_cover_path("st-%d" % i))
assert_int(asked).is_equal(CoverCache.MAX_DOWNLOADS)
_server.release(_cover_path("st-0"))
assert_int(_server.count(_cover_path("st-3"))).is_equal(1)
assert_int(_server.count(_cover_path("st-4"))).is_equal(0)
func test_cover_asked_for_twice_is_downloaded_once() -> void:
var covers: CoverCache = CoverCache.new()
_server.hold(_cover_path("st-1"))
_serve("st-1")
var first: Array[Image] = _fetch("st-1", 100, covers)
var second: Array[Image] = _fetch("st-1", 100, covers)
_server.release(_cover_path("st-1"))
assert_int(_server.count(_cover_path("st-1"))).is_equal(1)
assert_int(first.size()).is_equal(1)
assert_int(second.size()).is_equal(1)
+1
View File
@@ -0,0 +1 @@
uid://chubfhdhfqvyu
+50 -16
View File
@@ -59,16 +59,16 @@ func test_json_list_that_is_not_an_array_calls_on_failed() -> void:
assert_array(outcome).contains_exactly(["failed"])
func test_fetch_random_song_uses_normal_endpoint() -> void:
MusicServerClient.fetch_random_song(false, func(_body: PackedByteArray) -> void: pass)
@warning_ignore("unused_parameter")
func test_fetch_random_song_uses_the_endpoint_of_its_mode(mode: Settings.RandomMode,
path: String, test_parameters: Array = [
[Settings.RandomMode.NORMAL, "/music/rand"],
[Settings.RandomMode.LOW_PLAYED, "/music/rand/low"],
[Settings.RandomMode.CLASSIC, "/music/rand/classic"],
]) -> void:
MusicServerClient.fetch_random_song(mode, func(_body: PackedByteArray) -> void: pass)
assert_array(_server.requested_paths).contains_exactly(["/music/rand"])
func test_fetch_random_song_uses_low_played_endpoint() -> void:
MusicServerClient.fetch_random_song(true, func(_body: PackedByteArray) -> void: pass)
assert_array(_server.requested_paths).contains_exactly(["/music/rand/low"])
assert_array(_server.requested_paths).contains_exactly([path])
func test_fetch_song_passes_raw_bytes() -> void:
@@ -81,6 +81,40 @@ func test_fetch_song_passes_raw_bytes() -> void:
assert_array(received).contains_exactly([mp3])
func test_download_url_is_where_the_server_redirects() -> void:
var link: String = "https://gitea.example/releases/download/2.0.0/MusicPlayer_2.0.0.exe"
_server.respond_redirect("/download/windows", link)
var urls: Array[String] = []
MusicServerClient.fetch_client_download_url("windows",
func(url: String) -> void: urls.append(url))
assert_array(urls).contains_exactly([link])
# The token is only for the server, so the redirect is not followed with it
assert_array(_server.requested_follow_redirects).contains_exactly([false])
func test_missing_download_calls_on_failed() -> void:
var failed: Array[bool] = []
MusicServerClient.fetch_client_download_url("linux-package",
func(_url: String) -> void: pass, func() -> void: failed.append(true))
assert_array(failed).has_size(1)
@warning_ignore("unused_parameter")
func test_header_value(name: String, value: String, test_parameters: Array = [
["Location", "https://x/y"],
["location", "https://x/y"],
["X-Queue-Id", "abc"],
["Missing", ""],
]) -> void:
var headers: PackedStringArray = ["Location: https://x/y", "x-queue-id: abc "]
assert_str(MusicServerClient.header_value(headers, name)).is_equal(value)
func test_failed_request_does_not_call_back() -> void:
_server.respond_json("/music/list", [], 500)
var called: Array[bool] = [false]
@@ -103,7 +137,7 @@ func test_failed_request_calls_on_failed() -> void:
_server.respond_bytes("/music/rand", PackedByteArray(), 503)
var outcome: Array[String] = []
MusicServerClient.fetch_random_song(false,
MusicServerClient.fetch_random_song(Settings.RandomMode.NORMAL,
func(_body: PackedByteArray) -> void: outcome.append("done"),
func() -> void: outcome.append("failed"))
@@ -370,8 +404,6 @@ func test_delete_token_without_token_sends_nothing() -> void:
func test_endpoint_paths_and_methods() -> void:
var noop: Callable = func(_data: Variant = null) -> void: pass
var calls: Array[Array] = [
[func() -> void: MusicServerClient.fetch_random_song_classic(noop),
"/music/rand/classic", HTTPClient.METHOD_GET],
[func() -> void: MusicServerClient.fetch_next_song(noop),
"/music/next", HTTPClient.METHOD_GET],
[func() -> void: MusicServerClient.fetch_previous_song(noop),
@@ -404,9 +436,9 @@ func test_endpoint_paths_and_methods() -> void:
"/download", HTTPClient.METHOD_GET],
[func() -> void: MusicServerClient.fetch_client_assets(noop),
"/download/list", HTTPClient.METHOD_GET],
[func() -> void: MusicServerClient.download_client_windows(noop),
[func() -> void: MusicServerClient.fetch_client_download_url("windows", noop),
"/download/windows", HTTPClient.METHOD_GET],
[func() -> void: MusicServerClient.download_client_linux(noop),
[func() -> void: MusicServerClient.fetch_client_download_url("linux", noop),
"/download/linux", HTTPClient.METHOD_GET],
[func() -> void: MusicServerClient.cleanup_tokens(noop),
"/token/cleanup", HTTPClient.METHOD_POST],
@@ -641,7 +673,8 @@ class _HeaderTransport:
_method: int,
on_complete: Callable,
_request_headers: PackedStringArray = PackedStringArray(),
_body: String = "",
_body: PackedByteArray = PackedByteArray(),
_follow_redirects: bool = true,
) -> void:
on_complete.call(200, JSON.stringify({"token": "t"} if _url.ends_with("/token") else [])
.to_utf8_buffer(), _headers)
@@ -661,7 +694,8 @@ class _RecordingTransport:
_method: int,
on_complete: Callable,
_headers: PackedStringArray = PackedStringArray(),
_body: String = "",
_body: PackedByteArray = PackedByteArray(),
_follow_redirects: bool = true,
) -> void:
_urls.append(url)
if url.ends_with("/token"):
+3 -1
View File
@@ -7,6 +7,8 @@ const SAVED_VALUES: Dictionary = {
&"hide_beginning": true, &"hide_for_seconds": 7.0, &"hide_length": true,
&"hide_ticks": true, &"winning_score": 13, &"inspiration_list_speed": 4,
&"fullscreen": true, &"last_seen_version": "1.2.3", &"theme_id": "original_web",
&"reduce_animations": true,
&"short_songs": true, &"short_song_seconds": 45,
}
var _snapshot: AutoloadSnapshot
@@ -92,7 +94,7 @@ func test_statistics_settings_are_not_saved() -> void:
var file: ConfigFile = _saved_file()
assert_bool(file.has_section_key("quiz", "add_to_stats")).is_false()
assert_bool(file.has_section_key("quiz", "use_low_played_mode")).is_false()
assert_bool(file.has_section_key("quiz", "random_mode")).is_false()
func test_queue_ids_are_saved_and_load_back() -> void:
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more