Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0862d160e5 | ||
|
|
f39865fffc | ||
|
|
c03c0e4efe | ||
|
|
06421bb63e | ||
|
|
ef6ac8f1fd | ||
|
|
a735c515bf | ||
|
|
0fc3be79e1 | ||
|
|
ae231975bd | ||
|
|
b9fa3c3d63 |
No files matched your search
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://67is3ejxq0j8
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxjx3kacfqg6j
|
||||
+29
-1
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://djxm2kkq8hol1
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://0jfq23w54ihw
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://bu3bi7qhxr7it
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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=""
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dupwb11ovvn7j
|
||||
+52
-1
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://06q8sppe2evn
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cuukc27kr8360
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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("????????")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://chubfhdhfqvyu
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
Reference in new issue
Block a user