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..c467d734 100644 --- a/src/Controllers/PlayerController.vala +++ b/src/Controllers/PlayerController.vala @@ -7,7 +7,6 @@ * @file PlayerController.vala */ -using Gst; using Tuner.Models; /** @@ -20,133 +19,94 @@ 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 StreamPlayer? _player; private Station _station; private Metadata _metadata; - private Is _player_state; - private string _player_state_name; + private StreamPlayer.State _player_state = StreamPlayer.State.STOPPED; private uint _tape_counter_id = 0; + 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; 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 - /** - * @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 + /** + * @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 (string state) + private void set_play_state (StreamPlayer.State state) { + var player = _player; + if (player == null) + return; switch (state) { - case "playing": - Gdk.threads_add_idle (() => { - if (app().is_offline) + case StreamPlayer.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 = StreamPlayer.State.STOPPED; + break; } _play_error = false; - player_state = Is.PLAYING; - return false; - }); + player_state = StreamPlayer.State.PLAYING; + } break; - case "buffering": - Gdk.threads_add_idle (() => { - if (app().is_offline) + 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 = Is.STOPPED; - return false; + player.stop (); + player_state = StreamPlayer.State.STOPPED; + break; } _play_error = false; - player_state = Is.BUFFERING; - return false; - }); + player_state = StreamPlayer.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 = StreamPlayer.State.STOPPED_ERROR; } else { if (offline_or_lost_network) _play_error = false; - player_state = Is.STOPPED; + player_state = StreamPlayer.State.STOPPED; } - return false; - }); + } break; - } + } // switch } // set_reverse_symbol @@ -155,17 +115,18 @@ public class Tuner.Controllers.PlayerController : GLib.Object * * Set by player signal. Does the tape counter emit */ - public Is player_state { + public StreamPlayer.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 == StreamPlayer.State.STOPPED || value == StreamPlayer.State.STOPPED_ERROR) { if (_tape_counter_id > 0) { @@ -173,13 +134,14 @@ public class Tuner.Controllers.PlayerController : GLib.Object _tape_counter_id = 0; } } - else if (value == Is.PLAYING) + else if (value == StreamPlayer.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 +173,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 +194,30 @@ 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 (); + var previous_player = _player; + if (previous_player != null) + 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 (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 { - _player.play (); + if (_player != null) + _player.play (); return Source.REMOVE; }); } // play_station @@ -252,13 +238,15 @@ public class Tuner.Controllers.PlayerController : GLib.Object */ public void play_pause () { switch (_player_state) { - case Is.PLAYING: - case Is.BUFFERING: - _player.stop (); + case StreamPlayer.State.PLAYING: + case StreamPlayer.State.BUFFERING: + if (_player != null) + _player.stop (); break; default: _play_error = false; - _player.play (); + if (_player != null) + _player.play (); break; } } // play_pause @@ -269,8 +257,158 @@ public class Tuner.Controllers.PlayerController : GLib.Object * */ public void stop () { - _player.stop (); - } // stop + if (_player != null) + _player.stop (); + } // stop + + /** + * @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 = StreamPlayer.State.STOPPED; + _debounce_state_id = 0; + _play_error = false; + + _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); + + on_player_state_changed (_player.play_state); + on_player_metadata_changed (_player.metadata); + } // attach_player + + + /** + * @brief Disconnects the current player instance and clears controller state. + */ + private void detach_player () + { + cancel_state_debounce (); + if (_player != null) + { + _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 + + + /** + * @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 + + + /** + * @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 == StreamPlayer.State.PLAYING) + { + cancel_state_debounce (); + set_play_state (StreamPlayer.State.PLAYING); + return; + } + + if (state == StreamPlayer.State.BUFFERING + || state == StreamPlayer.State.PAUSED) + { + if (_player_state == StreamPlayer.State.PLAYING) + { + schedule_state_debounce (); + } + else + { + set_play_state (StreamPlayer.State.BUFFERING); + } + return; + } + + cancel_state_debounce (); + 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) + 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 + + + /** + * @brief Cancels any pending debounce timer. + */ + private void cancel_state_debounce () + { + if (_debounce_state_id > 0) + { + Source.remove (_debounce_state_id); + _debounce_state_id = 0; + } + } // 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) + return; + + if (_metadata.process_tag_table (metadata)) + { + var app_ref = app(); + if (app_ref != null) + app_ref.events.metadata_changed_sig (_station, _metadata); + } + } // 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 (StreamPlayer.State.STOPPED); + } // on_player_error /** diff --git a/src/Coordinators/PlaybackRecoveryCoordinator.vala b/src/Coordinators/PlaybackRecoveryCoordinator.vala index 5e6974d4..ac7cd0df 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 == 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; @@ -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 == StreamPlayer.State.PLAYING + || _player.player_state == StreamPlayer.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, StreamPlayer.State state) { - if (state == PlayerController.Is.PLAYING || state == PlayerController.Is.BUFFERING) + if (state == StreamPlayer.State.PLAYING || state == StreamPlayer.State.BUFFERING) _was_playing_before_offline = true; - if (_app.is_online && state == PlayerController.Is.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 8c0929b4..74091bbf 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, StreamPlayer.State state) { - if (_settings.do_not_vote || state != PlayerController.Is.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 4ea6ad3e..28bf18c4 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, 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 new file mode 100644 index 00000000..4635554c --- /dev/null +++ b/src/Ext/GstFader.vala @@ -0,0 +1,554 @@ +using Tuner.Models; + +namespace Tuner.Ext { + public class GstFader : 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 GstFader () { + } + + 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 (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; + } + + 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 (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; + } + + 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 (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; + } + + 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 == 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; + }); + } + + 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 (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 (); + } + } + + 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 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); + } + + 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 new file mode 100644 index 00000000..218b61d3 --- /dev/null +++ b/src/Ext/GstStreamPlayer.vala @@ -0,0 +1,333 @@ +/** + * SPDX-FileCopyrightText: Copyright © 2026 technosf + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @file GstStreamPlayer.vala + */ + +using Gst; +using Tuner.Models; + +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, 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 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; + public GLib.HashTable metadata { get { return _metadata; } } + + /** @brief Current output volume (0.0 - 1.0). */ + private double _volume = 0.5; + public double volume { get { return _volume; } } + + /** @brief Last observed RMS level in dB from the level element. */ + public double last_rms_db { get; private set; default = -100.0; } + + /** @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; + + /** + * @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. + * + * @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); + } + + /** + * @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; + string debug; + message.parse_error (out err, out debug); + stdout.printf ("Error: %s\n", err.message); + error_sig (err.message); + set_play_state (StreamPlayer.State.STOPPED); + break; + case MessageType.EOS: + stdout.printf ("end of stream\n"); + set_play_state (StreamPlayer.State.STOPPED); + 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); + stdout.printf ("state changed: %s->%s:%s\n", + oldstate.to_string (), newstate.to_string (), + pending.to_string ()); + break; + case MessageType.TAG: + stdout.printf ("taglist found\n"); + 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); + 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); + changed = true; + stdout.printf ("tag: %s = %s\n", tag, tag_string); + } + } + if (changed) + metadata_changed_sig (_metadata); + } + 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; + } + + /** @brief Start playback. */ + public void play () + { + playbin.set_state (Gst.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 (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 (StreamPlayer.State.PAUSED); + } + + /** + * @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) { + volume = 1.0; + } + _volume = volume; + playbin.volume = apply_trim (volume); + } + + /** + * @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); + } + + /** + * @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; + } + 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 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. + */ + private void setup_level_monitor () + { + 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); + } + } + + /** + * @brief Update and emit the app-level play state. + * + * @param state New app-level state. + */ + private void set_play_state (StreamPlayer.State state) + { + if (_play_state == state) + return; + _play_state = state; + state_changed_sig (_play_state); + } // set_play_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 + + /** + * @brief Translate GStreamer state into `PlayerInterface.State`. + * + * @param state GStreamer state. + * @return App-level state. + */ + private StreamPlayer.State map_play_state (Gst.State state) + { + switch (state) { + case Gst.State.PLAYING: + return StreamPlayer.State.PLAYING; + case Gst.State.PAUSED: + return StreamPlayer.State.PAUSED; + case Gst.State.READY: + return StreamPlayer.State.BUFFERING; + default: + return StreamPlayer.State.STOPPED; + } + } // map_play_state + } // GstStreamPlayer +} // namespace Tuner.Ext 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/Models/StreamPlayer.vala b/src/Models/StreamPlayer.vala new file mode 100644 index 00000000..11a2ae3c --- /dev/null +++ b/src/Models/StreamPlayer.vala @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: Copyright © 2026 technosf + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @file StreamPlayer.vala + */ + +namespace Tuner.Models{ + + public interface StreamPlayer : GLib.Object + { + public enum State + { + BUFFERING, + PAUSED, + PLAYING, + STOPPED, + STOPPED_ERROR + } // State + + 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); + + /** + * @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 fd37d4f3..39cf16f3 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 StreamPlayer.State.PLAYING: + case StreamPlayer.State.BUFFERING: playback_status = "Playing"; break; - case PlayerController.Is.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 881a1463..f884767b 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 == 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 2baf7955..00c5d8df 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 != StreamPlayer.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 == 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 6403fbaa..226f4831 100644 --- a/src/Widgets/PlayButton.vala +++ b/src/Widgets/PlayButton.vala @@ -12,6 +12,7 @@ using Gtk; using Tuner.Controllers; +using Tuner.Models; /** * @class PlayButton @@ -49,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(); @@ -67,34 +68,34 @@ 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 Gst.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. - */ - private void set_inverse_symbol (PlayerController.Is state) + } // 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 (StreamPlayer.State state) { switch (state) { - case PlayerController.Is.PLAYING: + case StreamPlayer.State.PLAYING: image = STOP; image.opacity = 1.0; break; - case PlayerController.Is.BUFFERING: + case StreamPlayer.State.BUFFERING: image = BUFFERING; image.opacity = 0.5; break; - case PlayerController.Is.STOPPED_ERROR: + case StreamPlayer.State.STOPPED_ERROR: image = ERROR; image.opacity = 0.5; break; @@ -103,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 diff --git a/src/Widgets/Window.vala b/src/Widgets/Window.vala index a4bc1815..b5c108a3 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 == StreamPlayer.State.PLAYING) { hide_on_delete(); var notification = new GLib.Notification(NOTIFICATION_PLAYING_BACKGROUND); notification.set_body(NOTIFICATION_CLICK_RESUME); diff --git a/src/Widgets/base/PlayerInfo.vala b/src/Widgets/base/PlayerInfo.vala index 31ea99cc..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; } @@ -44,14 +54,20 @@ 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; + /** + * @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) { @@ -130,12 +146,20 @@ 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) { hide_metadata_popover(); reveal_child = false; + _transitioning = true; + _pending_station = station; + _pending_metadata = null; Idle.add(() => { @@ -162,6 +186,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(); } ); @@ -174,13 +206,38 @@ 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) { + 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); + } + + /** + * @brief Applies a metadata payload to the UI. + * + * @param metadata Metadata payload. + */ + private void apply_metadata(Metadata metadata) + { _metadata = metadata.pretty_print; if (_metadata == "") @@ -206,6 +263,10 @@ public class Tuner.Widgets.Base.PlayerInfo : Revealer update_metadata_popover_text(); } + + /** + * @brief Shows the metadata popover for the current station. + */ private void show_metadata_popover() { if (_station == null) @@ -243,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) @@ -250,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) @@ -259,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() : ""; @@ -271,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) diff --git a/src/meson.build b/src/meson.build index 0c1baac2..1634cb22 100644 --- a/src/meson.build +++ b/src/meson.build @@ -7,6 +7,10 @@ # Listing files to compile sources = files ( 'Events/AppEventBus.vala', + + 'Ext/GstFader.vala', + 'Ext/GstStreamPlayer.vala', + 'Coordinators/PlaybackRecoveryCoordinator.vala', 'Coordinators/StartupCoordinator.vala', 'Coordinators/UsageTrackingCoordinator.vala', @@ -15,11 +19,14 @@ sources = files ( 'Controllers/PlayerController.vala', 'Controllers/SearchController.vala', + + # 'Models/CrossfadePlayer.vala', 'Models/Countries.vala', 'Models/Languages.vala', 'Models/Genre.vala', 'Models/Station.vala', 'Models/Favicon.vala', + 'Models/StreamPlayer.vala', 'Models/StreamMetadata.vala', 'Models/StationListBoxConfig.vala', 'Models/StationListBoxPager.vala',