From da7bb2c22551427147cb512878d32e7b5b32b857 Mon Sep 17 00:00:00 2001 From: technosf Date: Thu, 2 Apr 2026 16:18:42 -0700 Subject: [PATCH 1/6] Moving player access via interface --- .codex | 0 doc/DEVELOP.md | 1 + meson.build | 3 + src/Controllers/PlayerController.vala | 285 +++++---- .../PlaybackRecoveryCoordinator.vala | 15 +- .../UsageTrackingCoordinator.vala | 5 +- src/Events/AppEventBus.vala | 3 +- src/Ext/GstFader.vala | 552 ++++++++++++++++++ src/Ext/GstStreamPlayer.vala | 205 +++++++ src/Ext/PlayerInterface.vala | 30 + src/Main.vala | 10 + src/Models/Languages.vala | 5 +- src/Models/StreamMetadata.vala | 155 +++-- src/Services/DBusMediaPlayer.vala | 7 +- src/Widgets/Display.vala | 3 +- src/Widgets/HeaderBar.vala | 7 +- src/Widgets/PlayButton.vala | 13 +- src/Widgets/Window.vala | 3 +- src/meson.build | 7 + 19 files changed, 1139 insertions(+), 170 deletions(-) create mode 100644 .codex create mode 100644 src/Ext/GstFader.vala create mode 100644 src/Ext/GstStreamPlayer.vala create mode 100644 src/Ext/PlayerInterface.vala diff --git a/.codex b/.codex new file mode 100644 index 00000000..e69de29b diff --git a/doc/DEVELOP.md b/doc/DEVELOP.md index ffc5ce60..9c90416b 100644 --- a/doc/DEVELOP.md +++ b/doc/DEVELOP.md @@ -165,6 +165,7 @@ valadoc --force \ meson compile -C builddir meson compile -C builddir export-and-compile-local-schemas GSETTINGS_SCHEMA_DIR="builddir/data" ./builddir/io.github.tuner_labs.tuner +gdb -ex "set environment GSETTINGS_SCHEMA_DIR=builddir/data" -ex run -ex bt --args ./builddir/io.github.tuner_labs.tuner ## Building the Tuner Flatpak diff --git a/meson.build b/meson.build index 9e1c0680..fa278b49 100644 --- a/meson.build +++ b/meson.build @@ -115,6 +115,9 @@ dependencies = [ dependency ('json-glib-1.0') ] +cc = meson.get_compiler('c') +dependencies += cc.find_library('m', required: true) + subdir ('src') ## diff --git a/src/Controllers/PlayerController.vala b/src/Controllers/PlayerController.vala index 214c9851..554bbadf 100644 --- a/src/Controllers/PlayerController.vala +++ b/src/Controllers/PlayerController.vala @@ -7,7 +7,7 @@ * @file PlayerController.vala */ -using Gst; +using Tuner.Ext; using Tuner.Models; /** @@ -20,72 +20,31 @@ using Tuner.Models; */ public class Tuner.Controllers.PlayerController : GLib.Object { - /** - * @brief the Tuner play state - * - * Using our own play state keeps gstreamer deps out of the rest of the code - */ - public enum Is { - BUFFERING, - PAUSED, - PLAYING, - STOPPED, - STOPPED_ERROR - } // Is - - /** The error received when playing, if any */ private bool _play_error = false; public bool play_error { get { return _play_error; } } private const uint CLICK_INTERVAL_IN_SECONDS = 606; // tape counter timer - 10 mins plus 1% - private Player _player; + private PlayerInterface? _player; private Station _station; private Metadata _metadata; - private Is _player_state; - private string _player_state_name; + private PlayerInterface.State _player_state = PlayerInterface.State.STOPPED; private uint _tape_counter_id = 0; + private uint _player_poll_id = 0; + private uint _metadata_poll_id = 0; + private StreamStatus _last_status = StreamStatus.IDLE; + private PlayerInterface.State _last_play_state = PlayerInterface.State.STOPPED; + private double _volume_cache = 0.5; + private int64 _last_playing_usec = 0; + private const int64 PLAYING_STATE_DEBOUNCE_USEC = 750000; construct { - _player = new Player (null, null); - - _player.error.connect ((error) => - // There was an error playing the stream - { - Gdk.threads_add_idle (() => { - _play_error = true; - return false; - }); - }); - - _player.media_info_updated.connect ((obj) => - // Stream metadata received - { - if (_metadata.process_media_info_update (obj)) - app().events.metadata_changed_sig (_station, _metadata); - }); - - _player.volume_changed.connect ((obj) => - // Volume changed - { - app().events.volume_changed_sig(obj.volume); - app().settings.volume = obj.volume; - }); - - _player.state_changed.connect ((state) => - // Play state changed - { - // Don't forward flickering between playing and buffering - if ( !(state == PlayerState.PLAYING && state == PlayerState.BUFFERING) - && (_player_state_name != state.get_name ())) - { - _player_state_name = state.get_name (); - set_play_state (state.get_name ()); - } - }); + var app_ref = app(); + if (app_ref != null && app_ref.settings != null) + _volume_cache = app_ref.settings.volume; } // construct @@ -95,56 +54,60 @@ public class Tuner.Controllers.PlayerController : GLib.Object * Actions are set in a separate thread as attempting UI interaction * on the gstreamer signal results in a seg fault */ - private void set_play_state (string state) + private void set_play_state (PlayerInterface.State state) { + var player = _player; + if (player == null) + return; switch (state) { - case "playing": - Gdk.threads_add_idle (() => { - if (app().is_offline) + case PlayerInterface.State.PLAYING: + { + var app_ref = app(); + if (app_ref != null && app_ref.is_offline) { _play_error = false; - _player.stop (); - player_state = Is.STOPPED; - return false; + player.stop (); + player_state = PlayerInterface.State.STOPPED; + break; } _play_error = false; - player_state = Is.PLAYING; - return false; - }); + player_state = PlayerInterface.State.PLAYING; + } break; - case "buffering": - Gdk.threads_add_idle (() => { - if (app().is_offline) + case PlayerInterface.State.BUFFERING: + case PlayerInterface.State.PAUSED: + { + var app_ref = app(); + if (app_ref != null && app_ref.is_offline) { _play_error = false; - _player.stop (); - player_state = Is.STOPPED; - return false; + player.stop (); + player_state = PlayerInterface.State.STOPPED; + break; } _play_error = false; - player_state = Is.BUFFERING; - return false; - }); + player_state = PlayerInterface.State.BUFFERING; + } break; default : // STOPPED: - Gdk.threads_add_idle (() => { + { bool network_available = NetworkMonitor.get_default ().get_network_available (); - bool offline_or_lost_network = app().is_offline || !network_available; + var app_ref = app(); + bool offline_or_lost_network = (app_ref != null && app_ref.is_offline) || !network_available; if ( _play_error && !offline_or_lost_network ) { - player_state = Is.STOPPED_ERROR; + player_state = PlayerInterface.State.STOPPED_ERROR; } else { if (offline_or_lost_network) _play_error = false; - player_state = Is.STOPPED; + player_state = PlayerInterface.State.STOPPED; } - return false; - }); + } break; } } // set_reverse_symbol @@ -155,17 +118,18 @@ public class Tuner.Controllers.PlayerController : GLib.Object * * Set by player signal. Does the tape counter emit */ - public Is player_state { + public PlayerInterface.State player_state { get { return _player_state; } // get private set { _player_state = value; - if (_station != null) - app().events.state_changed_sig(_station, value); + var app_ref = app(); + if (_station != null && app_ref != null) + app_ref.events.state_changed_sig(_station, value); - if (value == Is.STOPPED || value == Is.STOPPED_ERROR) + if (value == PlayerInterface.State.STOPPED || value == PlayerInterface.State.STOPPED_ERROR) { if (_tape_counter_id > 0) { @@ -173,13 +137,14 @@ public class Tuner.Controllers.PlayerController : GLib.Object _tape_counter_id = 0; } } - else if (value == Is.PLAYING) + else if (value == PlayerInterface.State.PLAYING) { _tape_counter_id = Timeout.add_seconds_full(Priority.LOW, CLICK_INTERVAL_IN_SECONDS, () => { if (_station == null) return Source.REMOVE; - app().events.tape_counter_sig(_station); + if (app_ref != null) + app_ref.events.tape_counter_sig(_station); return Source.CONTINUE; }); } @@ -211,9 +176,20 @@ public class Tuner.Controllers.PlayerController : GLib.Object * @return The current volume of the player. */ public double volume { - get { return _player.volume; } - set { _player.volume = value; } - } + get { return _player != null ? _player.volume : _volume_cache; } + set { + _volume_cache = value; + if (_player != null) + _player.set_volume_level (value); + var app_ref = app(); + if (app_ref != null) + { + app_ref.events.volume_changed_sig (value); + if (app_ref.settings != null) + app_ref.settings.volume = value; + } + } + } // volume /** @@ -221,17 +197,25 @@ public class Tuner.Controllers.PlayerController : GLib.Object * * @param station The station to play. */ - public void play_station (Station station) + public void play_station (Station station) { - _player.stop (); + if (_player != null) + _player.stop (); + detach_player (); _station = station; - app().events.station_changed_sig (_station); - _player.uri = (_station.urlResolved != null && _station.urlResolved != "") ? _station.urlResolved : _station.url; + var app_ref = app(); + if (app_ref != null) + app_ref.events.station_changed_sig (_station); + string stream_url = (_station.urlResolved != null && _station.urlResolved != "") ? _station.urlResolved : _station.url; + if (app_ref != null && app_ref.settings != null) + _volume_cache = app_ref.settings.volume; + attach_player (new StreamPlayer (stream_url)); _play_error = false; Timeout.add (500, () => // Wait a half of a second to play the station to help flush metadata { - _player.play (); + if (_player != null) + _player.play (); return Source.REMOVE; }); } // play_station @@ -252,13 +236,15 @@ public class Tuner.Controllers.PlayerController : GLib.Object */ public void play_pause () { switch (_player_state) { - case Is.PLAYING: - case Is.BUFFERING: - _player.stop (); + case PlayerInterface.State.PLAYING: + case PlayerInterface.State.BUFFERING: + if (_player != null) + _player.stop (); break; default: _play_error = false; - _player.play (); + if (_player != null) + _player.play (); break; } } // play_pause @@ -269,8 +255,109 @@ public class Tuner.Controllers.PlayerController : GLib.Object * */ public void stop () { - _player.stop (); - } // stop + if (_player != null) + _player.stop (); + } // stop + + private void attach_player (PlayerInterface player) + { + detach_player (); + _player = player; + _player.set_volume_level (_volume_cache); + _last_status = _player.status; + _last_play_state = _player.play_state; + + _player_poll_id = Timeout.add (200, () => { + if (_player == null) + return Source.REMOVE; + update_player_state (); + return Source.CONTINUE; + }); + + _metadata_poll_id = Timeout.add (500, () => { + if (_player == null) + return Source.REMOVE; + update_metadata (); + return Source.CONTINUE; + }); + } // attach_player + + + private void detach_player () + { + if (_player_poll_id > 0) + { + Source.remove (_player_poll_id); + _player_poll_id = 0; + } + if (_metadata_poll_id > 0) + { + Source.remove (_metadata_poll_id); + _metadata_poll_id = 0; + } + _player = null; + } // detach_player + + + private void update_player_state () + { + var player = _player; + if (player == null) + return; + + if (player.status == _last_status && player.play_state == _last_play_state) + return; + + _last_status = player.status; + _last_play_state = player.play_state; + + if (player.status == StreamStatus.ERROR) + { + _play_error = true; + set_play_state (PlayerInterface.State.STOPPED); + return; + } + + if (player.play_state == PlayerInterface.State.PLAYING) + { + _last_playing_usec = GLib.get_monotonic_time (); + set_play_state (PlayerInterface.State.PLAYING); + } + else if (player.play_state == PlayerInterface.State.BUFFERING + || player.play_state == PlayerInterface.State.PAUSED) + { + var now = GLib.get_monotonic_time (); + if (player.status == StreamStatus.PLAYING + && _last_playing_usec > 0 + && (now - _last_playing_usec) < PLAYING_STATE_DEBOUNCE_USEC) + { + set_play_state (PlayerInterface.State.PLAYING); + } + else + { + set_play_state (PlayerInterface.State.BUFFERING); + } + } + else + { + _last_playing_usec = 0; + set_play_state (PlayerInterface.State.STOPPED); + } + } // update_player_state + + + private void update_metadata () + { + if (_player == null || _station == null) + return; + + if (_metadata.process_tag_table (_player.metadata)) + { + var app_ref = app(); + if (app_ref != null) + app_ref.events.metadata_changed_sig (_station, _metadata); + } + } // update_metadata /** diff --git a/src/Coordinators/PlaybackRecoveryCoordinator.vala b/src/Coordinators/PlaybackRecoveryCoordinator.vala index 5e6974d4..7ac413ac 100644 --- a/src/Coordinators/PlaybackRecoveryCoordinator.vala +++ b/src/Coordinators/PlaybackRecoveryCoordinator.vala @@ -7,6 +7,7 @@ */ using Tuner.Controllers; +using Tuner.Ext; using Tuner.Events; using Tuner.Models; @@ -70,8 +71,8 @@ namespace Tuner.Coordinators { { if (is_online) { - bool already_playing = _player.player_state == PlayerController.Is.PLAYING - || _player.player_state == PlayerController.Is.BUFFERING; + bool already_playing = _player.player_state == PlayerInterface.State.PLAYING + || _player.player_state == PlayerInterface.State.BUFFERING; if (_settings.play_restart && _was_playing_before_offline && _player.can_play() && !already_playing) _player.play_station(_player.station); _was_playing_before_offline = false; @@ -79,8 +80,8 @@ namespace Tuner.Coordinators { else { _was_playing_before_offline = _was_playing_before_offline - || _player.player_state == PlayerController.Is.PLAYING - || _player.player_state == PlayerController.Is.BUFFERING; + || _player.player_state == PlayerInterface.State.PLAYING + || _player.player_state == PlayerInterface.State.BUFFERING; } } // on_connectivity_changed @@ -91,12 +92,12 @@ namespace Tuner.Coordinators { * @param station Current station associated with the state change. * @param state Current player state. */ - private void on_player_state_changed(Station station, PlayerController.Is state) + private void on_player_state_changed(Station station, PlayerInterface.State state) { - if (state == PlayerController.Is.PLAYING || state == PlayerController.Is.BUFFERING) + if (state == PlayerInterface.State.PLAYING || state == PlayerInterface.State.BUFFERING) _was_playing_before_offline = true; - if (_app.is_online && state == PlayerController.Is.STOPPED) + if (_app.is_online && state == PlayerInterface.State.STOPPED) _was_playing_before_offline = false; } diff --git a/src/Coordinators/UsageTrackingCoordinator.vala b/src/Coordinators/UsageTrackingCoordinator.vala index 8c0929b4..205450f1 100644 --- a/src/Coordinators/UsageTrackingCoordinator.vala +++ b/src/Coordinators/UsageTrackingCoordinator.vala @@ -7,6 +7,7 @@ */ using Tuner.Controllers; +using Tuner.Ext; using Tuner.Events; using Tuner.Models; using Tuner.Services; @@ -61,9 +62,9 @@ namespace Tuner.Coordinators { * @param station Station associated with the state transition. * @param state New player state. */ - private void on_player_state_changed(Station station, PlayerController.Is state) + private void on_player_state_changed(Station station, PlayerInterface.State state) { - if (_settings.do_not_vote || state != PlayerController.Is.PLAYING) + if (_settings.do_not_vote || state != PlayerInterface.State.PLAYING) return; _provider.click(station.stationuuid); diff --git a/src/Events/AppEventBus.vala b/src/Events/AppEventBus.vala index 4ea6ad3e..93506de9 100644 --- a/src/Events/AppEventBus.vala +++ b/src/Events/AppEventBus.vala @@ -9,6 +9,7 @@ using Tuner.Models; using Tuner.Controllers; +using Tuner.Ext; namespace Tuner { @@ -30,7 +31,7 @@ namespace Tuner { public signal void station_changed_sig (Station station); /** Signal emitted when the player state changes. */ - public signal void state_changed_sig (Station station, PlayerController.Is state); + public signal void state_changed_sig (Station station, PlayerInterface.State state); /** Signal emitted when the title changes. */ public signal void metadata_changed_sig (Station station, Metadata metadata); diff --git a/src/Ext/GstFader.vala b/src/Ext/GstFader.vala new file mode 100644 index 00000000..43db8f69 --- /dev/null +++ b/src/Ext/GstFader.vala @@ -0,0 +1,552 @@ +using Tuner.Ext; + +public class Fader : GLib.Object { + public enum FadeInMode { + UNBUFFERED, + PLAYING_READY, + AUDIO_READY, + PREROLL_AUDIO_READY + } + + public enum TransitionPolicy { + IGNORE_DURING_FADE, + CANCEL_AND_START, + QUEUE_LATEST + } + + public enum FadeCurve { + LINEAR, + EXPONENTIAL, + LOGARITHMIC, + SMOOTHSTEP, + EQUAL_POWER + } + + // Crossfade timing state. + private uint timeout_id = 0; + private uint tail_timeout_id = 0; + private uint gate_timeout_id = 0; + private StreamPlayer? from_player; + private StreamPlayer? to_player; + private double target_volume = 0.5; + private uint duration_ms = 1500; + private uint interval_ms = 50; + private uint elapsed_ms = 0; + private FadeCurve curve = FadeCurve.LINEAR; + private bool preroll_enabled = false; + private bool tail_enabled = false; + private bool limiter_enabled = false; + private bool silence_gate_enabled = false; + private bool beat_sync_enabled = false; + private bool loudness_trim_enabled = false; + private uint tail_duration_ms = 1200; + private double tail_level = 0.12; + private double silence_threshold_db = -35.0; + private uint silence_gate_timeout_ms = 2000; + private double target_rms_db = -20.0; + private double max_trim_db = 12.0; + private uint beat_window_ms = 2000; + private uint beat_poll_ms = 50; + private bool apply_trim_after_fade = false; + private double pending_trim_to_db = 0.0; + private FadeInMode fade_in_mode = FadeInMode.UNBUFFERED; + private uint fade_in_timeout_ms = 3000; + private TransitionPolicy transition_policy = TransitionPolicy.CANCEL_AND_START; + private bool in_transition = false; + private TransitionRequest? queued_request; + private TransitionKind active_kind = TransitionKind.CROSSFADE; + private StreamPlayer? active_from_player; + private StreamPlayer? active_to_player; + private double active_from_volume_start = 0.0; + private double active_to_volume_start = 0.0; + + public signal void fade_completed (StreamPlayer player); + + public Fader () { + } + + public void set_curve (FadeCurve curve) { + this.curve = curve; + } + + public void set_duration_ms (uint duration_ms) { + this.duration_ms = duration_ms; + } + + public void set_fade_in_mode (FadeInMode mode) { + fade_in_mode = mode; + } + + public void set_transition_policy (TransitionPolicy policy) { + transition_policy = policy; + } + + public void set_fade_in_timeout_ms (uint timeout_ms) { + fade_in_timeout_ms = timeout_ms; + } + + public void set_preroll_enabled (bool enabled) { + preroll_enabled = enabled; + } + + public void set_tail_enabled (bool enabled) { + tail_enabled = enabled; + } + + public void set_limiter_enabled (bool enabled) { + limiter_enabled = enabled; + } + + public void set_silence_gate_enabled (bool enabled) { + silence_gate_enabled = enabled; + } + + public void set_beat_sync_enabled (bool enabled) { + beat_sync_enabled = enabled; + } + + public void set_loudness_trim_enabled (bool enabled) { + loudness_trim_enabled = enabled; + } + + public void set_loudness_trim_params (double target_rms_db, double max_trim_db) { + this.target_rms_db = target_rms_db; + this.max_trim_db = max_trim_db; + } + + public void set_tail_params (uint duration_ms, double level) { + tail_duration_ms = duration_ms; + tail_level = level; + } + + public void set_silence_gate_params (double threshold_db, uint timeout_ms) { + silence_threshold_db = threshold_db; + silence_gate_timeout_ms = timeout_ms; + } + + public void set_beat_sync_params (uint window_ms, uint poll_ms) { + beat_window_ms = window_ms; + beat_poll_ms = poll_ms; + } + + public void crossfade (StreamPlayer? from_player, StreamPlayer to_player, double target_volume, uint? duration_ms = null, uint interval_ms = 50) { + // Fade out the current player while fading in the new one. + if (!prepare_transition (TransitionRequest.crossfade (from_player, to_player, target_volume, duration_ms, interval_ms))) { + return; + } + + this.from_player = from_player; + this.to_player = to_player; + this.target_volume = target_volume; + if (duration_ms != null) { + this.duration_ms = duration_ms; + } + this.interval_ms = interval_ms; + this.elapsed_ms = 0; + + active_kind = TransitionKind.CROSSFADE; + active_from_player = from_player; + active_to_player = to_player; + active_from_volume_start = from_player != null ? from_player.volume : 0.0; + active_to_volume_start = 0.0; + start_crossfade (); + } + + public void fade_in (StreamPlayer to_player, double target_volume, uint? duration_ms = null, uint interval_ms = 50) { + if (!prepare_transition (TransitionRequest.fade_in (to_player, target_volume, duration_ms, interval_ms))) { + return; + } + + this.from_player = null; + this.to_player = to_player; + this.target_volume = target_volume; + if (duration_ms != null) { + this.duration_ms = duration_ms; + } + this.interval_ms = interval_ms; + this.elapsed_ms = 0; + + active_kind = TransitionKind.FADE_IN; + active_from_player = null; + active_to_player = to_player; + active_from_volume_start = 0.0; + active_to_volume_start = 0.0; + + to_player.set_volume_level (0.0); + if (fade_in_mode == FadeInMode.PREROLL_AUDIO_READY) { + to_player.prepare (); + } + start_fade_in_gate (); + } + + public void fade_out (StreamPlayer from_player, double start_volume, uint? duration_ms = null, uint interval_ms = 50) { + if (!prepare_transition (TransitionRequest.fade_out (from_player, start_volume, duration_ms, interval_ms))) { + return; + } + + this.from_player = from_player; + this.to_player = null; + this.target_volume = start_volume; + if (duration_ms != null) { + this.duration_ms = duration_ms; + } + this.interval_ms = interval_ms; + this.elapsed_ms = 0; + + active_kind = TransitionKind.FADE_OUT; + active_from_player = from_player; + active_to_player = null; + active_from_volume_start = start_volume; + active_to_volume_start = 0.0; + start_fade_out (); + } + + private double apply_curve (double t) { + if (t <= 0.0) { + return 0.0; + } + if (t >= 1.0) { + return 1.0; + } + switch (curve) { + case FadeCurve.EXPONENTIAL: + return t * t; + case FadeCurve.LOGARITHMIC: + return Math.sqrt (t); + case FadeCurve.SMOOTHSTEP: + return t * t * (3.0 - 2.0 * t); + case FadeCurve.EQUAL_POWER: + return Math.sin (t * Math.PI / 2.0); + case FadeCurve.LINEAR: + default: + return t; + } + } + + private void start_crossfade () { + in_transition = true; + to_player.set_volume_level (0.0); + if (preroll_enabled) { + to_player.prepare (); + } + + if (silence_gate_enabled && from_player != null) { + gate_timeout_id = GLib.Timeout.add (interval_ms, () => { + elapsed_ms += interval_ms; + if (from_player.last_rms_db <= silence_threshold_db || elapsed_ms >= silence_gate_timeout_ms) { + begin_fade (); + gate_timeout_id = 0; + return false; + } + return true; + }); + } else if (beat_sync_enabled && from_player != null) { + gate_timeout_id = GLib.Timeout.add (beat_poll_ms, () => { + elapsed_ms += beat_poll_ms; + if (is_on_beat (from_player.last_rms_db) || elapsed_ms >= beat_window_ms) { + begin_fade (); + gate_timeout_id = 0; + return false; + } + return true; + }); + } else { + begin_fade (); + } + } + + private void begin_fade () { + elapsed_ms = 0; + in_transition = true; + to_player.play (); + if (loudness_trim_enabled) { + if (from_player == null) { + // Avoid a perceived jump on fade-in by applying trim after fade completes. + apply_trim_after_fade = true; + pending_trim_to_db = compute_trim_db (to_player.last_rms_db); + to_player.set_trim_db_level (0.0); + } else { + to_player.set_trim_db_level (compute_trim_db (to_player.last_rms_db)); + from_player.set_trim_db_level (compute_trim_db (from_player.last_rms_db)); + } + } else { + to_player.set_trim_db_level (0.0); + if (from_player != null) { + from_player.set_trim_db_level (0.0); + } + } + + if (this.duration_ms == 0) { + to_player.set_volume_level (target_volume); + finalize_fade (); + return; + } + + timeout_id = GLib.Timeout.add (interval_ms, () => { + // Ramp based on elapsed time and selected curve. + elapsed_ms += interval_ms; + double progress = (double) elapsed_ms / (double) duration_ms; + if (progress > 1.0) { + progress = 1.0; + } + + double curved = apply_curve (progress); + + double to_volume = target_volume * curved; + double from_volume = active_from_volume_start * (1.0 - curved); + if (from_player == null) { + from_volume = 0.0; + } + + if (limiter_enabled && from_player != null) { + double sum = to_volume + from_volume; + if (sum > 1.0) { + double scale = 1.0 / sum; + to_volume *= scale; + from_volume *= scale; + } + } + + to_player.set_volume_level (to_volume); + if (from_player != null) { + from_player.set_volume_level (from_volume); + } + + if (progress >= 1.0) { + finalize_fade (); + timeout_id = 0; + return false; + } + return true; + }); + } + + private void start_fade_in_gate () { + elapsed_ms = 0; + in_transition = true; + + if (fade_in_mode == FadeInMode.UNBUFFERED) { + begin_fade (); + return; + } + + to_player.play (); + + gate_timeout_id = GLib.Timeout.add (interval_ms, () => { + elapsed_ms += interval_ms; + + bool ready = false; + switch (fade_in_mode) { + case FadeInMode.PLAYING_READY: + ready = (to_player.play_state == PlayerInterface.State.PLAYING); + break; + case FadeInMode.AUDIO_READY: + case FadeInMode.PREROLL_AUDIO_READY: + ready = (to_player.last_rms_db > silence_threshold_db); + break; + case FadeInMode.UNBUFFERED: + default: + ready = true; + break; + } + + if (ready || elapsed_ms >= fade_in_timeout_ms) { + gate_timeout_id = 0; + begin_fade (); + return false; + } + return true; + }); + } + + private void start_fade_out () { + in_transition = true; + if (this.duration_ms == 0) { + from_player.set_volume_level (0.0); + from_player.stop (); + fade_completed (from_player); + in_transition = false; + consume_queued_request (); + return; + } + timeout_id = GLib.Timeout.add (interval_ms, () => { + elapsed_ms += interval_ms; + double progress = (double) elapsed_ms / (double) duration_ms; + if (progress > 1.0) { + progress = 1.0; + } + double curved = apply_curve (progress); + double from_volume = target_volume * (1.0 - curved); + from_player.set_volume_level (from_volume); + + if (progress >= 1.0) { + from_player.stop (); + fade_completed (from_player); + in_transition = false; + consume_queued_request (); + timeout_id = 0; + return false; + } + return true; + }); + } + + private void finalize_fade () { + if (from_player == null) { + if (to_player != null) { + if (apply_trim_after_fade) { + to_player.set_trim_db_level (pending_trim_to_db); + apply_trim_after_fade = false; + } + fade_completed (to_player); + in_transition = false; + consume_queued_request (); + } + return; + } + if (tail_enabled) { + from_player.set_volume_level (target_volume * tail_level); + tail_timeout_id = GLib.Timeout.add (tail_duration_ms, () => { + from_player.stop (); + fade_completed (from_player); + in_transition = false; + consume_queued_request (); + tail_timeout_id = 0; + return false; + }); + } else { + from_player.stop (); + fade_completed (from_player); + in_transition = false; + consume_queued_request (); + } + } + + private bool is_on_beat (double rms_db) { + // Simple energy gate: treat strong RMS spikes as beat candidates. + return rms_db > -18.0; + } + + private double compute_trim_db (double rms_db) { + if (rms_db <= -90.0) { + return 0.0; + } + double delta = target_rms_db - rms_db; + if (delta > max_trim_db) { + return max_trim_db; + } + if (delta < -max_trim_db) { + return -max_trim_db; + } + return delta; + } + + public void cancel () { + // Stop any active fade. + if (timeout_id != 0) { + GLib.Source.remove (timeout_id); + timeout_id = 0; + } + if (tail_timeout_id != 0) { + GLib.Source.remove (tail_timeout_id); + tail_timeout_id = 0; + } + if (gate_timeout_id != 0) { + GLib.Source.remove (gate_timeout_id); + gate_timeout_id = 0; + } + in_transition = false; + queued_request = null; + } + + public void cancel_and_stop (StreamPlayer? keep_player) { + // Cancel any transition and stop any non-kept active players. + cancel (); + if (active_from_player != null && active_from_player != keep_player) { + active_from_player.stop (); + } + if (active_to_player != null && active_to_player != keep_player) { + active_to_player.stop (); + } + } + + private bool prepare_transition (TransitionRequest request) { + if (!in_transition) { + cancel (); + return true; + } + switch (transition_policy) { + case TransitionPolicy.IGNORE_DURING_FADE: + return false; + case TransitionPolicy.CANCEL_AND_START: + cancel (); + return true; + case TransitionPolicy.QUEUE_LATEST: + default: + queued_request = request; + return false; + } + } + + private void consume_queued_request () { + if (queued_request == null) { + return; + } + var req = queued_request; + queued_request = null; + + switch (req.kind) { + case TransitionKind.CROSSFADE: + crossfade (req.from_player, req.to_player, req.target_volume, req.has_duration ? req.duration_ms : (uint?) null, req.interval_ms); + break; + case TransitionKind.FADE_IN: + fade_in (req.to_player, req.target_volume, req.has_duration ? req.duration_ms : (uint?) null, req.interval_ms); + break; + case TransitionKind.FADE_OUT: + fade_out (req.from_player, req.target_volume, req.has_duration ? req.duration_ms : (uint?) null, req.interval_ms); + break; + default: + break; + } + } +} + +private enum TransitionKind { + CROSSFADE, + FADE_IN, + FADE_OUT +} + +private class TransitionRequest : GLib.Object { + public TransitionKind kind { get; construct; } + public StreamPlayer? from_player { get; construct; } + public StreamPlayer? to_player { get; construct; } + public double target_volume { get; construct; } + public uint duration_ms { get; construct; } + public bool has_duration { get; construct; } + public uint interval_ms { get; construct; } + + private TransitionRequest (TransitionKind kind, StreamPlayer? from_player, StreamPlayer? to_player, double target_volume, uint? duration_ms, uint interval_ms) { + Object ( + kind: kind, + from_player: from_player, + to_player: to_player, + target_volume: target_volume, + duration_ms: duration_ms != null ? duration_ms : 0, + has_duration: duration_ms != null, + interval_ms: interval_ms + ); + } + + public static TransitionRequest crossfade (StreamPlayer? from_player, StreamPlayer to_player, double target_volume, uint? duration_ms, uint interval_ms) { + return new TransitionRequest (TransitionKind.CROSSFADE, from_player, to_player, target_volume, duration_ms, interval_ms); + } + + public static TransitionRequest fade_in (StreamPlayer to_player, double target_volume, uint? duration_ms, uint interval_ms) { + return new TransitionRequest (TransitionKind.FADE_IN, null, to_player, target_volume, duration_ms, interval_ms); + } + + public static TransitionRequest fade_out (StreamPlayer from_player, double target_volume, uint? duration_ms, uint interval_ms) { + return new TransitionRequest (TransitionKind.FADE_OUT, from_player, null, target_volume, duration_ms, interval_ms); + } +} diff --git a/src/Ext/GstStreamPlayer.vala b/src/Ext/GstStreamPlayer.vala new file mode 100644 index 00000000..62489e00 --- /dev/null +++ b/src/Ext/GstStreamPlayer.vala @@ -0,0 +1,205 @@ +using Gst; + + +namespace Tuner.Ext { + + public enum StreamStatus { + IDLE, + PLAYING, + STOPPED, + EOS, + ERROR + } + + public class StreamPlayer : GLib.Object, PlayerInterface { + // Stream URL configured at construction time. + public string stream_url { get; construct; } + // Current lifecycle status. + private StreamStatus _status = StreamStatus.IDLE; + public StreamStatus status { get { return _status; } } + // App-level state derived from the GStreamer playbin state. + private PlayerInterface.State _play_state = PlayerInterface.State.STOPPED; + public PlayerInterface.State play_state { get { return _play_state; } } + // Latest string metadata from the stream. + private GLib.HashTable _metadata; + public GLib.HashTable metadata { get { return _metadata; } } + // Current output volume (0.0 - 1.0). + private double _volume = 0.5; + public double volume { get { return _volume; } } + // Last observed RMS level in dB from the level element (more negative is quieter). + public double last_rms_db { get; private set; default = -100.0; } + // Optional per-stream trim in dB (positive/negative). + public double trim_db { get; private set; default = 0.0; } + + private dynamic Element playbin; + private dynamic Element level; + + public StreamPlayer (string stream_url) { + // Create a per-stream playbin and attach a bus watcher. + GLib.Object (stream_url: stream_url); + _metadata = new GLib.HashTable (GLib.str_hash, GLib.str_equal); + playbin = ElementFactory.make ("playbin", "play"); + playbin.uri = stream_url; + set_volume_level (0.5); + setup_level_monitor (); + + Gst.Bus bus = playbin.get_bus (); + bus.add_watch (0, bus_callback); + } + + private bool bus_callback (Gst.Bus bus, Gst.Message message) { + // Update state and metadata based on bus messages. + switch (message.type) { + case MessageType.ERROR: + GLib.Error err; + string debug; + message.parse_error (out err, out debug); + _status = StreamStatus.ERROR; + stdout.printf ("Error: %s\n", err.message); + break; + case MessageType.EOS: + _status = StreamStatus.EOS; + stdout.printf ("end of stream\n"); + break; + case MessageType.STATE_CHANGED: + Gst.State oldstate; + Gst.State newstate; + Gst.State pending; + message.parse_state_changed (out oldstate, out newstate, out pending); + update_play_state (newstate); + _status = (newstate == Gst.State.PLAYING) ? StreamStatus.PLAYING : _status; + stdout.printf ("state changed: %s->%s:%s\n", + oldstate.to_string (), newstate.to_string (), + pending.to_string ()); + break; + case MessageType.TAG: + // Tags can include non-string values; only collect strings to avoid warnings. + stdout.printf ("taglist found\n"); + Gst.TagList? tag_list = null; + message.parse_tag (out tag_list); + if (tag_list != null) { + var count = tag_list.n_tags (); + for (uint i = 0; i < count; i++) { + var tag = tag_list.nth_tag_name (i); + unowned GLib.Value? value = tag_list.get_value_index (tag, 0); + if (value != null && value.holds (typeof (string))) { + var tag_string = value.get_string (); + _metadata.insert (tag, tag_string); + stdout.printf ("tag: %s = %s\n", tag, tag_string); + } + } + } + break; + case MessageType.ELEMENT: + unowned Gst.Structure? structure = message.get_structure (); + if (structure != null && structure.has_name ("level")) { + unowned GLib.Value? list_value = structure.get_value ("rms"); + if (list_value != null) { + if (list_value.holds (typeof (Gst.ValueList))) { + uint size = Gst.ValueList.get_size (list_value); + if (size > 0) { + unowned GLib.Value? value = Gst.ValueList.get_value (list_value, 0); + if (value != null) { + if (value.holds (typeof (double))) { + last_rms_db = value.get_double (); + } else if (value.holds (typeof (float))) { + last_rms_db = (double) value.get_float (); + } + } + } + } else if (list_value.holds (typeof (double))) { + last_rms_db = list_value.get_double (); + } else if (list_value.holds (typeof (float))) { + last_rms_db = (double) list_value.get_float (); + } + } + } + break; + default: + break; + } + + return true; + } + + public void play () { + // Transition playbin to PLAYING state. + playbin.set_state (Gst.State.PLAYING); + update_play_state (Gst.State.PLAYING); + _status = StreamStatus.PLAYING; + } + + public void stop () { + // Reset playbin to NULL state and mark as stopped. + playbin.set_state (Gst.State.NULL); + update_play_state (Gst.State.NULL); + _status = StreamStatus.STOPPED; + } + + public void prepare () { + // Pre-roll the pipeline without output. + playbin.set_state (Gst.State.PAUSED); + update_play_state (Gst.State.PAUSED); + } + + public void set_volume_level (double volume) { + // Clamp and apply volume to playbin. + if (volume < 0.0) { + volume = 0.0; + } else if (volume > 1.0) { + volume = 1.0; + } + _volume = volume; + playbin.volume = apply_trim (volume); + } + + public void set_trim_db_level (double trim_db) { + // Apply a dB trim to balance perceived loudness across stations. + this.trim_db = trim_db; + playbin.volume = apply_trim (this.volume); + } + + private double apply_trim (double volume) { + if (trim_db == 0.0) { + return volume; + } + double multiplier = Math.pow (10.0, trim_db / 20.0); + double adjusted = volume * multiplier; + if (adjusted < 0.0) { + return 0.0; + } + if (adjusted > 1.0) { + return 1.0; + } + return adjusted; + } + + private void setup_level_monitor () { + // Inject a level element so we can read RMS values for silence detection. + level = ElementFactory.make ("level", "level"); + if (level != null) { + level.set_property ("interval", (uint64) 100000000); // 100ms in ns + level.set_property ("post-messages", true); + playbin.set_property ("audio-filter", level); + } + } + + private void update_play_state (Gst.State state) { + switch (state) { + case Gst.State.PLAYING: + _play_state = PlayerInterface.State.PLAYING; + break; + case Gst.State.PAUSED: + _play_state = PlayerInterface.State.PAUSED; + break; + case Gst.State.READY: + _play_state = PlayerInterface.State.BUFFERING; + break; + default: + _play_state = PlayerInterface.State.STOPPED; + break; + } + } + } + +} diff --git a/src/Ext/PlayerInterface.vala b/src/Ext/PlayerInterface.vala new file mode 100644 index 00000000..6133197d --- /dev/null +++ b/src/Ext/PlayerInterface.vala @@ -0,0 +1,30 @@ +/** + * SPDX-FileCopyrightText: Copyright © 2026 technosf + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @file PlayerInterface.vala + */ + +namespace Tuner.Ext { + + public interface PlayerInterface : GLib.Object { + public enum State { + BUFFERING, + PAUSED, + PLAYING, + STOPPED, + STOPPED_ERROR + } + + public abstract StreamStatus status { get; } + public abstract State play_state { get; } + public abstract GLib.HashTable metadata { get; } + public abstract double volume { get; } + + public abstract void play (); + public abstract void stop (); + public abstract void set_volume_level (double volume); + } + +} diff --git a/src/Main.vala b/src/Main.vala index 9e43d2a5..7da7f248 100644 --- a/src/Main.vala +++ b/src/Main.vala @@ -41,5 +41,15 @@ public static int main (string[] args) Intl.setlocale (LocaleCategory.ALL, ""); Gst.init (ref args); var app = Tuner.Application.instance; + try { + app.register (null); + } catch (Error e) { + GLib.critical ("Failed to register application: %s", e.message); + return 1; + } + if (app.is_remote) { + GLib.critical ("Tuner is already running."); + return 1; + } return app.run (args); } diff --git a/src/Models/Languages.vala b/src/Models/Languages.vala index a38a6e29..6dcf30db 100644 --- a/src/Models/Languages.vala +++ b/src/Models/Languages.vala @@ -341,7 +341,10 @@ namespace Tuner.Models { _cached_translated_map = new TreeMap (); foreach (string id in Application.LOCALES_FOUND) { - _cached_translated_map[id] = dpgettext2(null, "Languages", map.get(id)); + string? name = map.get (id); + if (name == null || name == "") + name = id; + _cached_translated_map[id] = dpgettext2 (null, "Languages", name); } // foreach } // rebuild_language_cache diff --git a/src/Models/StreamMetadata.vala b/src/Models/StreamMetadata.vala index 1cbb2136..ba4a2fff 100644 --- a/src/Models/StreamMetadata.vala +++ b/src/Models/StreamMetadata.vala @@ -89,15 +89,7 @@ public class Tuner.Models.Metadata : GLib.Object { var streamlist = media_info.get_stream_list ().copy (); - title = ""; - artist = ""; - image = ""; - genre = ""; - homepage = ""; - audio_info = ""; - org_loc = ""; - track = ""; - pretty_print = ""; + reset_fields (); foreach (var stream in streamlist) // Hopefully just one metadata stream { @@ -151,48 +143,119 @@ public class Tuner.Models.Metadata : GLib.Object } }); // tags.foreach - _title = extract ("title"); - _artist = extract ("artist"); - _image = extract ("image"); - _genre = extract ("genre"); - _homepage = extract ("homepage"); - - _audio_info = extract ("audio_codec "); - _audio_info += extract ("bitrate "); - _audio_info += extract ("channel_mode"); - if (_audio_info != null && _audio_info.length > 0) - _audio_info = safestrip(_audio_info); - - _org_loc = extract("organization "); - _org_loc += extract ("location"); - if (_org_loc != null && _org_loc.length > 0) - org_loc = safestrip(_org_loc); - - _track = extract("track-number"); - _track += extract("track-count"); - _track += extract("container-specific-track-id"); - _track +=extract ("extended-comment"); - if (_track != null && _track.length > 0) - track = safestrip(_track); - - StringBuilder sb = new StringBuilder (); - foreach ( var tag in METADATA_TAGS ) - // Pretty print - { - if (_metadata_values.has_key(tag)) - { - sb.append ( _(METADATA_TITLES[METADATA_TAGS.index_of (tag),1])) - .append(" : ") - .append( _metadata_values.get (tag)) - .append("\n"); - } - } - pretty_print = sb.truncate (sb.len-1).str; + update_from_metadata_values (); } // foreach return true; } // process_media_info_update + /** + * Extracts the metadata from a tag table. + * + * @param tags The tag table from the stream. + * @return true if the metadata has changed + */ + internal bool process_tag_table (GLib.HashTable tags) + { + reset_fields (); + + var keys = new Gee.ArrayList (); + tags.foreach ((key, value) => { + keys.add (key); + }); + keys.sort ((a, b) => { return strcmp (a, b); }); + + StringBuilder sb = new StringBuilder (); + foreach (var key in keys) + { + string? value = tags.lookup (key); + if (value == null) + continue; + sb.append (key).append ("=").append (value).append (";"); + } + + if (all_tags == sb.str) + return false; + all_tags = sb.str; + + foreach (var key in keys) + { + string? value = tags.lookup (key); + if (value == null) + continue; + + var index = METADATA_TAGS.index_of (key); + if (index == -1) + { + warning(@"New meta tag: $key"); + continue; + } + + _metadata_values.set (key, value); + } + + update_from_metadata_values (); + return true; + } + + private void reset_fields () + { + title = ""; + artist = ""; + image = ""; + genre = ""; + homepage = ""; + audio_info = ""; + org_loc = ""; + track = ""; + pretty_print = ""; + } + + private void update_from_metadata_values () + { + _title = extract ("title"); + _artist = extract ("artist"); + _image = extract ("image"); + _genre = extract ("genre"); + _homepage = extract ("homepage"); + + _audio_info = extract ("audio_codec "); + _audio_info += extract ("bitrate "); + _audio_info += extract ("channel_mode"); + if (_audio_info != null && _audio_info.length > 0) + _audio_info = safestrip(_audio_info); + + _org_loc = extract("organization "); + _org_loc += extract ("location"); + if (_org_loc != null && _org_loc.length > 0) + org_loc = safestrip(_org_loc); + + _track = extract("track-number"); + _track += extract("track-count"); + _track += extract("container-specific-track-id"); + _track += extract ("extended-comment"); + if (_track != null && _track.length > 0) + track = safestrip(_track); + + StringBuilder sb = new StringBuilder (); + foreach ( var tag in METADATA_TAGS ) + // Pretty print + { + if (_metadata_values.has_key(tag)) + { + sb.append ( _(METADATA_TITLES[METADATA_TAGS.index_of (tag),1])) + .append(" : ") + .append( _metadata_values.get (tag)) + .append("\n"); + } + } + + if (sb.len > 0) + pretty_print = sb.truncate (sb.len-1).str; + else + pretty_print = ""; + } + /** */ private string extract( string key) diff --git a/src/Services/DBusMediaPlayer.vala b/src/Services/DBusMediaPlayer.vala index fd37d4f3..8f51c6b5 100644 --- a/src/Services/DBusMediaPlayer.vala +++ b/src/Services/DBusMediaPlayer.vala @@ -8,6 +8,7 @@ */ using Tuner.Controllers; +using Tuner.Ext; using Tuner.Models; /** @@ -191,11 +192,11 @@ namespace Tuner.Services.DBus { switch (state) { - case PlayerController.Is.PLAYING: - case PlayerController.Is.BUFFERING: + case PlayerInterface.State.PLAYING: + case PlayerInterface.State.BUFFERING: playback_status = "Playing"; break; - case PlayerController.Is.PAUSED: + case PlayerInterface.State.PAUSED: playback_status = "Paused"; break; default: diff --git a/src/Widgets/Display.vala b/src/Widgets/Display.vala index 881a1463..3112ad7d 100644 --- a/src/Widgets/Display.vala +++ b/src/Widgets/Display.vala @@ -21,6 +21,7 @@ using Gee; using Tuner.Controllers; +using Tuner.Ext; using Tuner.Models; using Tuner.Services; using Tuner.Widgets.Base; @@ -190,7 +191,7 @@ public class Tuner.Widgets.Display : Gtk.Paned, StationListHookup { _app.events.state_changed_sig.connect((station, state) => { - if (_shuffle && state == PlayerController.Is.STOPPED_ERROR) + if (_shuffle && state == PlayerInterface.State.STOPPED_ERROR) { Timeout.add(HeaderBar.SHUFFLE_ERROR_RETRY_DELAY_MS, () => { diff --git a/src/Widgets/HeaderBar.vala b/src/Widgets/HeaderBar.vala index 2baf7955..44b28150 100644 --- a/src/Widgets/HeaderBar.vala +++ b/src/Widgets/HeaderBar.vala @@ -12,6 +12,7 @@ using Gtk; using Tuner.Controllers; +using Tuner.Ext; using Tuner.Models; using Gee; using Tuner.Services; @@ -325,7 +326,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar */ public bool update_playing_station(Station station) { - if ( _app.is_offline || ( _station != null && _station == station && _player.player_state != Tuner.Controllers.PlayerController.Is.STOPPED_ERROR ) ) + if ( _app.is_offline || ( _station != null && _station == station && _player.player_state != PlayerInterface.State.STOPPED_ERROR ) ) return false; if (_station_update_lock.trylock()) @@ -413,8 +414,8 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar */ private void update_controls_state() { - bool is_playing_now = _player.player_state == PlayerController.Is.PLAYING - || _player.player_state == PlayerController.Is.BUFFERING; + bool is_playing_now = _player.player_state == PlayerInterface.State.PLAYING + || _player.player_state == PlayerInterface.State.BUFFERING; if (_app.is_offline) { diff --git a/src/Widgets/PlayButton.vala b/src/Widgets/PlayButton.vala index 6403fbaa..f5801a70 100644 --- a/src/Widgets/PlayButton.vala +++ b/src/Widgets/PlayButton.vala @@ -12,6 +12,7 @@ using Gtk; using Tuner.Controllers; +using Tuner.Ext; /** * @class PlayButton @@ -73,28 +74,28 @@ public class Tuner.Widgets.PlayButton : Gtk.Button /** * @brief Set the play button symbol and sensitivity * - * This method is instigated from a Gst.Player state change signal. + * This method is instigated from a player state change signal. * Performing any UI actions directly while handling the signal * causes a segmentation fault. To get around this, threads_add_idle * is used. * - * @param state The new play state string. + * @param state The new play state enum. */ - private void set_inverse_symbol (PlayerController.Is state) + private void set_inverse_symbol (PlayerInterface.State state) { switch (state) { - case PlayerController.Is.PLAYING: + case PlayerInterface.State.PLAYING: image = STOP; image.opacity = 1.0; break; - case PlayerController.Is.BUFFERING: + case PlayerInterface.State.BUFFERING: image = BUFFERING; image.opacity = 0.5; break; - case PlayerController.Is.STOPPED_ERROR: + case PlayerInterface.State.STOPPED_ERROR: image = ERROR; image.opacity = 0.5; break; diff --git a/src/Widgets/Window.vala b/src/Widgets/Window.vala index a4bc1815..cfa66e83 100644 --- a/src/Widgets/Window.vala +++ b/src/Widgets/Window.vala @@ -27,6 +27,7 @@ using Gee; using Tuner.Controllers; +using Tuner.Ext; using Tuner.Models; /** @@ -491,7 +492,7 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow get_size (out _width, out _height); // Echo ending dimensions so Settings can pick them up _settings.save (); - if (player_ctrl.player_state == PlayerController.Is.PLAYING) { + if (player_ctrl.player_state == PlayerInterface.State.PLAYING) { hide_on_delete(); var notification = new GLib.Notification(NOTIFICATION_PLAYING_BACKGROUND); notification.set_body(NOTIFICATION_CLICK_RESUME); diff --git a/src/meson.build b/src/meson.build index 0c1baac2..fe31b02b 100644 --- a/src/meson.build +++ b/src/meson.build @@ -7,6 +7,11 @@ # Listing files to compile sources = files ( 'Events/AppEventBus.vala', + + 'Ext/GstFader.vala', + 'Ext/PlayerInterface.vala', + 'Ext/GstStreamPlayer.vala', + 'Coordinators/PlaybackRecoveryCoordinator.vala', 'Coordinators/StartupCoordinator.vala', 'Coordinators/UsageTrackingCoordinator.vala', @@ -15,6 +20,8 @@ sources = files ( 'Controllers/PlayerController.vala', 'Controllers/SearchController.vala', + + # 'Models/CrossfadePlayer.vala', 'Models/Countries.vala', 'Models/Languages.vala', 'Models/Genre.vala', From 3ffb0827886491187942e52609032ca0b1d96b34 Mon Sep 17 00:00:00 2001 From: technosf Date: Thu, 2 Apr 2026 17:25:13 -0700 Subject: [PATCH 2/6] Debounce --- src/Controllers/PlayerController.vala | 132 ++++++++++++++------------ src/Ext/GstFader.vala | 30 +++--- src/Ext/GstStreamPlayer.vala | 81 +++++++++------- src/Ext/PlayerInterface.vala | 17 ++-- src/Widgets/PlayButton.vala | 44 ++++----- 5 files changed, 164 insertions(+), 140 deletions(-) diff --git a/src/Controllers/PlayerController.vala b/src/Controllers/PlayerController.vala index 554bbadf..9484dfab 100644 --- a/src/Controllers/PlayerController.vala +++ b/src/Controllers/PlayerController.vala @@ -31,13 +31,10 @@ public class Tuner.Controllers.PlayerController : GLib.Object private Metadata _metadata; private PlayerInterface.State _player_state = PlayerInterface.State.STOPPED; private uint _tape_counter_id = 0; - private uint _player_poll_id = 0; - private uint _metadata_poll_id = 0; - private StreamStatus _last_status = StreamStatus.IDLE; private PlayerInterface.State _last_play_state = PlayerInterface.State.STOPPED; private double _volume_cache = 0.5; - private int64 _last_playing_usec = 0; - private const int64 PLAYING_STATE_DEBOUNCE_USEC = 750000; + private uint _debounce_state_id = 0; + private const uint PLAYING_STATE_DEBOUNCE_MS = 1000; construct @@ -51,8 +48,7 @@ public class Tuner.Controllers.PlayerController : GLib.Object /** * @brief Process the Player play state changes emitted from gstreamer. * - * Actions are set in a separate thread as attempting UI interaction - * on the gstreamer signal results in a seg fault + * Actions are normalized in controller space to keep stream implementations simple. */ private void set_play_state (PlayerInterface.State state) { @@ -109,7 +105,7 @@ public class Tuner.Controllers.PlayerController : GLib.Object } } break; - } + } // switch } // set_reverse_symbol @@ -209,7 +205,7 @@ public class Tuner.Controllers.PlayerController : GLib.Object string stream_url = (_station.urlResolved != null && _station.urlResolved != "") ? _station.urlResolved : _station.url; if (app_ref != null && app_ref.settings != null) _volume_cache = app_ref.settings.volume; - attach_player (new StreamPlayer (stream_url)); + attach_player (new GstStreamPlayer (stream_url)); _play_error = false; Timeout.add (500, () => // Wait a half of a second to play the station to help flush metadata @@ -264,100 +260,114 @@ public class Tuner.Controllers.PlayerController : GLib.Object detach_player (); _player = player; _player.set_volume_level (_volume_cache); - _last_status = _player.status; - _last_play_state = _player.play_state; + _last_play_state = PlayerInterface.State.STOPPED; + _debounce_state_id = 0; + _play_error = false; - _player_poll_id = Timeout.add (200, () => { - if (_player == null) - return Source.REMOVE; - update_player_state (); - return Source.CONTINUE; - }); + _player.state_changed_sig.connect (on_player_state_changed); + _player.metadata_changed_sig.connect (on_player_metadata_changed); + _player.error_sig.connect (on_player_error); - _metadata_poll_id = Timeout.add (500, () => { - if (_player == null) - return Source.REMOVE; - update_metadata (); - return Source.CONTINUE; - }); + on_player_state_changed (_player.play_state); + on_player_metadata_changed (_player.metadata); } // attach_player private void detach_player () { - if (_player_poll_id > 0) - { - Source.remove (_player_poll_id); - _player_poll_id = 0; - } - if (_metadata_poll_id > 0) + cancel_state_debounce (); + if (_player != null) { - Source.remove (_metadata_poll_id); - _metadata_poll_id = 0; + _player.state_changed_sig.disconnect (on_player_state_changed); + _player.metadata_changed_sig.disconnect (on_player_metadata_changed); + _player.error_sig.disconnect (on_player_error); } _player = null; } // detach_player - private void update_player_state () + private void on_player_state_changed (PlayerInterface.State state) { - var player = _player; - if (player == null) - return; - - if (player.status == _last_status && player.play_state == _last_play_state) - return; + apply_player_state (state, false); + } // on_player_state_changed - _last_status = player.status; - _last_play_state = player.play_state; - if (player.status == StreamStatus.ERROR) - { - _play_error = true; - set_play_state (PlayerInterface.State.STOPPED); + private void apply_player_state (PlayerInterface.State state, bool force) + { + if (!force && state == _last_play_state) return; - } + _last_play_state = state; - if (player.play_state == PlayerInterface.State.PLAYING) + if (state == PlayerInterface.State.PLAYING) { - _last_playing_usec = GLib.get_monotonic_time (); + cancel_state_debounce (); set_play_state (PlayerInterface.State.PLAYING); + return; } - else if (player.play_state == PlayerInterface.State.BUFFERING - || player.play_state == PlayerInterface.State.PAUSED) + + if (state == PlayerInterface.State.BUFFERING + || state == PlayerInterface.State.PAUSED) { - var now = GLib.get_monotonic_time (); - if (player.status == StreamStatus.PLAYING - && _last_playing_usec > 0 - && (now - _last_playing_usec) < PLAYING_STATE_DEBOUNCE_USEC) + if (_player_state == PlayerInterface.State.PLAYING) { - set_play_state (PlayerInterface.State.PLAYING); + schedule_state_debounce (); } else { set_play_state (PlayerInterface.State.BUFFERING); } + return; } - else + + cancel_state_debounce (); + set_play_state (PlayerInterface.State.STOPPED); + } // apply_player_state + + + private void schedule_state_debounce () + { + if (_debounce_state_id > 0) + return; + _debounce_state_id = Timeout.add (PLAYING_STATE_DEBOUNCE_MS, () => { + _debounce_state_id = 0; + var player = _player; + if (player == null) + return Source.REMOVE; + apply_player_state (player.play_state, true); + return Source.REMOVE; + }); + } // schedule_state_debounce + + + private void cancel_state_debounce () + { + if (_debounce_state_id > 0) { - _last_playing_usec = 0; - set_play_state (PlayerInterface.State.STOPPED); + Source.remove (_debounce_state_id); + _debounce_state_id = 0; } - } // update_player_state + } // cancel_state_debounce - private void update_metadata () + private void on_player_metadata_changed (GLib.HashTable metadata) { if (_player == null || _station == null) return; - if (_metadata.process_tag_table (_player.metadata)) + if (_metadata.process_tag_table (metadata)) { var app_ref = app(); if (app_ref != null) app_ref.events.metadata_changed_sig (_station, _metadata); } - } // update_metadata + } // on_player_metadata_changed + + + private void on_player_error (string _message) + { + _play_error = true; + set_play_state (PlayerInterface.State.STOPPED); + } // on_player_error /** diff --git a/src/Ext/GstFader.vala b/src/Ext/GstFader.vala index 43db8f69..a4267a91 100644 --- a/src/Ext/GstFader.vala +++ b/src/Ext/GstFader.vala @@ -26,8 +26,8 @@ public class Fader : GLib.Object { private uint timeout_id = 0; private uint tail_timeout_id = 0; private uint gate_timeout_id = 0; - private StreamPlayer? from_player; - private StreamPlayer? to_player; + private GstStreamPlayer? from_player; + private GstStreamPlayer? to_player; private double target_volume = 0.5; private uint duration_ms = 1500; private uint interval_ms = 50; @@ -55,12 +55,12 @@ public class Fader : GLib.Object { private bool in_transition = false; private TransitionRequest? queued_request; private TransitionKind active_kind = TransitionKind.CROSSFADE; - private StreamPlayer? active_from_player; - private StreamPlayer? active_to_player; + private GstStreamPlayer? active_from_player; + private GstStreamPlayer? active_to_player; private double active_from_volume_start = 0.0; private double active_to_volume_start = 0.0; - public signal void fade_completed (StreamPlayer player); + public signal void fade_completed (GstStreamPlayer player); public Fader () { } @@ -129,7 +129,7 @@ public class Fader : GLib.Object { beat_poll_ms = poll_ms; } - public void crossfade (StreamPlayer? from_player, StreamPlayer to_player, double target_volume, uint? duration_ms = null, uint interval_ms = 50) { + public void crossfade (GstStreamPlayer? from_player, GstStreamPlayer to_player, double target_volume, uint? duration_ms = null, uint interval_ms = 50) { // Fade out the current player while fading in the new one. if (!prepare_transition (TransitionRequest.crossfade (from_player, to_player, target_volume, duration_ms, interval_ms))) { return; @@ -152,7 +152,7 @@ public class Fader : GLib.Object { start_crossfade (); } - public void fade_in (StreamPlayer to_player, double target_volume, uint? duration_ms = null, uint interval_ms = 50) { + public void fade_in (GstStreamPlayer to_player, double target_volume, uint? duration_ms = null, uint interval_ms = 50) { if (!prepare_transition (TransitionRequest.fade_in (to_player, target_volume, duration_ms, interval_ms))) { return; } @@ -179,7 +179,7 @@ public class Fader : GLib.Object { start_fade_in_gate (); } - public void fade_out (StreamPlayer from_player, double start_volume, uint? duration_ms = null, uint interval_ms = 50) { + public void fade_out (GstStreamPlayer from_player, double start_volume, uint? duration_ms = null, uint interval_ms = 50) { if (!prepare_transition (TransitionRequest.fade_out (from_player, start_volume, duration_ms, interval_ms))) { return; } @@ -459,7 +459,7 @@ public class Fader : GLib.Object { queued_request = null; } - public void cancel_and_stop (StreamPlayer? keep_player) { + public void cancel_and_stop (GstStreamPlayer? keep_player) { // Cancel any transition and stop any non-kept active players. cancel (); if (active_from_player != null && active_from_player != keep_player) { @@ -519,14 +519,14 @@ private enum TransitionKind { private class TransitionRequest : GLib.Object { public TransitionKind kind { get; construct; } - public StreamPlayer? from_player { get; construct; } - public StreamPlayer? to_player { get; construct; } + public GstStreamPlayer? from_player { get; construct; } + public GstStreamPlayer? to_player { get; construct; } public double target_volume { get; construct; } public uint duration_ms { get; construct; } public bool has_duration { get; construct; } public uint interval_ms { get; construct; } - private TransitionRequest (TransitionKind kind, StreamPlayer? from_player, StreamPlayer? to_player, double target_volume, uint? duration_ms, uint interval_ms) { + private TransitionRequest (TransitionKind kind, GstStreamPlayer? from_player, GstStreamPlayer? to_player, double target_volume, uint? duration_ms, uint interval_ms) { Object ( kind: kind, from_player: from_player, @@ -538,15 +538,15 @@ private class TransitionRequest : GLib.Object { ); } - public static TransitionRequest crossfade (StreamPlayer? from_player, StreamPlayer to_player, double target_volume, uint? duration_ms, uint interval_ms) { + public static TransitionRequest crossfade (GstStreamPlayer? from_player, GstStreamPlayer to_player, double target_volume, uint? duration_ms, uint interval_ms) { return new TransitionRequest (TransitionKind.CROSSFADE, from_player, to_player, target_volume, duration_ms, interval_ms); } - public static TransitionRequest fade_in (StreamPlayer to_player, double target_volume, uint? duration_ms, uint interval_ms) { + public static TransitionRequest fade_in (GstStreamPlayer to_player, double target_volume, uint? duration_ms, uint interval_ms) { return new TransitionRequest (TransitionKind.FADE_IN, null, to_player, target_volume, duration_ms, interval_ms); } - public static TransitionRequest fade_out (StreamPlayer from_player, double target_volume, uint? duration_ms, uint interval_ms) { + public static TransitionRequest fade_out (GstStreamPlayer from_player, double target_volume, uint? duration_ms, uint interval_ms) { return new TransitionRequest (TransitionKind.FADE_OUT, from_player, null, target_volume, duration_ms, interval_ms); } } diff --git a/src/Ext/GstStreamPlayer.vala b/src/Ext/GstStreamPlayer.vala index 62489e00..9a80189f 100644 --- a/src/Ext/GstStreamPlayer.vala +++ b/src/Ext/GstStreamPlayer.vala @@ -1,22 +1,19 @@ -using Gst; +/** + * SPDX-FileCopyrightText: Copyright © 2026 technosf + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @file GstStreamPlayer.vala + */ + using Gst; -namespace Tuner.Ext { - public enum StreamStatus { - IDLE, - PLAYING, - STOPPED, - EOS, - ERROR - } +namespace Tuner.Ext { - public class StreamPlayer : GLib.Object, PlayerInterface { + public class GstStreamPlayer : GLib.Object, PlayerInterface { // Stream URL configured at construction time. public string stream_url { get; construct; } - // Current lifecycle status. - private StreamStatus _status = StreamStatus.IDLE; - public StreamStatus status { get { return _status; } } // App-level state derived from the GStreamer playbin state. private PlayerInterface.State _play_state = PlayerInterface.State.STOPPED; public PlayerInterface.State play_state { get { return _play_state; } } @@ -34,7 +31,7 @@ namespace Tuner.Ext { private dynamic Element playbin; private dynamic Element level; - public StreamPlayer (string stream_url) { + public GstStreamPlayer (string stream_url) { // Create a per-stream playbin and attach a bus watcher. GLib.Object (stream_url: stream_url); _metadata = new GLib.HashTable (GLib.str_hash, GLib.str_equal); @@ -54,12 +51,13 @@ namespace Tuner.Ext { GLib.Error err; string debug; message.parse_error (out err, out debug); - _status = StreamStatus.ERROR; stdout.printf ("Error: %s\n", err.message); + error_sig (err.message); + set_play_state (PlayerInterface.State.STOPPED); break; case MessageType.EOS: - _status = StreamStatus.EOS; stdout.printf ("end of stream\n"); + set_play_state (PlayerInterface.State.STOPPED); break; case MessageType.STATE_CHANGED: Gst.State oldstate; @@ -67,7 +65,6 @@ namespace Tuner.Ext { Gst.State pending; message.parse_state_changed (out oldstate, out newstate, out pending); update_play_state (newstate); - _status = (newstate == Gst.State.PLAYING) ? StreamStatus.PLAYING : _status; stdout.printf ("state changed: %s->%s:%s\n", oldstate.to_string (), newstate.to_string (), pending.to_string ()); @@ -78,6 +75,7 @@ namespace Tuner.Ext { Gst.TagList? tag_list = null; message.parse_tag (out tag_list); if (tag_list != null) { + bool changed = false; var count = tag_list.n_tags (); for (uint i = 0; i < count; i++) { var tag = tag_list.nth_tag_name (i); @@ -85,9 +83,12 @@ namespace Tuner.Ext { if (value != null && value.holds (typeof (string))) { var tag_string = value.get_string (); _metadata.insert (tag, tag_string); + changed = true; stdout.printf ("tag: %s = %s\n", tag, tag_string); } } + if (changed) + metadata_changed_sig (_metadata); } break; case MessageType.ELEMENT: @@ -125,21 +126,19 @@ namespace Tuner.Ext { public void play () { // Transition playbin to PLAYING state. playbin.set_state (Gst.State.PLAYING); - update_play_state (Gst.State.PLAYING); - _status = StreamStatus.PLAYING; + set_play_state (PlayerInterface.State.PLAYING); } public void stop () { // Reset playbin to NULL state and mark as stopped. playbin.set_state (Gst.State.NULL); - update_play_state (Gst.State.NULL); - _status = StreamStatus.STOPPED; + set_play_state (PlayerInterface.State.STOPPED); } public void prepare () { // Pre-roll the pipeline without output. playbin.set_state (Gst.State.PAUSED); - update_play_state (Gst.State.PAUSED); + set_play_state (PlayerInterface.State.PAUSED); } public void set_volume_level (double volume) { @@ -174,7 +173,8 @@ namespace Tuner.Ext { return adjusted; } - private void setup_level_monitor () { + private void setup_level_monitor () + { // Inject a level element so we can read RMS values for silence detection. level = ElementFactory.make ("level", "level"); if (level != null) { @@ -184,22 +184,31 @@ namespace Tuner.Ext { } } - private void update_play_state (Gst.State state) { + private void set_play_state (PlayerInterface.State state) + { + if (_play_state == state) + return; + _play_state = state; + state_changed_sig (_play_state); + } // set_play_state + + private void update_play_state (Gst.State state) + { + set_play_state (map_play_state (state)); + } // update_play_state + + private PlayerInterface.State map_play_state (Gst.State state) + { switch (state) { case Gst.State.PLAYING: - _play_state = PlayerInterface.State.PLAYING; - break; + return PlayerInterface.State.PLAYING; case Gst.State.PAUSED: - _play_state = PlayerInterface.State.PAUSED; - break; + return PlayerInterface.State.PAUSED; case Gst.State.READY: - _play_state = PlayerInterface.State.BUFFERING; - break; + return PlayerInterface.State.BUFFERING; default: - _play_state = PlayerInterface.State.STOPPED; - break; + return PlayerInterface.State.STOPPED; } - } - } - -} + } // map_play_state + } // GstStreamPlayer +} // namespace Tuner.Ext diff --git a/src/Ext/PlayerInterface.vala b/src/Ext/PlayerInterface.vala index 6133197d..02633989 100644 --- a/src/Ext/PlayerInterface.vala +++ b/src/Ext/PlayerInterface.vala @@ -8,23 +8,28 @@ namespace Tuner.Ext { - public interface PlayerInterface : GLib.Object { - public enum State { + public interface PlayerInterface : GLib.Object + { + public enum State + { BUFFERING, PAUSED, PLAYING, STOPPED, STOPPED_ERROR - } + } // State - public abstract StreamStatus status { get; } public abstract State play_state { get; } public abstract GLib.HashTable metadata { get; } public abstract double volume { get; } + public signal void state_changed_sig (State state); + public signal void metadata_changed_sig (GLib.HashTable metadata); + public signal void error_sig (string message); + public abstract void play (); public abstract void stop (); public abstract void set_volume_level (double volume); - } + } // PlayerInterface -} +} // namespace diff --git a/src/Widgets/PlayButton.vala b/src/Widgets/PlayButton.vala index f5801a70..87c71ff2 100644 --- a/src/Widgets/PlayButton.vala +++ b/src/Widgets/PlayButton.vala @@ -50,12 +50,12 @@ public class Tuner.Widgets.PlayButton : Gtk.Button /* Public */ -/** - * @class PlayButton - * - * @brief Create the play button and hook it up to the PlayerController - * - */ + /** + * @class PlayButton + * + * @brief Create the play button and hook it up to the PlayerController + * + */ public PlayButton() { Object(); @@ -68,19 +68,19 @@ public class Tuner.Widgets.PlayButton : Gtk.Button { set_inverse_symbol (state); }); - } - - -/** - * @brief Set the play button symbol and sensitivity - * - * This method is instigated from a player state change signal. - * Performing any UI actions directly while handling the signal - * causes a segmentation fault. To get around this, threads_add_idle - * is used. - * - * @param state The new play state enum. - */ + } // construct + + + /** + * @brief Set the play button symbol and sensitivity + * + * This method is instigated from a player state change signal. + * Performing any UI actions directly while handling the signal + * causes a segmentation fault. To get around this, threads_add_idle + * is used. + * + * @param state The new play state enum. + */ private void set_inverse_symbol (PlayerInterface.State state) { switch (state) @@ -104,6 +104,6 @@ public class Tuner.Widgets.PlayButton : Gtk.Button image = PLAY; image.opacity = 1.0; break; - } - } // set_reverse_symbol -} // PlayButton + } // switch + } // set_reverse_symbol +} // PlayButton From f203be3041ca912c1938662b2516f4d26839f5da Mon Sep 17 00:00:00 2001 From: technosf Date: Sat, 11 Apr 2026 22:31:37 -0700 Subject: [PATCH 3/6] layout --- src/Controllers/PlayerController.vala | 60 +++---- .../PlaybackRecoveryCoordinator.vala | 14 +- .../UsageTrackingCoordinator.vala | 4 +- src/Events/AppEventBus.vala | 2 +- src/Ext/GstFader.vala | 2 +- src/Ext/GstStreamPlayer.vala | 146 +++++++++++++----- .../Player.vala} | 4 +- src/Services/DBusMediaPlayer.vala | 6 +- src/Widgets/Display.vala | 2 +- src/Widgets/HeaderBar.vala | 6 +- src/Widgets/PlayButton.vala | 8 +- src/Widgets/Window.vala | 2 +- 12 files changed, 159 insertions(+), 97 deletions(-) rename src/{Ext/PlayerInterface.vala => Models/Player.vala} (92%) diff --git a/src/Controllers/PlayerController.vala b/src/Controllers/PlayerController.vala index 9484dfab..04d85076 100644 --- a/src/Controllers/PlayerController.vala +++ b/src/Controllers/PlayerController.vala @@ -26,12 +26,12 @@ public class Tuner.Controllers.PlayerController : GLib.Object private const uint CLICK_INTERVAL_IN_SECONDS = 606; // tape counter timer - 10 mins plus 1% - private PlayerInterface? _player; + private Player? _player; private Station _station; private Metadata _metadata; - private PlayerInterface.State _player_state = PlayerInterface.State.STOPPED; + private Player.State _player_state = Player.State.STOPPED; private uint _tape_counter_id = 0; - private PlayerInterface.State _last_play_state = PlayerInterface.State.STOPPED; + private Player.State _last_play_state = Player.State.STOPPED; private double _volume_cache = 0.5; private uint _debounce_state_id = 0; private const uint PLAYING_STATE_DEBOUNCE_MS = 1000; @@ -50,40 +50,40 @@ public class Tuner.Controllers.PlayerController : GLib.Object * * Actions are normalized in controller space to keep stream implementations simple. */ - private void set_play_state (PlayerInterface.State state) + private void set_play_state (Player.State state) { var player = _player; if (player == null) return; switch (state) { - case PlayerInterface.State.PLAYING: + case Player.State.PLAYING: { var app_ref = app(); if (app_ref != null && app_ref.is_offline) { _play_error = false; player.stop (); - player_state = PlayerInterface.State.STOPPED; + player_state = Player.State.STOPPED; break; } _play_error = false; - player_state = PlayerInterface.State.PLAYING; + player_state = Player.State.PLAYING; } break; - case PlayerInterface.State.BUFFERING: - case PlayerInterface.State.PAUSED: + case Player.State.BUFFERING: + case Player.State.PAUSED: { var app_ref = app(); if (app_ref != null && app_ref.is_offline) { _play_error = false; player.stop (); - player_state = PlayerInterface.State.STOPPED; + player_state = Player.State.STOPPED; break; } _play_error = false; - player_state = PlayerInterface.State.BUFFERING; + player_state = Player.State.BUFFERING; } break; @@ -95,13 +95,13 @@ public class Tuner.Controllers.PlayerController : GLib.Object if ( _play_error && !offline_or_lost_network ) { - player_state = PlayerInterface.State.STOPPED_ERROR; + player_state = Player.State.STOPPED_ERROR; } else { if (offline_or_lost_network) _play_error = false; - player_state = PlayerInterface.State.STOPPED; + player_state = Player.State.STOPPED; } } break; @@ -114,7 +114,7 @@ public class Tuner.Controllers.PlayerController : GLib.Object * * Set by player signal. Does the tape counter emit */ - public PlayerInterface.State player_state { + public Player.State player_state { get { return _player_state; } // get @@ -125,7 +125,7 @@ public class Tuner.Controllers.PlayerController : GLib.Object if (_station != null && app_ref != null) app_ref.events.state_changed_sig(_station, value); - if (value == PlayerInterface.State.STOPPED || value == PlayerInterface.State.STOPPED_ERROR) + if (value == Player.State.STOPPED || value == Player.State.STOPPED_ERROR) { if (_tape_counter_id > 0) { @@ -133,7 +133,7 @@ public class Tuner.Controllers.PlayerController : GLib.Object _tape_counter_id = 0; } } - else if (value == PlayerInterface.State.PLAYING) + else if (value == Player.State.PLAYING) { _tape_counter_id = Timeout.add_seconds_full(Priority.LOW, CLICK_INTERVAL_IN_SECONDS, () => { @@ -232,8 +232,8 @@ public class Tuner.Controllers.PlayerController : GLib.Object */ public void play_pause () { switch (_player_state) { - case PlayerInterface.State.PLAYING: - case PlayerInterface.State.BUFFERING: + case Player.State.PLAYING: + case Player.State.BUFFERING: if (_player != null) _player.stop (); break; @@ -255,12 +255,12 @@ public class Tuner.Controllers.PlayerController : GLib.Object _player.stop (); } // stop - private void attach_player (PlayerInterface player) + private void attach_player (Player player) { detach_player (); _player = player; _player.set_volume_level (_volume_cache); - _last_play_state = PlayerInterface.State.STOPPED; + _last_play_state = Player.State.STOPPED; _debounce_state_id = 0; _play_error = false; @@ -286,41 +286,41 @@ public class Tuner.Controllers.PlayerController : GLib.Object } // detach_player - private void on_player_state_changed (PlayerInterface.State state) + private void on_player_state_changed (Player.State state) { apply_player_state (state, false); } // on_player_state_changed - private void apply_player_state (PlayerInterface.State state, bool force) + private void apply_player_state (Player.State state, bool force) { if (!force && state == _last_play_state) return; _last_play_state = state; - if (state == PlayerInterface.State.PLAYING) + if (state == Player.State.PLAYING) { cancel_state_debounce (); - set_play_state (PlayerInterface.State.PLAYING); + set_play_state (Player.State.PLAYING); return; } - if (state == PlayerInterface.State.BUFFERING - || state == PlayerInterface.State.PAUSED) + if (state == Player.State.BUFFERING + || state == Player.State.PAUSED) { - if (_player_state == PlayerInterface.State.PLAYING) + if (_player_state == Player.State.PLAYING) { schedule_state_debounce (); } else { - set_play_state (PlayerInterface.State.BUFFERING); + set_play_state (Player.State.BUFFERING); } return; } cancel_state_debounce (); - set_play_state (PlayerInterface.State.STOPPED); + set_play_state (Player.State.STOPPED); } // apply_player_state @@ -366,7 +366,7 @@ public class Tuner.Controllers.PlayerController : GLib.Object private void on_player_error (string _message) { _play_error = true; - set_play_state (PlayerInterface.State.STOPPED); + set_play_state (Player.State.STOPPED); } // on_player_error diff --git a/src/Coordinators/PlaybackRecoveryCoordinator.vala b/src/Coordinators/PlaybackRecoveryCoordinator.vala index 7ac413ac..d3478e57 100644 --- a/src/Coordinators/PlaybackRecoveryCoordinator.vala +++ b/src/Coordinators/PlaybackRecoveryCoordinator.vala @@ -71,8 +71,8 @@ namespace Tuner.Coordinators { { if (is_online) { - bool already_playing = _player.player_state == PlayerInterface.State.PLAYING - || _player.player_state == PlayerInterface.State.BUFFERING; + bool already_playing = _player.player_state == Player.State.PLAYING + || _player.player_state == Player.State.BUFFERING; if (_settings.play_restart && _was_playing_before_offline && _player.can_play() && !already_playing) _player.play_station(_player.station); _was_playing_before_offline = false; @@ -80,8 +80,8 @@ namespace Tuner.Coordinators { else { _was_playing_before_offline = _was_playing_before_offline - || _player.player_state == PlayerInterface.State.PLAYING - || _player.player_state == PlayerInterface.State.BUFFERING; + || _player.player_state == Player.State.PLAYING + || _player.player_state == Player.State.BUFFERING; } } // on_connectivity_changed @@ -92,12 +92,12 @@ namespace Tuner.Coordinators { * @param station Current station associated with the state change. * @param state Current player state. */ - private void on_player_state_changed(Station station, PlayerInterface.State state) + private void on_player_state_changed(Station station, Player.State state) { - if (state == PlayerInterface.State.PLAYING || state == PlayerInterface.State.BUFFERING) + if (state == Player.State.PLAYING || state == Player.State.BUFFERING) _was_playing_before_offline = true; - if (_app.is_online && state == PlayerInterface.State.STOPPED) + if (_app.is_online && state == Player.State.STOPPED) _was_playing_before_offline = false; } diff --git a/src/Coordinators/UsageTrackingCoordinator.vala b/src/Coordinators/UsageTrackingCoordinator.vala index 205450f1..cc417ee8 100644 --- a/src/Coordinators/UsageTrackingCoordinator.vala +++ b/src/Coordinators/UsageTrackingCoordinator.vala @@ -62,9 +62,9 @@ namespace Tuner.Coordinators { * @param station Station associated with the state transition. * @param state New player state. */ - private void on_player_state_changed(Station station, PlayerInterface.State state) + private void on_player_state_changed(Station station, Player.State state) { - if (_settings.do_not_vote || state != PlayerInterface.State.PLAYING) + if (_settings.do_not_vote || state != Player.State.PLAYING) return; _provider.click(station.stationuuid); diff --git a/src/Events/AppEventBus.vala b/src/Events/AppEventBus.vala index 93506de9..a26d0a19 100644 --- a/src/Events/AppEventBus.vala +++ b/src/Events/AppEventBus.vala @@ -31,7 +31,7 @@ namespace Tuner { public signal void station_changed_sig (Station station); /** Signal emitted when the player state changes. */ - public signal void state_changed_sig (Station station, PlayerInterface.State state); + public signal void state_changed_sig (Station station, Player.State state); /** Signal emitted when the title changes. */ public signal void metadata_changed_sig (Station station, Metadata metadata); diff --git a/src/Ext/GstFader.vala b/src/Ext/GstFader.vala index a4267a91..c4d8c641 100644 --- a/src/Ext/GstFader.vala +++ b/src/Ext/GstFader.vala @@ -338,7 +338,7 @@ public class Fader : GLib.Object { bool ready = false; switch (fade_in_mode) { case FadeInMode.PLAYING_READY: - ready = (to_player.play_state == PlayerInterface.State.PLAYING); + ready = (to_player.play_state == Player.State.PLAYING); break; case FadeInMode.AUDIO_READY: case FadeInMode.PREROLL_AUDIO_READY: diff --git a/src/Ext/GstStreamPlayer.vala b/src/Ext/GstStreamPlayer.vala index 9a80189f..a3ef63a6 100644 --- a/src/Ext/GstStreamPlayer.vala +++ b/src/Ext/GstStreamPlayer.vala @@ -6,46 +6,71 @@ * @file GstStreamPlayer.vala */ - using Gst; +using Gst; +namespace Tuner.Ext +{ + /** + * @class GstStreamPlayer + * @brief GStreamer-backed stream implementation for `PlayerInterface`. + * + * Wraps a `playbin` pipeline, translates GStreamer state into the app-level + * state enum, and emits metadata and error signals. + */ + public class GstStreamPlayer : GLib.Object, Player + { + /** @brief Stream URL configured at construction time. */ + public string stream_url { get; construct; } -namespace Tuner.Ext { + /** @brief App-level state derived from the GStreamer pipeline. */ + private Player.State _play_state = Player.State.STOPPED; + public Player.State play_state { get { return _play_state; } } - public class GstStreamPlayer : GLib.Object, PlayerInterface { - // Stream URL configured at construction time. - public string stream_url { get; construct; } - // App-level state derived from the GStreamer playbin state. - private PlayerInterface.State _play_state = PlayerInterface.State.STOPPED; - public PlayerInterface.State play_state { get { return _play_state; } } - // Latest string metadata from the stream. + /** @brief Latest string metadata from the stream. */ private GLib.HashTable _metadata; public GLib.HashTable metadata { get { return _metadata; } } - // Current output volume (0.0 - 1.0). + + /** @brief Current output volume (0.0 - 1.0). */ private double _volume = 0.5; public double volume { get { return _volume; } } - // Last observed RMS level in dB from the level element (more negative is quieter). + + /** @brief Last observed RMS level in dB from the level element. */ public double last_rms_db { get; private set; default = -100.0; } - // Optional per-stream trim in dB (positive/negative). + + /** @brief Optional per-stream trim in dB (positive/negative). */ public double trim_db { get; private set; default = 0.0; } private dynamic Element playbin; private dynamic Element level; - public GstStreamPlayer (string stream_url) { - // Create a per-stream playbin and attach a bus watcher. + /** + * @brief Create a GStreamer-backed stream player. + * + * @param stream_url Stream URL for the playbin pipeline. + */ + public GstStreamPlayer (string stream_url) + { GLib.Object (stream_url: stream_url); _metadata = new GLib.HashTable (GLib.str_hash, GLib.str_equal); playbin = ElementFactory.make ("playbin", "play"); playbin.uri = stream_url; set_volume_level (0.5); setup_level_monitor (); + playbin.user_agent = @"$(Application.APP_ID)/$(Application.APP_VERSION)"; Gst.Bus bus = playbin.get_bus (); bus.add_watch (0, bus_callback); } - private bool bus_callback (Gst.Bus bus, Gst.Message message) { - // Update state and metadata based on bus messages. + /** + * @brief Handle GStreamer bus messages. + * + * @param bus GStreamer bus instance. + * @param message Bus message to process. + * @return True to keep the watch active. + */ + private bool bus_callback (Gst.Bus bus, Gst.Message message) + { switch (message.type) { case MessageType.ERROR: GLib.Error err; @@ -53,11 +78,11 @@ namespace Tuner.Ext { message.parse_error (out err, out debug); stdout.printf ("Error: %s\n", err.message); error_sig (err.message); - set_play_state (PlayerInterface.State.STOPPED); + set_play_state (Player.State.STOPPED); break; case MessageType.EOS: stdout.printf ("end of stream\n"); - set_play_state (PlayerInterface.State.STOPPED); + set_play_state (Player.State.STOPPED); break; case MessageType.STATE_CHANGED: Gst.State oldstate; @@ -70,7 +95,6 @@ namespace Tuner.Ext { pending.to_string ()); break; case MessageType.TAG: - // Tags can include non-string values; only collect strings to avoid warnings. stdout.printf ("taglist found\n"); Gst.TagList? tag_list = null; message.parse_tag (out tag_list); @@ -123,26 +147,34 @@ namespace Tuner.Ext { return true; } - public void play () { - // Transition playbin to PLAYING state. + /** @brief Start playback. */ + public void play () + { playbin.set_state (Gst.State.PLAYING); - set_play_state (PlayerInterface.State.PLAYING); + set_play_state (Player.State.PLAYING); } - public void stop () { - // Reset playbin to NULL state and mark as stopped. + /** @brief Stop playback and reset the pipeline. */ + public void stop () + { playbin.set_state (Gst.State.NULL); - set_play_state (PlayerInterface.State.STOPPED); + set_play_state (Player.State.STOPPED); } - public void prepare () { - // Pre-roll the pipeline without output. + /** @brief Pre-roll the pipeline without output. */ + public void prepare () + { playbin.set_state (Gst.State.PAUSED); - set_play_state (PlayerInterface.State.PAUSED); + set_play_state (Player.State.PAUSED); } - public void set_volume_level (double volume) { - // Clamp and apply volume to playbin. + /** + * @brief Set the output volume level. + * + * @param volume Volume between 0.0 and 1.0. + */ + public void set_volume_level (double volume) + { if (volume < 0.0) { volume = 0.0; } else if (volume > 1.0) { @@ -152,13 +184,25 @@ namespace Tuner.Ext { playbin.volume = apply_trim (volume); } - public void set_trim_db_level (double trim_db) { - // Apply a dB trim to balance perceived loudness across stations. + /** + * @brief Set a per-stream trim in dB. + * + * @param trim_db Trim value in dB. + */ + public void set_trim_db_level (double trim_db) + { this.trim_db = trim_db; playbin.volume = apply_trim (this.volume); } - private double apply_trim (double volume) { + /** + * @brief Apply trim to the volume and clamp to [0.0, 1.0]. + * + * @param volume Base volume. + * @return Adjusted volume. + */ + private double apply_trim (double volume) + { if (trim_db == 0.0) { return volume; } @@ -173,9 +217,11 @@ namespace Tuner.Ext { return adjusted; } - private void setup_level_monitor () + /** + * @brief Configure RMS monitoring via the `level` element. + */ + private void setup_level_monitor () { - // Inject a level element so we can read RMS values for silence detection. level = ElementFactory.make ("level", "level"); if (level != null) { level.set_property ("interval", (uint64) 100000000); // 100ms in ns @@ -184,7 +230,12 @@ namespace Tuner.Ext { } } - private void set_play_state (PlayerInterface.State state) + /** + * @brief Update and emit the app-level play state. + * + * @param state New app-level state. + */ + private void set_play_state (Player.State state) { if (_play_state == state) return; @@ -192,22 +243,33 @@ namespace Tuner.Ext { state_changed_sig (_play_state); } // set_play_state - private void update_play_state (Gst.State state) + /** + * @brief Map GStreamer states into app-level state and emit updates. + * + * @param state GStreamer state. + */ + private void update_play_state (Gst.State state) { set_play_state (map_play_state (state)); } // update_play_state - private PlayerInterface.State map_play_state (Gst.State state) + /** + * @brief Translate GStreamer state into `PlayerInterface.State`. + * + * @param state GStreamer state. + * @return App-level state. + */ + private Player.State map_play_state (Gst.State state) { switch (state) { case Gst.State.PLAYING: - return PlayerInterface.State.PLAYING; + return Player.State.PLAYING; case Gst.State.PAUSED: - return PlayerInterface.State.PAUSED; + return Player.State.PAUSED; case Gst.State.READY: - return PlayerInterface.State.BUFFERING; + return Player.State.BUFFERING; default: - return PlayerInterface.State.STOPPED; + return Player.State.STOPPED; } } // map_play_state } // GstStreamPlayer diff --git a/src/Ext/PlayerInterface.vala b/src/Models/Player.vala similarity index 92% rename from src/Ext/PlayerInterface.vala rename to src/Models/Player.vala index 02633989..3de378dc 100644 --- a/src/Ext/PlayerInterface.vala +++ b/src/Models/Player.vala @@ -6,9 +6,9 @@ * @file PlayerInterface.vala */ -namespace Tuner.Ext { +namespace Tuner.Models{ - public interface PlayerInterface : GLib.Object + public interface Player : GLib.Object { public enum State { diff --git a/src/Services/DBusMediaPlayer.vala b/src/Services/DBusMediaPlayer.vala index 8f51c6b5..38063862 100644 --- a/src/Services/DBusMediaPlayer.vala +++ b/src/Services/DBusMediaPlayer.vala @@ -192,11 +192,11 @@ namespace Tuner.Services.DBus { switch (state) { - case PlayerInterface.State.PLAYING: - case PlayerInterface.State.BUFFERING: + case Player.State.PLAYING: + case Player.State.BUFFERING: playback_status = "Playing"; break; - case PlayerInterface.State.PAUSED: + case Player.State.PAUSED: playback_status = "Paused"; break; default: diff --git a/src/Widgets/Display.vala b/src/Widgets/Display.vala index 3112ad7d..aaf84cbe 100644 --- a/src/Widgets/Display.vala +++ b/src/Widgets/Display.vala @@ -191,7 +191,7 @@ public class Tuner.Widgets.Display : Gtk.Paned, StationListHookup { _app.events.state_changed_sig.connect((station, state) => { - if (_shuffle && state == PlayerInterface.State.STOPPED_ERROR) + if (_shuffle && state == Player.State.STOPPED_ERROR) { Timeout.add(HeaderBar.SHUFFLE_ERROR_RETRY_DELAY_MS, () => { diff --git a/src/Widgets/HeaderBar.vala b/src/Widgets/HeaderBar.vala index 44b28150..2575cc11 100644 --- a/src/Widgets/HeaderBar.vala +++ b/src/Widgets/HeaderBar.vala @@ -326,7 +326,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar */ public bool update_playing_station(Station station) { - if ( _app.is_offline || ( _station != null && _station == station && _player.player_state != PlayerInterface.State.STOPPED_ERROR ) ) + if ( _app.is_offline || ( _station != null && _station == station && _player.player_state != Player.State.STOPPED_ERROR ) ) return false; if (_station_update_lock.trylock()) @@ -414,8 +414,8 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar */ private void update_controls_state() { - bool is_playing_now = _player.player_state == PlayerInterface.State.PLAYING - || _player.player_state == PlayerInterface.State.BUFFERING; + bool is_playing_now = _player.player_state == Player.State.PLAYING + || _player.player_state == Player.State.BUFFERING; if (_app.is_offline) { diff --git a/src/Widgets/PlayButton.vala b/src/Widgets/PlayButton.vala index 87c71ff2..d25fbdb2 100644 --- a/src/Widgets/PlayButton.vala +++ b/src/Widgets/PlayButton.vala @@ -81,21 +81,21 @@ public class Tuner.Widgets.PlayButton : Gtk.Button * * @param state The new play state enum. */ - private void set_inverse_symbol (PlayerInterface.State state) + private void set_inverse_symbol (Player.State state) { switch (state) { - case PlayerInterface.State.PLAYING: + case Player.State.PLAYING: image = STOP; image.opacity = 1.0; break; - case PlayerInterface.State.BUFFERING: + case Player.State.BUFFERING: image = BUFFERING; image.opacity = 0.5; break; - case PlayerInterface.State.STOPPED_ERROR: + case Player.State.STOPPED_ERROR: image = ERROR; image.opacity = 0.5; break; diff --git a/src/Widgets/Window.vala b/src/Widgets/Window.vala index cfa66e83..ca86f0c7 100644 --- a/src/Widgets/Window.vala +++ b/src/Widgets/Window.vala @@ -492,7 +492,7 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow get_size (out _width, out _height); // Echo ending dimensions so Settings can pick them up _settings.save (); - if (player_ctrl.player_state == PlayerInterface.State.PLAYING) { + if (player_ctrl.player_state == Player.State.PLAYING) { hide_on_delete(); var notification = new GLib.Notification(NOTIFICATION_PLAYING_BACKGROUND); notification.set_body(NOTIFICATION_CLICK_RESUME); From 092ea0f41b141215a886a6dd78aa50364dc8ae70 Mon Sep 17 00:00:00 2001 From: technosf Date: Sun, 12 Apr 2026 06:28:47 -0700 Subject: [PATCH 4/6] Fading --- src/Controllers/PlayerController.vala | 117 ++- .../PlaybackRecoveryCoordinator.vala | 14 +- .../UsageTrackingCoordinator.vala | 4 +- src/Events/AppEventBus.vala | 2 +- src/Ext/GstFader.vala | 934 +++++++++--------- src/Ext/GstStreamPlayer.vala | 85 +- src/Models/{Player.vala => StreamPlayer.vala} | 16 +- src/Services/DBusMediaPlayer.vala | 6 +- src/Utils.vala | 18 +- src/Widgets/Display.vala | 2 +- src/Widgets/HeaderBar.vala | 6 +- src/Widgets/PlayButton.vala | 10 +- src/Widgets/Window.vala | 2 +- src/meson.build | 2 +- 14 files changed, 673 insertions(+), 545 deletions(-) rename src/Models/{Player.vala => StreamPlayer.vala} (58%) diff --git a/src/Controllers/PlayerController.vala b/src/Controllers/PlayerController.vala index 04d85076..c467d734 100644 --- a/src/Controllers/PlayerController.vala +++ b/src/Controllers/PlayerController.vala @@ -7,7 +7,6 @@ * @file PlayerController.vala */ -using Tuner.Ext; using Tuner.Models; /** @@ -26,12 +25,12 @@ public class Tuner.Controllers.PlayerController : GLib.Object private const uint CLICK_INTERVAL_IN_SECONDS = 606; // tape counter timer - 10 mins plus 1% - private Player? _player; + private StreamPlayer? _player; private Station _station; private Metadata _metadata; - private Player.State _player_state = Player.State.STOPPED; + private StreamPlayer.State _player_state = StreamPlayer.State.STOPPED; private uint _tape_counter_id = 0; - private Player.State _last_play_state = Player.State.STOPPED; + private StreamPlayer.State _last_play_state = StreamPlayer.State.STOPPED; private double _volume_cache = 0.5; private uint _debounce_state_id = 0; private const uint PLAYING_STATE_DEBOUNCE_MS = 1000; @@ -45,45 +44,47 @@ public class Tuner.Controllers.PlayerController : GLib.Object } // construct - /** - * @brief Process the Player play state changes emitted from gstreamer. - * + /** + * @brief Process play state changes emitted from the stream player. + * * Actions are normalized in controller space to keep stream implementations simple. + * + * @param state The stream player's reported state. */ - private void set_play_state (Player.State state) + private void set_play_state (StreamPlayer.State state) { var player = _player; if (player == null) return; switch (state) { - case Player.State.PLAYING: + case StreamPlayer.State.PLAYING: { var app_ref = app(); if (app_ref != null && app_ref.is_offline) { _play_error = false; player.stop (); - player_state = Player.State.STOPPED; + player_state = StreamPlayer.State.STOPPED; break; } _play_error = false; - player_state = Player.State.PLAYING; + player_state = StreamPlayer.State.PLAYING; } break; - case Player.State.BUFFERING: - case Player.State.PAUSED: + case StreamPlayer.State.BUFFERING: + case StreamPlayer.State.PAUSED: { var app_ref = app(); if (app_ref != null && app_ref.is_offline) { _play_error = false; player.stop (); - player_state = Player.State.STOPPED; + player_state = StreamPlayer.State.STOPPED; break; } _play_error = false; - player_state = Player.State.BUFFERING; + player_state = StreamPlayer.State.BUFFERING; } break; @@ -95,13 +96,13 @@ public class Tuner.Controllers.PlayerController : GLib.Object if ( _play_error && !offline_or_lost_network ) { - player_state = Player.State.STOPPED_ERROR; + player_state = StreamPlayer.State.STOPPED_ERROR; } else { if (offline_or_lost_network) _play_error = false; - player_state = Player.State.STOPPED; + player_state = StreamPlayer.State.STOPPED; } } break; @@ -114,7 +115,7 @@ public class Tuner.Controllers.PlayerController : GLib.Object * * Set by player signal. Does the tape counter emit */ - public Player.State player_state { + public StreamPlayer.State player_state { get { return _player_state; } // get @@ -125,7 +126,7 @@ public class Tuner.Controllers.PlayerController : GLib.Object if (_station != null && app_ref != null) app_ref.events.state_changed_sig(_station, value); - if (value == Player.State.STOPPED || value == Player.State.STOPPED_ERROR) + if (value == StreamPlayer.State.STOPPED || value == StreamPlayer.State.STOPPED_ERROR) { if (_tape_counter_id > 0) { @@ -133,7 +134,7 @@ public class Tuner.Controllers.PlayerController : GLib.Object _tape_counter_id = 0; } } - else if (value == Player.State.PLAYING) + else if (value == StreamPlayer.State.PLAYING) { _tape_counter_id = Timeout.add_seconds_full(Priority.LOW, CLICK_INTERVAL_IN_SECONDS, () => { @@ -195,9 +196,9 @@ public class Tuner.Controllers.PlayerController : GLib.Object */ public void play_station (Station station) { - if (_player != null) - _player.stop (); - detach_player (); + var previous_player = _player; + if (previous_player != null) + detach_player (); _station = station; var app_ref = app(); if (app_ref != null) @@ -205,8 +206,13 @@ public class Tuner.Controllers.PlayerController : GLib.Object string stream_url = (_station.urlResolved != null && _station.urlResolved != "") ? _station.urlResolved : _station.url; if (app_ref != null && app_ref.settings != null) _volume_cache = app_ref.settings.volume; - attach_player (new GstStreamPlayer (stream_url)); + attach_player (Tuner.create_stream_player (stream_url)); _play_error = false; + if (previous_player != null && _player != null) + { + previous_player.crossfade_to (_player, _volume_cache); + return; + } Timeout.add (500, () => // Wait a half of a second to play the station to help flush metadata { @@ -232,8 +238,8 @@ public class Tuner.Controllers.PlayerController : GLib.Object */ public void play_pause () { switch (_player_state) { - case Player.State.PLAYING: - case Player.State.BUFFERING: + case StreamPlayer.State.PLAYING: + case StreamPlayer.State.BUFFERING: if (_player != null) _player.stop (); break; @@ -255,12 +261,17 @@ public class Tuner.Controllers.PlayerController : GLib.Object _player.stop (); } // stop - private void attach_player (Player player) + /** + * @brief Connects a new player instance and initializes controller state. + * + * @param player The player instance to attach. + */ + private void attach_player (StreamPlayer player) { detach_player (); _player = player; _player.set_volume_level (_volume_cache); - _last_play_state = Player.State.STOPPED; + _last_play_state = StreamPlayer.State.STOPPED; _debounce_state_id = 0; _play_error = false; @@ -273,6 +284,9 @@ public class Tuner.Controllers.PlayerController : GLib.Object } // attach_player + /** + * @brief Disconnects the current player instance and clears controller state. + */ private void detach_player () { cancel_state_debounce (); @@ -286,44 +300,58 @@ public class Tuner.Controllers.PlayerController : GLib.Object } // detach_player - private void on_player_state_changed (Player.State state) + /** + * @brief Handles state change signals from the player. + * + * @param state The new player state. + */ + private void on_player_state_changed (StreamPlayer.State state) { apply_player_state (state, false); } // on_player_state_changed - private void apply_player_state (Player.State state, bool force) + /** + * @brief Applies a player state change with optional debounce override. + * + * @param state The player state to apply. + * @param force True to apply even if the state matches the last seen value. + */ + private void apply_player_state (StreamPlayer.State state, bool force) { if (!force && state == _last_play_state) return; _last_play_state = state; - if (state == Player.State.PLAYING) + if (state == StreamPlayer.State.PLAYING) { cancel_state_debounce (); - set_play_state (Player.State.PLAYING); + set_play_state (StreamPlayer.State.PLAYING); return; } - if (state == Player.State.BUFFERING - || state == Player.State.PAUSED) + if (state == StreamPlayer.State.BUFFERING + || state == StreamPlayer.State.PAUSED) { - if (_player_state == Player.State.PLAYING) + if (_player_state == StreamPlayer.State.PLAYING) { schedule_state_debounce (); } else { - set_play_state (Player.State.BUFFERING); + set_play_state (StreamPlayer.State.BUFFERING); } return; } cancel_state_debounce (); - set_play_state (Player.State.STOPPED); + set_play_state (StreamPlayer.State.STOPPED); } // apply_player_state + /** + * @brief Schedules a short debounce before applying a non-playing state. + */ private void schedule_state_debounce () { if (_debounce_state_id > 0) @@ -339,6 +367,9 @@ public class Tuner.Controllers.PlayerController : GLib.Object } // schedule_state_debounce + /** + * @brief Cancels any pending debounce timer. + */ private void cancel_state_debounce () { if (_debounce_state_id > 0) @@ -349,6 +380,11 @@ public class Tuner.Controllers.PlayerController : GLib.Object } // cancel_state_debounce + /** + * @brief Handles updated metadata from the player. + * + * @param metadata The metadata table provided by the player. + */ private void on_player_metadata_changed (GLib.HashTable metadata) { if (_player == null || _station == null) @@ -363,10 +399,15 @@ public class Tuner.Controllers.PlayerController : GLib.Object } // on_player_metadata_changed + /** + * @brief Handles playback errors reported by the player. + * + * @param _message The error message reported by the player. + */ private void on_player_error (string _message) { _play_error = true; - set_play_state (Player.State.STOPPED); + set_play_state (StreamPlayer.State.STOPPED); } // on_player_error diff --git a/src/Coordinators/PlaybackRecoveryCoordinator.vala b/src/Coordinators/PlaybackRecoveryCoordinator.vala index d3478e57..ac7cd0df 100644 --- a/src/Coordinators/PlaybackRecoveryCoordinator.vala +++ b/src/Coordinators/PlaybackRecoveryCoordinator.vala @@ -71,8 +71,8 @@ namespace Tuner.Coordinators { { if (is_online) { - bool already_playing = _player.player_state == Player.State.PLAYING - || _player.player_state == Player.State.BUFFERING; + bool already_playing = _player.player_state == StreamPlayer.State.PLAYING + || _player.player_state == StreamPlayer.State.BUFFERING; if (_settings.play_restart && _was_playing_before_offline && _player.can_play() && !already_playing) _player.play_station(_player.station); _was_playing_before_offline = false; @@ -80,8 +80,8 @@ namespace Tuner.Coordinators { else { _was_playing_before_offline = _was_playing_before_offline - || _player.player_state == Player.State.PLAYING - || _player.player_state == Player.State.BUFFERING; + || _player.player_state == StreamPlayer.State.PLAYING + || _player.player_state == StreamPlayer.State.BUFFERING; } } // on_connectivity_changed @@ -92,12 +92,12 @@ namespace Tuner.Coordinators { * @param station Current station associated with the state change. * @param state Current player state. */ - private void on_player_state_changed(Station station, Player.State state) + private void on_player_state_changed(Station station, StreamPlayer.State state) { - if (state == Player.State.PLAYING || state == Player.State.BUFFERING) + if (state == StreamPlayer.State.PLAYING || state == StreamPlayer.State.BUFFERING) _was_playing_before_offline = true; - if (_app.is_online && state == Player.State.STOPPED) + if (_app.is_online && state == StreamPlayer.State.STOPPED) _was_playing_before_offline = false; } diff --git a/src/Coordinators/UsageTrackingCoordinator.vala b/src/Coordinators/UsageTrackingCoordinator.vala index cc417ee8..74091bbf 100644 --- a/src/Coordinators/UsageTrackingCoordinator.vala +++ b/src/Coordinators/UsageTrackingCoordinator.vala @@ -62,9 +62,9 @@ namespace Tuner.Coordinators { * @param station Station associated with the state transition. * @param state New player state. */ - private void on_player_state_changed(Station station, Player.State state) + private void on_player_state_changed(Station station, StreamPlayer.State state) { - if (_settings.do_not_vote || state != Player.State.PLAYING) + if (_settings.do_not_vote || state != StreamPlayer.State.PLAYING) return; _provider.click(station.stationuuid); diff --git a/src/Events/AppEventBus.vala b/src/Events/AppEventBus.vala index a26d0a19..28bf18c4 100644 --- a/src/Events/AppEventBus.vala +++ b/src/Events/AppEventBus.vala @@ -31,7 +31,7 @@ namespace Tuner { public signal void station_changed_sig (Station station); /** Signal emitted when the player state changes. */ - public signal void state_changed_sig (Station station, Player.State state); + public signal void state_changed_sig (Station station, StreamPlayer.State state); /** Signal emitted when the title changes. */ public signal void metadata_changed_sig (Station station, Metadata metadata); diff --git a/src/Ext/GstFader.vala b/src/Ext/GstFader.vala index c4d8c641..4635554c 100644 --- a/src/Ext/GstFader.vala +++ b/src/Ext/GstFader.vala @@ -1,552 +1,554 @@ -using Tuner.Ext; - -public class Fader : GLib.Object { - public enum FadeInMode { - UNBUFFERED, - PLAYING_READY, - AUDIO_READY, - PREROLL_AUDIO_READY - } - - public enum TransitionPolicy { - IGNORE_DURING_FADE, - CANCEL_AND_START, - QUEUE_LATEST - } - - public enum FadeCurve { - LINEAR, - EXPONENTIAL, - LOGARITHMIC, - SMOOTHSTEP, - EQUAL_POWER - } - - // Crossfade timing state. - private uint timeout_id = 0; - private uint tail_timeout_id = 0; - private uint gate_timeout_id = 0; - private GstStreamPlayer? from_player; - private GstStreamPlayer? to_player; - private double target_volume = 0.5; - private uint duration_ms = 1500; - private uint interval_ms = 50; - private uint elapsed_ms = 0; - private FadeCurve curve = FadeCurve.LINEAR; - private bool preroll_enabled = false; - private bool tail_enabled = false; - private bool limiter_enabled = false; - private bool silence_gate_enabled = false; - private bool beat_sync_enabled = false; - private bool loudness_trim_enabled = false; - private uint tail_duration_ms = 1200; - private double tail_level = 0.12; - private double silence_threshold_db = -35.0; - private uint silence_gate_timeout_ms = 2000; - private double target_rms_db = -20.0; - private double max_trim_db = 12.0; - private uint beat_window_ms = 2000; - private uint beat_poll_ms = 50; - private bool apply_trim_after_fade = false; - private double pending_trim_to_db = 0.0; - private FadeInMode fade_in_mode = FadeInMode.UNBUFFERED; - private uint fade_in_timeout_ms = 3000; - private TransitionPolicy transition_policy = TransitionPolicy.CANCEL_AND_START; - private bool in_transition = false; - private TransitionRequest? queued_request; - private TransitionKind active_kind = TransitionKind.CROSSFADE; - private GstStreamPlayer? active_from_player; - private GstStreamPlayer? active_to_player; - private double active_from_volume_start = 0.0; - private double active_to_volume_start = 0.0; - - public signal void fade_completed (GstStreamPlayer player); - - public Fader () { - } - - public void set_curve (FadeCurve curve) { - this.curve = curve; - } - - public void set_duration_ms (uint duration_ms) { - this.duration_ms = duration_ms; - } - - public void set_fade_in_mode (FadeInMode mode) { - fade_in_mode = mode; - } - - public void set_transition_policy (TransitionPolicy policy) { - transition_policy = policy; - } - - public void set_fade_in_timeout_ms (uint timeout_ms) { - fade_in_timeout_ms = timeout_ms; - } - - public void set_preroll_enabled (bool enabled) { - preroll_enabled = enabled; - } +using Tuner.Models; + +namespace Tuner.Ext { + public class GstFader : GLib.Object { + public enum FadeInMode { + UNBUFFERED, + PLAYING_READY, + AUDIO_READY, + PREROLL_AUDIO_READY + } - public void set_tail_enabled (bool enabled) { - tail_enabled = enabled; - } + public enum TransitionPolicy { + IGNORE_DURING_FADE, + CANCEL_AND_START, + QUEUE_LATEST + } - public void set_limiter_enabled (bool enabled) { - limiter_enabled = enabled; - } + public enum FadeCurve { + LINEAR, + EXPONENTIAL, + LOGARITHMIC, + SMOOTHSTEP, + EQUAL_POWER + } - public void set_silence_gate_enabled (bool enabled) { - silence_gate_enabled = enabled; - } + // Crossfade timing state. + private uint timeout_id = 0; + private uint tail_timeout_id = 0; + private uint gate_timeout_id = 0; + private GstStreamPlayer? from_player; + private GstStreamPlayer? to_player; + private double target_volume = 0.5; + private uint duration_ms = 1500; + private uint interval_ms = 50; + private uint elapsed_ms = 0; + private FadeCurve curve = FadeCurve.LINEAR; + private bool preroll_enabled = false; + private bool tail_enabled = false; + private bool limiter_enabled = false; + private bool silence_gate_enabled = false; + private bool beat_sync_enabled = false; + private bool loudness_trim_enabled = false; + private uint tail_duration_ms = 1200; + private double tail_level = 0.12; + private double silence_threshold_db = -35.0; + private uint silence_gate_timeout_ms = 2000; + private double target_rms_db = -20.0; + private double max_trim_db = 12.0; + private uint beat_window_ms = 2000; + private uint beat_poll_ms = 50; + private bool apply_trim_after_fade = false; + private double pending_trim_to_db = 0.0; + private FadeInMode fade_in_mode = FadeInMode.UNBUFFERED; + private uint fade_in_timeout_ms = 3000; + private TransitionPolicy transition_policy = TransitionPolicy.CANCEL_AND_START; + private bool in_transition = false; + private TransitionRequest? queued_request; + private TransitionKind active_kind = TransitionKind.CROSSFADE; + private GstStreamPlayer? active_from_player; + private GstStreamPlayer? active_to_player; + private double active_from_volume_start = 0.0; + private double active_to_volume_start = 0.0; + + public signal void fade_completed (GstStreamPlayer player); + + public GstFader () { + } - public void set_beat_sync_enabled (bool enabled) { - beat_sync_enabled = enabled; - } + public void set_curve (FadeCurve curve) { + this.curve = curve; + } - public void set_loudness_trim_enabled (bool enabled) { - loudness_trim_enabled = enabled; - } + public void set_duration_ms (uint duration_ms) { + this.duration_ms = duration_ms; + } - public void set_loudness_trim_params (double target_rms_db, double max_trim_db) { - this.target_rms_db = target_rms_db; - this.max_trim_db = max_trim_db; - } + public void set_fade_in_mode (FadeInMode mode) { + fade_in_mode = mode; + } - public void set_tail_params (uint duration_ms, double level) { - tail_duration_ms = duration_ms; - tail_level = level; - } + public void set_transition_policy (TransitionPolicy policy) { + transition_policy = policy; + } - public void set_silence_gate_params (double threshold_db, uint timeout_ms) { - silence_threshold_db = threshold_db; - silence_gate_timeout_ms = timeout_ms; - } + public void set_fade_in_timeout_ms (uint timeout_ms) { + fade_in_timeout_ms = timeout_ms; + } - public void set_beat_sync_params (uint window_ms, uint poll_ms) { - beat_window_ms = window_ms; - beat_poll_ms = poll_ms; - } + public void set_preroll_enabled (bool enabled) { + preroll_enabled = enabled; + } - public void crossfade (GstStreamPlayer? from_player, GstStreamPlayer to_player, double target_volume, uint? duration_ms = null, uint interval_ms = 50) { - // Fade out the current player while fading in the new one. - if (!prepare_transition (TransitionRequest.crossfade (from_player, to_player, target_volume, duration_ms, interval_ms))) { - return; + public void set_tail_enabled (bool enabled) { + tail_enabled = enabled; } - this.from_player = from_player; - this.to_player = to_player; - this.target_volume = target_volume; - if (duration_ms != null) { - this.duration_ms = duration_ms; + public void set_limiter_enabled (bool enabled) { + limiter_enabled = enabled; } - this.interval_ms = interval_ms; - this.elapsed_ms = 0; - active_kind = TransitionKind.CROSSFADE; - active_from_player = from_player; - active_to_player = to_player; - active_from_volume_start = from_player != null ? from_player.volume : 0.0; - active_to_volume_start = 0.0; - start_crossfade (); - } + public void set_silence_gate_enabled (bool enabled) { + silence_gate_enabled = enabled; + } - public void fade_in (GstStreamPlayer to_player, double target_volume, uint? duration_ms = null, uint interval_ms = 50) { - if (!prepare_transition (TransitionRequest.fade_in (to_player, target_volume, duration_ms, interval_ms))) { - return; + public void set_beat_sync_enabled (bool enabled) { + beat_sync_enabled = enabled; } - this.from_player = null; - this.to_player = to_player; - this.target_volume = target_volume; - if (duration_ms != null) { - this.duration_ms = duration_ms; + public void set_loudness_trim_enabled (bool enabled) { + loudness_trim_enabled = enabled; } - this.interval_ms = interval_ms; - this.elapsed_ms = 0; - active_kind = TransitionKind.FADE_IN; - active_from_player = null; - active_to_player = to_player; - active_from_volume_start = 0.0; - active_to_volume_start = 0.0; + public void set_loudness_trim_params (double target_rms_db, double max_trim_db) { + this.target_rms_db = target_rms_db; + this.max_trim_db = max_trim_db; + } - to_player.set_volume_level (0.0); - if (fade_in_mode == FadeInMode.PREROLL_AUDIO_READY) { - to_player.prepare (); + public void set_tail_params (uint duration_ms, double level) { + tail_duration_ms = duration_ms; + tail_level = level; } - start_fade_in_gate (); - } - public void fade_out (GstStreamPlayer from_player, double start_volume, uint? duration_ms = null, uint interval_ms = 50) { - if (!prepare_transition (TransitionRequest.fade_out (from_player, start_volume, duration_ms, interval_ms))) { - return; + public void set_silence_gate_params (double threshold_db, uint timeout_ms) { + silence_threshold_db = threshold_db; + silence_gate_timeout_ms = timeout_ms; } - this.from_player = from_player; - this.to_player = null; - this.target_volume = start_volume; - if (duration_ms != null) { - this.duration_ms = duration_ms; + public void set_beat_sync_params (uint window_ms, uint poll_ms) { + beat_window_ms = window_ms; + beat_poll_ms = poll_ms; } - this.interval_ms = interval_ms; - this.elapsed_ms = 0; - active_kind = TransitionKind.FADE_OUT; - active_from_player = from_player; - active_to_player = null; - active_from_volume_start = start_volume; - active_to_volume_start = 0.0; - start_fade_out (); - } + public void crossfade (GstStreamPlayer? from_player, GstStreamPlayer to_player, double target_volume, uint? duration_ms = null, uint interval_ms = 50) { + // Fade out the current player while fading in the new one. + if (!prepare_transition (TransitionRequest.crossfade (from_player, to_player, target_volume, duration_ms, interval_ms))) { + return; + } - private double apply_curve (double t) { - if (t <= 0.0) { - return 0.0; - } - if (t >= 1.0) { - return 1.0; - } - switch (curve) { - case FadeCurve.EXPONENTIAL: - return t * t; - case FadeCurve.LOGARITHMIC: - return Math.sqrt (t); - case FadeCurve.SMOOTHSTEP: - return t * t * (3.0 - 2.0 * t); - case FadeCurve.EQUAL_POWER: - return Math.sin (t * Math.PI / 2.0); - case FadeCurve.LINEAR: - default: - return t; + this.from_player = from_player; + this.to_player = to_player; + this.target_volume = target_volume; + if (duration_ms != null) { + this.duration_ms = duration_ms; + } + this.interval_ms = interval_ms; + this.elapsed_ms = 0; + + active_kind = TransitionKind.CROSSFADE; + active_from_player = from_player; + active_to_player = to_player; + active_from_volume_start = from_player != null ? from_player.volume : 0.0; + active_to_volume_start = 0.0; + start_crossfade (); } - } - private void start_crossfade () { - in_transition = true; - to_player.set_volume_level (0.0); - if (preroll_enabled) { - to_player.prepare (); - } + public void fade_in (GstStreamPlayer to_player, double target_volume, uint? duration_ms = null, uint interval_ms = 50) { + if (!prepare_transition (TransitionRequest.fade_in (to_player, target_volume, duration_ms, interval_ms))) { + return; + } - if (silence_gate_enabled && from_player != null) { - gate_timeout_id = GLib.Timeout.add (interval_ms, () => { - elapsed_ms += interval_ms; - if (from_player.last_rms_db <= silence_threshold_db || elapsed_ms >= silence_gate_timeout_ms) { - begin_fade (); - gate_timeout_id = 0; - return false; - } - return true; - }); - } else if (beat_sync_enabled && from_player != null) { - gate_timeout_id = GLib.Timeout.add (beat_poll_ms, () => { - elapsed_ms += beat_poll_ms; - if (is_on_beat (from_player.last_rms_db) || elapsed_ms >= beat_window_ms) { - begin_fade (); - gate_timeout_id = 0; - return false; - } - return true; - }); - } else { - begin_fade (); + this.from_player = null; + this.to_player = to_player; + this.target_volume = target_volume; + if (duration_ms != null) { + this.duration_ms = duration_ms; + } + this.interval_ms = interval_ms; + this.elapsed_ms = 0; + + active_kind = TransitionKind.FADE_IN; + active_from_player = null; + active_to_player = to_player; + active_from_volume_start = 0.0; + active_to_volume_start = 0.0; + + to_player.set_volume_level (0.0); + if (fade_in_mode == FadeInMode.PREROLL_AUDIO_READY) { + to_player.prepare (); + } + start_fade_in_gate (); } - } - private void begin_fade () { - elapsed_ms = 0; - in_transition = true; - to_player.play (); - if (loudness_trim_enabled) { - if (from_player == null) { - // Avoid a perceived jump on fade-in by applying trim after fade completes. - apply_trim_after_fade = true; - pending_trim_to_db = compute_trim_db (to_player.last_rms_db); - to_player.set_trim_db_level (0.0); - } else { - to_player.set_trim_db_level (compute_trim_db (to_player.last_rms_db)); - from_player.set_trim_db_level (compute_trim_db (from_player.last_rms_db)); + public void fade_out (GstStreamPlayer from_player, double start_volume, uint? duration_ms = null, uint interval_ms = 50) { + if (!prepare_transition (TransitionRequest.fade_out (from_player, start_volume, duration_ms, interval_ms))) { + return; } - } else { - to_player.set_trim_db_level (0.0); - if (from_player != null) { - from_player.set_trim_db_level (0.0); + + this.from_player = from_player; + this.to_player = null; + this.target_volume = start_volume; + if (duration_ms != null) { + this.duration_ms = duration_ms; } + this.interval_ms = interval_ms; + this.elapsed_ms = 0; + + active_kind = TransitionKind.FADE_OUT; + active_from_player = from_player; + active_to_player = null; + active_from_volume_start = start_volume; + active_to_volume_start = 0.0; + start_fade_out (); } - if (this.duration_ms == 0) { - to_player.set_volume_level (target_volume); - finalize_fade (); - return; + private double apply_curve (double t) { + if (t <= 0.0) { + return 0.0; + } + if (t >= 1.0) { + return 1.0; + } + switch (curve) { + case FadeCurve.EXPONENTIAL: + return t * t; + case FadeCurve.LOGARITHMIC: + return Math.sqrt (t); + case FadeCurve.SMOOTHSTEP: + return t * t * (3.0 - 2.0 * t); + case FadeCurve.EQUAL_POWER: + return Math.sin (t * Math.PI / 2.0); + case FadeCurve.LINEAR: + default: + return t; + } } - timeout_id = GLib.Timeout.add (interval_ms, () => { - // Ramp based on elapsed time and selected curve. - elapsed_ms += interval_ms; - double progress = (double) elapsed_ms / (double) duration_ms; - if (progress > 1.0) { - progress = 1.0; + private void start_crossfade () { + in_transition = true; + to_player.set_volume_level (0.0); + if (preroll_enabled) { + to_player.prepare (); } - double curved = apply_curve (progress); - - double to_volume = target_volume * curved; - double from_volume = active_from_volume_start * (1.0 - curved); - if (from_player == null) { - from_volume = 0.0; + if (silence_gate_enabled && from_player != null) { + gate_timeout_id = GLib.Timeout.add (interval_ms, () => { + elapsed_ms += interval_ms; + if (from_player.last_rms_db <= silence_threshold_db || elapsed_ms >= silence_gate_timeout_ms) { + begin_fade (); + gate_timeout_id = 0; + return false; + } + return true; + }); + } else if (beat_sync_enabled && from_player != null) { + gate_timeout_id = GLib.Timeout.add (beat_poll_ms, () => { + elapsed_ms += beat_poll_ms; + if (is_on_beat (from_player.last_rms_db) || elapsed_ms >= beat_window_ms) { + begin_fade (); + gate_timeout_id = 0; + return false; + } + return true; + }); + } else { + begin_fade (); } + } - if (limiter_enabled && from_player != null) { - double sum = to_volume + from_volume; - if (sum > 1.0) { - double scale = 1.0 / sum; - to_volume *= scale; - from_volume *= scale; + private void begin_fade () { + elapsed_ms = 0; + in_transition = true; + to_player.play (); + if (loudness_trim_enabled) { + if (from_player == null) { + // Avoid a perceived jump on fade-in by applying trim after fade completes. + apply_trim_after_fade = true; + pending_trim_to_db = compute_trim_db (to_player.last_rms_db); + to_player.set_trim_db_level (0.0); + } else { + to_player.set_trim_db_level (compute_trim_db (to_player.last_rms_db)); + from_player.set_trim_db_level (compute_trim_db (from_player.last_rms_db)); + } + } else { + to_player.set_trim_db_level (0.0); + if (from_player != null) { + from_player.set_trim_db_level (0.0); } } - to_player.set_volume_level (to_volume); - if (from_player != null) { - from_player.set_volume_level (from_volume); - } - - if (progress >= 1.0) { + if (this.duration_ms == 0) { + to_player.set_volume_level (target_volume); finalize_fade (); - timeout_id = 0; - return false; + return; } - return true; - }); - } - private void start_fade_in_gate () { - elapsed_ms = 0; - in_transition = true; + timeout_id = GLib.Timeout.add (interval_ms, () => { + // Ramp based on elapsed time and selected curve. + elapsed_ms += interval_ms; + double progress = (double) elapsed_ms / (double) duration_ms; + if (progress > 1.0) { + progress = 1.0; + } - if (fade_in_mode == FadeInMode.UNBUFFERED) { - begin_fade (); - return; - } + double curved = apply_curve (progress); - to_player.play (); + double to_volume = target_volume * curved; + double from_volume = active_from_volume_start * (1.0 - curved); + if (from_player == null) { + from_volume = 0.0; + } - gate_timeout_id = GLib.Timeout.add (interval_ms, () => { - elapsed_ms += interval_ms; + if (limiter_enabled && from_player != null) { + double sum = to_volume + from_volume; + if (sum > 1.0) { + double scale = 1.0 / sum; + to_volume *= scale; + from_volume *= scale; + } + } - bool ready = false; - switch (fade_in_mode) { - case FadeInMode.PLAYING_READY: - ready = (to_player.play_state == Player.State.PLAYING); - break; - case FadeInMode.AUDIO_READY: - case FadeInMode.PREROLL_AUDIO_READY: - ready = (to_player.last_rms_db > silence_threshold_db); - break; - case FadeInMode.UNBUFFERED: - default: - ready = true; - break; - } + to_player.set_volume_level (to_volume); + if (from_player != null) { + from_player.set_volume_level (from_volume); + } - if (ready || elapsed_ms >= fade_in_timeout_ms) { - gate_timeout_id = 0; + if (progress >= 1.0) { + finalize_fade (); + timeout_id = 0; + return false; + } + return true; + }); + } + + private void start_fade_in_gate () { + elapsed_ms = 0; + in_transition = true; + + if (fade_in_mode == FadeInMode.UNBUFFERED) { begin_fade (); - return false; + return; } - return true; - }); - } - private void start_fade_out () { - in_transition = true; - if (this.duration_ms == 0) { - from_player.set_volume_level (0.0); - from_player.stop (); - fade_completed (from_player); - in_transition = false; - consume_queued_request (); - return; - } - timeout_id = GLib.Timeout.add (interval_ms, () => { - elapsed_ms += interval_ms; - double progress = (double) elapsed_ms / (double) duration_ms; - if (progress > 1.0) { - progress = 1.0; - } - double curved = apply_curve (progress); - double from_volume = target_volume * (1.0 - curved); - from_player.set_volume_level (from_volume); + to_player.play (); + + gate_timeout_id = GLib.Timeout.add (interval_ms, () => { + elapsed_ms += interval_ms; + + bool ready = false; + switch (fade_in_mode) { + case FadeInMode.PLAYING_READY: + ready = (to_player.play_state == StreamPlayer.State.PLAYING); + break; + case FadeInMode.AUDIO_READY: + case FadeInMode.PREROLL_AUDIO_READY: + ready = (to_player.last_rms_db > silence_threshold_db); + break; + case FadeInMode.UNBUFFERED: + default: + ready = true; + break; + } + + if (ready || elapsed_ms >= fade_in_timeout_ms) { + gate_timeout_id = 0; + begin_fade (); + return false; + } + return true; + }); + } - if (progress >= 1.0) { + private void start_fade_out () { + in_transition = true; + if (this.duration_ms == 0) { + from_player.set_volume_level (0.0); from_player.stop (); fade_completed (from_player); in_transition = false; consume_queued_request (); - timeout_id = 0; - return false; + return; } - return true; - }); - } + timeout_id = GLib.Timeout.add (interval_ms, () => { + elapsed_ms += interval_ms; + double progress = (double) elapsed_ms / (double) duration_ms; + if (progress > 1.0) { + progress = 1.0; + } + double curved = apply_curve (progress); + double from_volume = target_volume * (1.0 - curved); + from_player.set_volume_level (from_volume); - private void finalize_fade () { - if (from_player == null) { - if (to_player != null) { - if (apply_trim_after_fade) { - to_player.set_trim_db_level (pending_trim_to_db); - apply_trim_after_fade = false; + if (progress >= 1.0) { + from_player.stop (); + fade_completed (from_player); + in_transition = false; + consume_queued_request (); + timeout_id = 0; + return false; } - fade_completed (to_player); - in_transition = false; - consume_queued_request (); - } - return; + return true; + }); } - if (tail_enabled) { - from_player.set_volume_level (target_volume * tail_level); - tail_timeout_id = GLib.Timeout.add (tail_duration_ms, () => { + + private void finalize_fade () { + if (from_player == null) { + if (to_player != null) { + if (apply_trim_after_fade) { + to_player.set_trim_db_level (pending_trim_to_db); + apply_trim_after_fade = false; + } + fade_completed (to_player); + in_transition = false; + consume_queued_request (); + } + return; + } + if (tail_enabled) { + from_player.set_volume_level (target_volume * tail_level); + tail_timeout_id = GLib.Timeout.add (tail_duration_ms, () => { + from_player.stop (); + fade_completed (from_player); + in_transition = false; + consume_queued_request (); + tail_timeout_id = 0; + return false; + }); + } else { from_player.stop (); fade_completed (from_player); in_transition = false; consume_queued_request (); - tail_timeout_id = 0; - return false; - }); - } else { - from_player.stop (); - fade_completed (from_player); - in_transition = false; - consume_queued_request (); + } } - } - - private bool is_on_beat (double rms_db) { - // Simple energy gate: treat strong RMS spikes as beat candidates. - return rms_db > -18.0; - } - private double compute_trim_db (double rms_db) { - if (rms_db <= -90.0) { - return 0.0; - } - double delta = target_rms_db - rms_db; - if (delta > max_trim_db) { - return max_trim_db; + private bool is_on_beat (double rms_db) { + // Simple energy gate: treat strong RMS spikes as beat candidates. + return rms_db > -18.0; } - if (delta < -max_trim_db) { - return -max_trim_db; - } - return delta; - } - public void cancel () { - // Stop any active fade. - if (timeout_id != 0) { - GLib.Source.remove (timeout_id); - timeout_id = 0; - } - if (tail_timeout_id != 0) { - GLib.Source.remove (tail_timeout_id); - tail_timeout_id = 0; - } - if (gate_timeout_id != 0) { - GLib.Source.remove (gate_timeout_id); - gate_timeout_id = 0; + private double compute_trim_db (double rms_db) { + if (rms_db <= -90.0) { + return 0.0; + } + double delta = target_rms_db - rms_db; + if (delta > max_trim_db) { + return max_trim_db; + } + if (delta < -max_trim_db) { + return -max_trim_db; + } + return delta; } - in_transition = false; - queued_request = null; - } - public void cancel_and_stop (GstStreamPlayer? keep_player) { - // Cancel any transition and stop any non-kept active players. - cancel (); - if (active_from_player != null && active_from_player != keep_player) { - active_from_player.stop (); - } - if (active_to_player != null && active_to_player != keep_player) { - active_to_player.stop (); + public void cancel () { + // Stop any active fade. + if (timeout_id != 0) { + GLib.Source.remove (timeout_id); + timeout_id = 0; + } + if (tail_timeout_id != 0) { + GLib.Source.remove (tail_timeout_id); + tail_timeout_id = 0; + } + if (gate_timeout_id != 0) { + GLib.Source.remove (gate_timeout_id); + gate_timeout_id = 0; + } + in_transition = false; + queued_request = null; } - } - private bool prepare_transition (TransitionRequest request) { - if (!in_transition) { + public void cancel_and_stop (GstStreamPlayer? keep_player) { + // Cancel any transition and stop any non-kept active players. cancel (); - return true; + if (active_from_player != null && active_from_player != keep_player) { + active_from_player.stop (); + } + if (active_to_player != null && active_to_player != keep_player) { + active_to_player.stop (); + } } - switch (transition_policy) { - case TransitionPolicy.IGNORE_DURING_FADE: - return false; - case TransitionPolicy.CANCEL_AND_START: - cancel (); - return true; - case TransitionPolicy.QUEUE_LATEST: - default: - queued_request = request; - return false; + + private bool prepare_transition (TransitionRequest request) { + if (!in_transition) { + cancel (); + return true; + } + switch (transition_policy) { + case TransitionPolicy.IGNORE_DURING_FADE: + return false; + case TransitionPolicy.CANCEL_AND_START: + cancel (); + return true; + case TransitionPolicy.QUEUE_LATEST: + default: + queued_request = request; + return false; + } } - } - private void consume_queued_request () { - if (queued_request == null) { - return; - } - var req = queued_request; - queued_request = null; - - switch (req.kind) { - case TransitionKind.CROSSFADE: - crossfade (req.from_player, req.to_player, req.target_volume, req.has_duration ? req.duration_ms : (uint?) null, req.interval_ms); - break; - case TransitionKind.FADE_IN: - fade_in (req.to_player, req.target_volume, req.has_duration ? req.duration_ms : (uint?) null, req.interval_ms); - break; - case TransitionKind.FADE_OUT: - fade_out (req.from_player, req.target_volume, req.has_duration ? req.duration_ms : (uint?) null, req.interval_ms); - break; - default: - break; + private void consume_queued_request () { + if (queued_request == null) { + return; + } + var req = queued_request; + queued_request = null; + + switch (req.kind) { + case TransitionKind.CROSSFADE: + crossfade (req.from_player, req.to_player, req.target_volume, req.has_duration ? req.duration_ms : (uint?) null, req.interval_ms); + break; + case TransitionKind.FADE_IN: + fade_in (req.to_player, req.target_volume, req.has_duration ? req.duration_ms : (uint?) null, req.interval_ms); + break; + case TransitionKind.FADE_OUT: + fade_out (req.from_player, req.target_volume, req.has_duration ? req.duration_ms : (uint?) null, req.interval_ms); + break; + default: + break; + } } } -} - -private enum TransitionKind { - CROSSFADE, - FADE_IN, - FADE_OUT -} - -private class TransitionRequest : GLib.Object { - public TransitionKind kind { get; construct; } - public GstStreamPlayer? from_player { get; construct; } - public GstStreamPlayer? to_player { get; construct; } - public double target_volume { get; construct; } - public uint duration_ms { get; construct; } - public bool has_duration { get; construct; } - public uint interval_ms { get; construct; } - - private TransitionRequest (TransitionKind kind, GstStreamPlayer? from_player, GstStreamPlayer? to_player, double target_volume, uint? duration_ms, uint interval_ms) { - Object ( - kind: kind, - from_player: from_player, - to_player: to_player, - target_volume: target_volume, - duration_ms: duration_ms != null ? duration_ms : 0, - has_duration: duration_ms != null, - interval_ms: interval_ms - ); - } - public static TransitionRequest crossfade (GstStreamPlayer? from_player, GstStreamPlayer to_player, double target_volume, uint? duration_ms, uint interval_ms) { - return new TransitionRequest (TransitionKind.CROSSFADE, from_player, to_player, target_volume, duration_ms, interval_ms); - } + private enum TransitionKind { + CROSSFADE, + FADE_IN, + FADE_OUT + } + + private class TransitionRequest : GLib.Object { + public TransitionKind kind { get; construct; } + public GstStreamPlayer? from_player { get; construct; } + public GstStreamPlayer? to_player { get; construct; } + public double target_volume { get; construct; } + public uint duration_ms { get; construct; } + public bool has_duration { get; construct; } + public uint interval_ms { get; construct; } + + private TransitionRequest (TransitionKind kind, GstStreamPlayer? from_player, GstStreamPlayer? to_player, double target_volume, uint? duration_ms, uint interval_ms) { + Object ( + kind: kind, + from_player: from_player, + to_player: to_player, + target_volume: target_volume, + duration_ms: duration_ms != null ? duration_ms : 0, + has_duration: duration_ms != null, + interval_ms: interval_ms + ); + } - public static TransitionRequest fade_in (GstStreamPlayer to_player, double target_volume, uint? duration_ms, uint interval_ms) { - return new TransitionRequest (TransitionKind.FADE_IN, null, to_player, target_volume, duration_ms, interval_ms); - } + public static TransitionRequest crossfade (GstStreamPlayer? from_player, GstStreamPlayer to_player, double target_volume, uint? duration_ms, uint interval_ms) { + return new TransitionRequest (TransitionKind.CROSSFADE, from_player, to_player, target_volume, duration_ms, interval_ms); + } - public static TransitionRequest fade_out (GstStreamPlayer from_player, double target_volume, uint? duration_ms, uint interval_ms) { - return new TransitionRequest (TransitionKind.FADE_OUT, from_player, null, target_volume, duration_ms, interval_ms); - } -} + public static TransitionRequest fade_in (GstStreamPlayer to_player, double target_volume, uint? duration_ms, uint interval_ms) { + return new TransitionRequest (TransitionKind.FADE_IN, null, to_player, target_volume, duration_ms, interval_ms); + } + + public static TransitionRequest fade_out (GstStreamPlayer from_player, double target_volume, uint? duration_ms, uint interval_ms) { + return new TransitionRequest (TransitionKind.FADE_OUT, from_player, null, target_volume, duration_ms, interval_ms); + } + } // TransitionRequest +} // Ext \ No newline at end of file diff --git a/src/Ext/GstStreamPlayer.vala b/src/Ext/GstStreamPlayer.vala index a3ef63a6..218b61d3 100644 --- a/src/Ext/GstStreamPlayer.vala +++ b/src/Ext/GstStreamPlayer.vala @@ -7,6 +7,7 @@ */ using Gst; +using Tuner.Models; namespace Tuner.Ext { @@ -17,14 +18,19 @@ namespace Tuner.Ext * Wraps a `playbin` pipeline, translates GStreamer state into the app-level * state enum, and emits metadata and error signals. */ - public class GstStreamPlayer : GLib.Object, Player + public class GstStreamPlayer : GLib.Object, StreamPlayer { + private static GstFader? shared_fader; + private static uint default_fade_duration_ms = 1500; + private static uint default_fade_interval_ms = 50; + private static GstFader.FadeCurve default_fade_curve = GstFader.FadeCurve.LINEAR; + /** @brief Stream URL configured at construction time. */ public string stream_url { get; construct; } /** @brief App-level state derived from the GStreamer pipeline. */ - private Player.State _play_state = Player.State.STOPPED; - public Player.State play_state { get { return _play_state; } } + private StreamPlayer.State _play_state = StreamPlayer.State.STOPPED; + public StreamPlayer.State play_state { get { return _play_state; } } /** @brief Latest string metadata from the stream. */ private GLib.HashTable _metadata; @@ -43,6 +49,20 @@ namespace Tuner.Ext private dynamic Element playbin; private dynamic Element level; + /** + * @brief Update default crossfade settings for all players. + * + * These defaults are used by `crossfade_to` unless overridden later. + */ + public static void set_crossfade_defaults (uint duration_ms, uint interval_ms = 50, GstFader.FadeCurve curve = GstFader.FadeCurve.LINEAR) + { + default_fade_duration_ms = duration_ms; + default_fade_interval_ms = interval_ms; + default_fade_curve = curve; + if (shared_fader != null) + apply_fade_defaults (shared_fader); + } // set_crossfade_defaults + /** * @brief Create a GStreamer-backed stream player. * @@ -78,11 +98,11 @@ namespace Tuner.Ext message.parse_error (out err, out debug); stdout.printf ("Error: %s\n", err.message); error_sig (err.message); - set_play_state (Player.State.STOPPED); + set_play_state (StreamPlayer.State.STOPPED); break; case MessageType.EOS: stdout.printf ("end of stream\n"); - set_play_state (Player.State.STOPPED); + set_play_state (StreamPlayer.State.STOPPED); break; case MessageType.STATE_CHANGED: Gst.State oldstate; @@ -151,21 +171,42 @@ namespace Tuner.Ext public void play () { playbin.set_state (Gst.State.PLAYING); - set_play_state (Player.State.PLAYING); + set_play_state (StreamPlayer.State.PLAYING); } /** @brief Stop playback and reset the pipeline. */ public void stop () { playbin.set_state (Gst.State.NULL); - set_play_state (Player.State.STOPPED); + set_play_state (StreamPlayer.State.STOPPED); + } + + /** + * @brief Crossfade from this stream to the next stream. + * + * @param next_player The next player instance to transition to. + * @param target_volume Final volume for the next player (0.0 - 1.0). + */ + public void crossfade_to (StreamPlayer next_player, double target_volume) + { + var next_gst = next_player as GstStreamPlayer; + if (next_gst == null) + { + stop (); + next_player.set_volume_level (target_volume); + next_player.play (); + return; + } + + var fader = get_fader (); + fader.crossfade (this, next_gst, target_volume, default_fade_duration_ms, default_fade_interval_ms); } /** @brief Pre-roll the pipeline without output. */ public void prepare () { playbin.set_state (Gst.State.PAUSED); - set_play_state (Player.State.PAUSED); + set_play_state (StreamPlayer.State.PAUSED); } /** @@ -217,6 +258,22 @@ namespace Tuner.Ext return adjusted; } + private static GstFader get_fader () + { + if (shared_fader == null) + { + shared_fader = new GstFader (); + apply_fade_defaults (shared_fader); + } + return shared_fader; + } // get_fader + + private static void apply_fade_defaults (GstFader fader) + { + fader.set_duration_ms (default_fade_duration_ms); + fader.set_curve (default_fade_curve); + } // apply_fade_defaults + /** * @brief Configure RMS monitoring via the `level` element. */ @@ -235,7 +292,7 @@ namespace Tuner.Ext * * @param state New app-level state. */ - private void set_play_state (Player.State state) + private void set_play_state (StreamPlayer.State state) { if (_play_state == state) return; @@ -259,17 +316,17 @@ namespace Tuner.Ext * @param state GStreamer state. * @return App-level state. */ - private Player.State map_play_state (Gst.State state) + private StreamPlayer.State map_play_state (Gst.State state) { switch (state) { case Gst.State.PLAYING: - return Player.State.PLAYING; + return StreamPlayer.State.PLAYING; case Gst.State.PAUSED: - return Player.State.PAUSED; + return StreamPlayer.State.PAUSED; case Gst.State.READY: - return Player.State.BUFFERING; + return StreamPlayer.State.BUFFERING; default: - return Player.State.STOPPED; + return StreamPlayer.State.STOPPED; } } // map_play_state } // GstStreamPlayer diff --git a/src/Models/Player.vala b/src/Models/StreamPlayer.vala similarity index 58% rename from src/Models/Player.vala rename to src/Models/StreamPlayer.vala index 3de378dc..11a2ae3c 100644 --- a/src/Models/Player.vala +++ b/src/Models/StreamPlayer.vala @@ -3,12 +3,12 @@ * * SPDX-License-Identifier: GPL-3.0-or-later * - * @file PlayerInterface.vala + * @file StreamPlayer.vala */ namespace Tuner.Models{ - public interface Player : GLib.Object + public interface StreamPlayer : GLib.Object { public enum State { @@ -30,6 +30,18 @@ namespace Tuner.Models{ public abstract void play (); public abstract void stop (); public abstract void set_volume_level (double volume); + + /** + * @brief Transition from the current stream to another stream. + * + * Implementations may crossfade when supported. If fading is not + * available, implementations may fallback to an immediate stop of + * the current stream and start of the next stream. + * + * @param next_player The next player instance to transition to. + * @param target_volume Final volume for the next player (0.0 - 1.0). + */ + public abstract void crossfade_to (StreamPlayer next_player, double target_volume); } // PlayerInterface } // namespace diff --git a/src/Services/DBusMediaPlayer.vala b/src/Services/DBusMediaPlayer.vala index 38063862..39cf16f3 100644 --- a/src/Services/DBusMediaPlayer.vala +++ b/src/Services/DBusMediaPlayer.vala @@ -192,11 +192,11 @@ namespace Tuner.Services.DBus { switch (state) { - case Player.State.PLAYING: - case Player.State.BUFFERING: + case StreamPlayer.State.PLAYING: + case StreamPlayer.State.BUFFERING: playback_status = "Playing"; break; - case Player.State.PAUSED: + case StreamPlayer.State.PAUSED: playback_status = "Paused"; break; default: diff --git a/src/Utils.vala b/src/Utils.vala index 45b2ce04..02eb04cd 100644 --- a/src/Utils.vala +++ b/src/Utils.vala @@ -9,6 +9,8 @@ */ using GLib; + using Tuner.Models; + using Tuner.Ext; /** * @namespace Tuner @@ -153,4 +155,18 @@ namespace Tuner { return text._strip(); } // safestrip -} // namespace Tuner \ No newline at end of file + + /** + * @brief Create a stream player for the given URL. + * + * Centralizes the selection of the concrete StreamPlayer implementation. + * + * @param stream_url Stream URL to play. + * @return A StreamPlayer instance for the requested URL. + */ + public static StreamPlayer create_stream_player (string stream_url) + { + return new GstStreamPlayer (stream_url); + } // create_stream_player + +} // namespace Tuner diff --git a/src/Widgets/Display.vala b/src/Widgets/Display.vala index aaf84cbe..f884767b 100644 --- a/src/Widgets/Display.vala +++ b/src/Widgets/Display.vala @@ -191,7 +191,7 @@ public class Tuner.Widgets.Display : Gtk.Paned, StationListHookup { _app.events.state_changed_sig.connect((station, state) => { - if (_shuffle && state == Player.State.STOPPED_ERROR) + if (_shuffle && state == StreamPlayer.State.STOPPED_ERROR) { Timeout.add(HeaderBar.SHUFFLE_ERROR_RETRY_DELAY_MS, () => { diff --git a/src/Widgets/HeaderBar.vala b/src/Widgets/HeaderBar.vala index 2575cc11..00c5d8df 100644 --- a/src/Widgets/HeaderBar.vala +++ b/src/Widgets/HeaderBar.vala @@ -326,7 +326,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar */ public bool update_playing_station(Station station) { - if ( _app.is_offline || ( _station != null && _station == station && _player.player_state != Player.State.STOPPED_ERROR ) ) + if ( _app.is_offline || ( _station != null && _station == station && _player.player_state != StreamPlayer.State.STOPPED_ERROR ) ) return false; if (_station_update_lock.trylock()) @@ -414,8 +414,8 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar */ private void update_controls_state() { - bool is_playing_now = _player.player_state == Player.State.PLAYING - || _player.player_state == Player.State.BUFFERING; + bool is_playing_now = _player.player_state == StreamPlayer.State.PLAYING + || _player.player_state == StreamPlayer.State.BUFFERING; if (_app.is_offline) { diff --git a/src/Widgets/PlayButton.vala b/src/Widgets/PlayButton.vala index d25fbdb2..226f4831 100644 --- a/src/Widgets/PlayButton.vala +++ b/src/Widgets/PlayButton.vala @@ -12,7 +12,7 @@ using Gtk; using Tuner.Controllers; -using Tuner.Ext; +using Tuner.Models; /** * @class PlayButton @@ -81,21 +81,21 @@ public class Tuner.Widgets.PlayButton : Gtk.Button * * @param state The new play state enum. */ - private void set_inverse_symbol (Player.State state) + private void set_inverse_symbol (StreamPlayer.State state) { switch (state) { - case Player.State.PLAYING: + case StreamPlayer.State.PLAYING: image = STOP; image.opacity = 1.0; break; - case Player.State.BUFFERING: + case StreamPlayer.State.BUFFERING: image = BUFFERING; image.opacity = 0.5; break; - case Player.State.STOPPED_ERROR: + case StreamPlayer.State.STOPPED_ERROR: image = ERROR; image.opacity = 0.5; break; diff --git a/src/Widgets/Window.vala b/src/Widgets/Window.vala index ca86f0c7..b5c108a3 100644 --- a/src/Widgets/Window.vala +++ b/src/Widgets/Window.vala @@ -492,7 +492,7 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow get_size (out _width, out _height); // Echo ending dimensions so Settings can pick them up _settings.save (); - if (player_ctrl.player_state == Player.State.PLAYING) { + if (player_ctrl.player_state == StreamPlayer.State.PLAYING) { hide_on_delete(); var notification = new GLib.Notification(NOTIFICATION_PLAYING_BACKGROUND); notification.set_body(NOTIFICATION_CLICK_RESUME); diff --git a/src/meson.build b/src/meson.build index fe31b02b..1634cb22 100644 --- a/src/meson.build +++ b/src/meson.build @@ -9,7 +9,6 @@ sources = files ( 'Events/AppEventBus.vala', 'Ext/GstFader.vala', - 'Ext/PlayerInterface.vala', 'Ext/GstStreamPlayer.vala', 'Coordinators/PlaybackRecoveryCoordinator.vala', @@ -27,6 +26,7 @@ sources = files ( 'Models/Genre.vala', 'Models/Station.vala', 'Models/Favicon.vala', + 'Models/StreamPlayer.vala', 'Models/StreamMetadata.vala', 'Models/StationListBoxConfig.vala', 'Models/StationListBoxPager.vala', From d637f3a72d20250d94ba5ea83f8cd0f8470e2f8d Mon Sep 17 00:00:00 2001 From: technosf Date: Sun, 12 Apr 2026 06:46:16 -0700 Subject: [PATCH 5/6] Header transistion smooth --- src/Widgets/base/PlayerInfo.vala | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/Widgets/base/PlayerInfo.vala b/src/Widgets/base/PlayerInfo.vala index 31ea99cc..bc61bb91 100644 --- a/src/Widgets/base/PlayerInfo.vala +++ b/src/Widgets/base/PlayerInfo.vala @@ -44,6 +44,9 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer private Gtk.Label _metadata_label; private uint _hover_timeout_id = 0; private bool _popover_visible = false; + private bool _transitioning = false; + private Station? _pending_station = null; + private Metadata? _pending_metadata = null; internal signal void info_changed_completed_sig(); @@ -136,6 +139,9 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer { hide_metadata_popover(); reveal_child = false; + _transitioning = true; + _pending_station = station; + _pending_metadata = null; Idle.add(() => { @@ -162,6 +168,14 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer reveal_child = true; title_label.cycle(); + _transitioning = false; + if (_pending_metadata != null) + { + apply_metadata(_pending_metadata); + _pending_metadata = null; + _pending_station = null; + } + info_changed_completed_sig(); } ); @@ -178,9 +192,24 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer */ public void handle_metadata_changed(Station station, Metadata metadata) { + if (_transitioning) + { + if (_pending_station != null && station == _pending_station) + _pending_metadata = metadata; + return; + } + + if (_station != null && station != _station) + return; + if (_metadata == metadata.pretty_print) return; + apply_metadata(metadata); + } + + private void apply_metadata(Metadata metadata) + { _metadata = metadata.pretty_print; if (_metadata == "") @@ -206,6 +235,7 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer update_metadata_popover_text(); } + private void show_metadata_popover() { if (_station == null) From 8fba958518442dff32c9ca24ecb10bbd70f07934 Mon Sep 17 00:00:00 2001 From: technosf Date: Sun, 12 Apr 2026 06:49:13 -0700 Subject: [PATCH 6/6] Doc --- src/Widgets/base/PlayerInfo.vala | 57 ++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/src/Widgets/base/PlayerInfo.vala b/src/Widgets/base/PlayerInfo.vala index bc61bb91..9e1a6895 100644 --- a/src/Widgets/base/PlayerInfo.vala +++ b/src/Widgets/base/PlayerInfo.vala @@ -16,8 +16,12 @@ using Gdk; using Tuner.Controllers; using Tuner.Models; -/** - * PlayerInfo widget for displaying station and track information. +/** + * @class Tuner.Widgets.Base.PlayerInfo + * @brief Displays station name, artwork, and stream metadata. + * + * Provides a reveal-based transition when stations change and exposes + * helper hooks for metadata updates and popover display. */ public class Tuner.Widgets.Base.PlayerInfo : Revealer { @@ -26,12 +30,18 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer private const uint STATION_CHANGE_SETTLE_DELAY_MS = 1200u; private const string STREAM_METADATA = _("Stream Metadata"); + /** Station name label. */ public Label station_label { get; private set; } + /** Cycling label that displays the current track metadata. */ public CyclingRevealLabel title_label { get; private set; } //public StationContextMenu menu { get; private set; } + /** Favicon image for the current station. */ public Image favicon_image = new Image.from_icon_name(DEFAULT_ICON_NAME, IconSize.DIALOG); + /** + * @brief Raw metadata string used by the popover and fallback display. + */ public string metadata { get { return _metadata; } internal set { _metadata = value; } @@ -48,13 +58,16 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer private Station? _pending_station = null; private Metadata? _pending_metadata = null; + /** + * @brief Emitted after station transition visuals complete. + */ internal signal void info_changed_completed_sig(); /** - * Creates a new PlayerInfo widget. + * @brief Creates a new PlayerInfo widget. * - * @param window Parent window - * @param player Player controller + * @param window Parent window hosting the widget. + * @param player Player controller. */ public PlayerInfo(Window window, PlayerController player) { @@ -133,7 +146,12 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer } /** - * Handles display transition when station changes. + * @brief Handles the display transition when a station changes. + * + * This clears the previous station display, waits a short settle interval, + * and then reveals the new station with a crossfade. + * + * @param station The new station to display. */ internal async void change_station(Station station) { @@ -188,7 +206,12 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer } /** - * Handles metadata updates from the player. + * @brief Handles metadata updates from the player. + * + * Filters out updates that do not correspond to the active station. + * + * @param station Station that emitted the metadata. + * @param metadata Metadata payload. */ public void handle_metadata_changed(Station station, Metadata metadata) { @@ -208,6 +231,11 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer apply_metadata(metadata); } + /** + * @brief Applies a metadata payload to the UI. + * + * @param metadata Metadata payload. + */ private void apply_metadata(Metadata metadata) { _metadata = metadata.pretty_print; @@ -236,6 +264,9 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer } + /** + * @brief Shows the metadata popover for the current station. + */ private void show_metadata_popover() { if (_station == null) @@ -273,6 +304,9 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer _popover_visible = true; } + /** + * @brief Hides the metadata popover if visible. + */ private void hide_metadata_popover() { if (_metadata_popover != null) @@ -280,6 +314,9 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer _popover_visible = false; } + /** + * @brief Updates the metadata popover contents. + */ private void update_metadata_popover_text() { if (_metadata_label == null) @@ -289,6 +326,9 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer _metadata_label.set_text(text); } + /** + * @brief Copies the current metadata text to the clipboard. + */ private void copy_metadata_to_clipboard() { var popularity = _station != null ? _station.popularity() : ""; @@ -301,6 +341,9 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer } } + /** + * @brief Shows a short "Copied to clipboard" confirmation. + */ private void show_copy_confirmation() { if (_metadata_popover == null)