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

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

Closes #30
Closes #51

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:21:20 +02:00

302 lines
9.7 KiB
GDScript

class_name MusicPlayer
extends Control
## The bar that plays the songs, with play, restart, progress and volume. Whether a song
## repeats is a quiz setting ([code]Settings.auto_repeat_song[/code]).
##
## 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
var play_icon: Texture = preload("res://icons/play_button_icon.svg")
var pause_icon: Texture = preload("res://icons/pause_button_icon.svg")
var is_changing: bool = false
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
@onready var sound_player: AudioStreamPlayer = $AudioSoundPlayer
@onready var sound_effect_player: AudioStreamPlayer = $AudioSoundEffectPlayer
@onready var progress_slider: HSlider = %MusicPlayerSlider
@onready var volume_slider: VolumeSlider = %VolumeSlider
@onready var music_time_label: Label = %MusicTimeLabel
func _ready() -> void:
play_button.pressed.connect(play_or_pause)
restart_button.pressed.connect(restart)
ButtonBounce.add_to(play_button)
ButtonBounce.add_to(restart_button)
progress_slider.drag_started.connect(_on_drag_started)
progress_slider.drag_ended.connect(_on_drag_ended)
audio_player.finished.connect(_on_finished)
func _process(_delta: float) -> void:
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.
func shows_playing() -> bool:
return play_button.icon == pause_icon
# Shows pause on the play button while a song plays, and play otherwise
func _show_playing(playing: bool) -> void:
var icon: Texture = pause_icon if playing else play_icon
if play_button.icon != icon:
play_button.icon = icon
ButtonBounce.pop(play_button)
# 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")
return "%02d:%02d" % [total_seconds / 60, total_seconds % 60]
func format_text(part: float, total: float) -> String:
if (Settings.hide_beginning && part < Settings.hide_for_seconds) && Settings.hide_length:
return "??:?? / ??:??"
elif Settings.hide_length:
return format_time(part) + " / ??:??"
elif Settings.hide_beginning && part < Settings.hide_for_seconds:
return "??:?? / " + format_time(total)
else:
return format_time(part) + " / " + format_time(total)
func play_or_pause() -> void:
if song_finished:
restart()
elif audio_player.stream_paused:
_show_playing(true)
audio_player.stream_paused = false
song_finished = false
audio_player.seek(playback_position)
GameLog.debug("continue")
_show_length()
else:
pause()
func pause() -> void:
audio_player.stream_paused = true
playback_position = audio_player.get_playback_position()
_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 = _clip_start
_show_playing(true)
audio_player.play(_clip_start)
func _on_finished() -> void:
_show_playing(false)
song_finished = true
if !Settings.stop_after_current:
play_next_song.emit()
if Settings.auto_repeat_song:
restart()
func _on_drag_started() -> void:
is_changing = true
func _on_drag_ended(_changed: bool) -> void:
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(_clip_start + progress_slider.value)
playback_position = _clip_start + progress_slider.value
is_changing = false
func change_volume(value: float) -> void:
volume_slider.value += value
volume_slider.change_volume(volume_slider.value)
func _on_point_triggered(point: String) -> void:
if point == "first":
var value: int = randi_range(0, 10)
if value == 0:
play_sound(preload("res://sounds/sound1.mp3"))
elif value < 5:
play_sound(preload("res://sounds/intro_long.mp3"))
else:
play_sound(preload("res://sounds/intro_short.mp3"))
elif point == "match":
play_sound(preload("res://sounds/sound0.mp3"))
func play_sound_effect(sound_effect_name: AudioStream) -> void:
sound_effect_player.stream = sound_effect_name
sound_effect_player.play()
func play_sound(sound_name: AudioStream) -> void:
sound_player.stream = sound_name
audio_player.stop()
sound_player.play()
song_finished = true
_show_playing(false)
progress_slider.value = 0
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()
song_finished = false
_show_playing(true)
stream = audio_player.stream
progress_slider.value = 0
_show_length()
## Puts song [param song_object_no] in the player without playing it, e.g. the song that
## was playing when the game was closed. Play starts it from the beginning.
func load_song_object(song_object_no: int) -> void:
var song: AudioStream = Playlist.get_song(song_object_no)
if song == null:
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
_show_length()
func play_song_object(song_object_no: int) -> void:
GameLog.debug("play_song_object")
var song: AudioStream = Playlist.get_song(song_object_no)
if song == null:
GameLog.warning("Song ", song_object_no, " has no file to play")
return
if audio_player.is_playing():
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(_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
_show_length()
Playlist.set_currently_playing_song(song_object_no)
Playlist.unset_is_playing()
if !Settings.hide_next_track:
GameLog.debug("Show answer now!!")
Playlist.song_is_answered(song_object_no)
Playlist.song_has_played(song_object_no)
Playlist.song_is_playing(song_object_no)
update_song_list.emit()
## Plays a song to test the sound: one from the server, or a song that comes with the
## game when playing from a music folder.
func get_sound_test_song() -> void:
if Settings.is_offline():
sound_test_local()
return
var test_sound: Callable = func test_sound(body: PackedByteArray) -> void:
var sound: AudioStream = AudioStreamMP3.new()
sound.data = body
play_song(sound)
MusicServerClient.fetch_sound_test_song(test_sound)
# The sound test without a server: a song that comes with the game
func sound_test_local() -> void:
play_song(preload("res://sounds/01. Opening.mp3"))