diff --git a/.codex b/.codex new file mode 100644 index 00000000..e69de29b diff --git a/README.md b/README.md index ba76799c..0fca77ed 100644 --- a/README.md +++ b/README.md @@ -115,10 +115,11 @@ Help is appreciated: - [technosf](https://github.com/technosf) Current maintainer and rewriter of a swarth of Tuner for V2 - [louis77](https://github.com/louis77) Originator and genius behind Tuner -- [@jrthwlate](https://hosted.weblate.org/user/jrthwlate/) - Estonian translation +- [jrthwlate](https://hosted.weblate.org/user/jrthwlate/) - Estonian translation - [@yakushabb](https://github.com/yakushabb) for flathub and flatpak config help - [faleksandar.com](https://faleksandar.com/) for icons and colors - [@NathanBnm](https://github.com/NathanBnm) - French translation +- [David D.](https://hosted.weblate.org/user/dadu042/) - French translation - [@DevAlien](https://github.com/DevAlien) - Italian translation - [@albanobattistella](https://github.com/albanobattistella) - Italian translation - [@Vistaus](https://github.com/Vistaus) - Dutch translation diff --git a/data/css/Tuner-system.css b/data/css/Tuner-system.css index bcf870d2..2a6ffefa 100644 --- a/data/css/Tuner-system.css +++ b/data/css/Tuner-system.css @@ -16,8 +16,8 @@ */ .station-label, label.station-label { - font-weight: 500; - font-size: 12pt; + font-weight: 600; + font-size: 11.5pt; letter-spacing: -0.05em; color: @theme_text_color; } @@ -26,7 +26,7 @@ label.station-label { label.track-info { /* font-weight: 150; */ font-size: 11pt; - letter-spacing: -0.05em; + letter-spacing: -0.06em; color: @theme_text_color; } diff --git a/data/css/Tuner-theme.css b/data/css/Tuner-theme.css new file mode 100644 index 00000000..6b710c4b --- /dev/null +++ b/data/css/Tuner-theme.css @@ -0,0 +1,45 @@ +/* style.css — adaptive to light/dark */ + +window { + background-color: @theme_bg_color; + color: @theme_fg_color; +} + +entry, textview { + background-color: @theme_base_color; + color: @theme_text_color; + border-color: @theme_mid_color; +} + +selection { + background-color: @theme_selected_bg_color; + color: @theme_selected_fg_color; +} + +button { + background-color: @theme_button_background; + color: @theme_button_foreground; + border-radius: 4px; + border-width: 1px; + border-color: @theme_mid_color; + padding: 4px 8px; +} + +button:hover { + background-color: @theme_selected_bg_color; +} + +/* Headerbars */ +headerbar { + background-color: @theme_bg_color; + color: @theme_fg_color; +} + +/* Optional: dark-specific tweaks */ +@define-color dark_bg_color #2e3436; +@define-color dark_fg_color #eeeeec; + +window:dark { + background-color: @dark_bg_color; + color: @dark_fg_color; +} diff --git a/data/icons/hicolor/scalable/apps/io.github.tuner_labs.tuner-scalable.svg b/data/icons/hicolor/scalable/apps/io.github.tuner_labs.tuner-scalable.svg index 2ec7d2d9..519a1577 100644 --- a/data/icons/hicolor/scalable/apps/io.github.tuner_labs.tuner-scalable.svg +++ b/data/icons/hicolor/scalable/apps/io.github.tuner_labs.tuner-scalable.svg @@ -5,527 +5,476 @@ SPDX-FileCopyrightText: © 2026 SPDX-License-Identifier: GPL-3.0-or-later --> + image/svg+xml - + - - - - + - - - - + + + - + - - - - + + + - + - + - - - + + + - + - + - - - - + + + + - + - + - + - + - + - + - + - + - + - - + - + + - - - - - - + + + + - + - - + + - - - - + + - + - - + + - - - + + + + - - + - + - - + + - + - - + - - + - + - - + + - + - - + - - + - + - - + + - + - - + + + - - - + - + - - + + - + - - + - - - + - + - - + + - + - - + - - - + - + - - + + - + - - + + + + + + + + + + + diff --git a/data/io.github.tuner_labs.tuner.desktop.in b/data/io.github.tuner_labs.tuner.desktop.in index 6bfbcd03..b0f15c9e 100644 --- a/data/io.github.tuner_labs.tuner.desktop.in +++ b/data/io.github.tuner_labs.tuner.desktop.in @@ -6,11 +6,12 @@ [Desktop Entry] Type=Application Name=Tuner -GenericName=Internet Radio Player +GenericName=Internet streaming radio music player Comment=Listen to Radio Stations from around the world Categories=Tuner;AudioVideo;Audio;Player; +MimeType=x-scheme-handler/tuner; Exec=io.github.tuner_labs.tuner Icon=io.github.tuner_labs.tuner-scalable Terminal=false -Keywords=Radio;Receiver;FM;Talk;Sport;News -SingleMainWindow=true +Keywords=Radio;Receiver;FM;Music;Talk;Sport;News +SingleMainWindow=true \ No newline at end of file diff --git a/data/io.github.tuner_labs.tuner.gschema.xml b/data/io.github.tuner_labs.tuner.gschema.xml index fec3782e..81869db0 100644 --- a/data/io.github.tuner_labs.tuner.gschema.xml +++ b/data/io.github.tuner_labs.tuner.gschema.xml @@ -54,6 +54,11 @@ SPDX-License-Identifier: GPL-3.0-or-later Stream metadata image popup Show a movable popup with images discovered in the stream metadata + + false + Shrink metadata title dynamically + If enabled, the metadata title width expands during label cycling but only shrinks when a new metadata payload arrives. + [] Excluded Countries 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/io.github.tuner_labs.tuner.debug.yml b/io.github.tuner_labs.tuner.debug.yml index 5dd938d5..6274f49e 100644 --- a/io.github.tuner_labs.tuner.debug.yml +++ b/io.github.tuner_labs.tuner.debug.yml @@ -10,8 +10,6 @@ command: io.github.tuner_labs.tuner runtime: org.freedesktop.Platform runtime-version: '25.08' -extensions: - - org.gtk.Gtk3theme.Adwaita-dark sdk: org.freedesktop.Sdk @@ -31,6 +29,7 @@ finish-args: - --share=network - --socket=pulseaudio - --filesystem=xdg-desktop # Playlist import export + - --talk-name=org.freedesktop.portal.OpenURI # For opening URLs in the default browser cleanup: diff --git a/io.github.tuner_labs.tuner.yml b/io.github.tuner_labs.tuner.yml index 518cf6ea..15b85d9b 100644 --- a/io.github.tuner_labs.tuner.yml +++ b/io.github.tuner_labs.tuner.yml @@ -10,8 +10,6 @@ command: io.github.tuner_labs.tuner runtime: org.freedesktop.Platform runtime-version: '25.08' -extensions: - - org.gtk.Gtk3theme.Adwaita-dark sdk: org.freedesktop.Sdk diff --git a/meson.build b/meson.build index 9e1c0680..955f5581 100644 --- a/meson.build +++ b/meson.build @@ -112,9 +112,13 @@ dependencies = [ dependency ('gstreamer-1.0'), dependency ('gstreamer-player-1.0'), dependency ('libsoup-3.0'), - dependency ('json-glib-1.0') + dependency ('json-glib-1.0'), + dependency ('librsvg-2.0') ] +cc = meson.get_compiler('c') +dependencies += cc.find_library('m', required: true) + subdir ('src') ## diff --git a/po/application/application.pot b/po/application/application.pot index c3972b49..af583980 100644 --- a/po/application/application.pot +++ b/po/application/application.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: application\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-30 17:20-0700\n" +"POT-Creation-Date: 2026-04-19 20:37-0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,258 +17,267 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" +#. Count once per day +#. ---------------------------------------------------------- +#. Temporal Properties +#. ---------------------------------------------------------- #. Indicates if the station is in the provider index #. Indicates if the station is up-to-date with the provider index -#: src/Models/Station.vala:131 +#: src/Models/Station.vala:160 msgid "Station no longer in the index" msgstr "" -#: src/Models/Station.vala:355 +#: src/Models/Station.vala:402 +#, c-format +msgid "Last Played: %s\tPlays: %d" +msgstr "" + +#: src/Models/Station.vala:436 msgid "Changes" msgstr "" -#: src/Models/Station.vala:356 +#: src/Models/Station.vala:437 msgid "Stream Url" msgstr "" -#: src/Models/Station.vala:357 +#: src/Models/Station.vala:438 msgid "Stream Resolved Url" msgstr "" -#: src/Models/Station.vala:358 +#: src/Models/Station.vala:439 msgid "Favicon address" msgstr "" -#: src/Models/Station.vala:359 +#: src/Models/Station.vala:440 msgid "Homepage address" msgstr "" -#: src/Models/Station.vala:360 +#: src/Models/Station.vala:441 msgid "Station tags" msgstr "" -#: src/Models/Station.vala:361 src/Models/StreamMetadata.vala:32 +#: src/Models/Station.vala:442 src/Models/StreamMetadata.vala:36 msgid "Bitrate" msgstr "" -#: src/Models/Station.vala:362 +#: src/Models/Station.vala:443 msgid "Codec" msgstr "" #. Ordered array of tags and descriptions -#: src/Models/StreamMetadata.vala:23 +#: src/Models/StreamMetadata.vala:27 msgid "Title" msgstr "" -#: src/Models/StreamMetadata.vala:24 +#: src/Models/StreamMetadata.vala:28 msgid "Artist" msgstr "" -#: src/Models/StreamMetadata.vala:25 +#: src/Models/StreamMetadata.vala:29 msgid "Album" msgstr "" -#: src/Models/StreamMetadata.vala:26 +#: src/Models/StreamMetadata.vala:30 msgid "Image" msgstr "" -#: src/Models/StreamMetadata.vala:27 +#: src/Models/StreamMetadata.vala:31 msgid "Genre" msgstr "" -#: src/Models/StreamMetadata.vala:28 +#: src/Models/StreamMetadata.vala:32 msgid "Homepage" msgstr "" -#: src/Models/StreamMetadata.vala:29 +#: src/Models/StreamMetadata.vala:33 msgid "Organization" msgstr "" -#: src/Models/StreamMetadata.vala:30 +#: src/Models/StreamMetadata.vala:34 msgid "Location" msgstr "" -#: src/Models/StreamMetadata.vala:31 +#: src/Models/StreamMetadata.vala:35 msgid "Extended Comment" msgstr "" -#: src/Models/StreamMetadata.vala:33 +#: src/Models/StreamMetadata.vala:37 msgid "Audio Codec" msgstr "" -#: src/Models/StreamMetadata.vala:34 +#: src/Models/StreamMetadata.vala:38 msgid "Channel Mode" msgstr "" -#: src/Models/StreamMetadata.vala:35 +#: src/Models/StreamMetadata.vala:39 msgid "Track Number" msgstr "" -#: src/Models/StreamMetadata.vala:36 +#: src/Models/StreamMetadata.vala:40 msgid "Track Count" msgstr "" -#: src/Models/StreamMetadata.vala:37 +#: src/Models/StreamMetadata.vala:41 msgid "Nominal Bitrate" msgstr "" -#: src/Models/StreamMetadata.vala:38 +#: src/Models/StreamMetadata.vala:42 msgid "Minimum Bitrate" msgstr "" -#: src/Models/StreamMetadata.vala:39 +#: src/Models/StreamMetadata.vala:43 msgid "Maximum Bitrate" msgstr "" -#: src/Models/StreamMetadata.vala:40 +#: src/Models/StreamMetadata.vala:44 msgid "Has CRC" msgstr "" -#: src/Models/StreamMetadata.vala:41 +#: src/Models/StreamMetadata.vala:45 msgid "Container Format" msgstr "" -#: src/Models/StreamMetadata.vala:42 +#: src/Models/StreamMetadata.vala:46 msgid "Track Id" msgstr "" -#: src/Models/StreamMetadata.vala:43 +#: src/Models/StreamMetadata.vala:47 msgid "Application Name" msgstr "" -#: src/Models/StreamMetadata.vala:44 +#: src/Models/StreamMetadata.vala:48 msgid "Encoder" msgstr "" -#: src/Models/StreamMetadata.vala:45 +#: src/Models/StreamMetadata.vala:49 msgid "Encoder Version" msgstr "" -#: src/Models/StreamMetadata.vala:46 +#: src/Models/StreamMetadata.vala:50 msgid "Encoded by" msgstr "" -#: src/Models/StreamMetadata.vala:47 +#: src/Models/StreamMetadata.vala:51 msgid "Private Data" msgstr "" -#: src/Models/StreamMetadata.vala:48 +#: src/Models/StreamMetadata.vala:52 msgid "ID3 Private" msgstr "" -#: src/Models/StreamMetadata.vala:49 +#: src/Models/StreamMetadata.vala:53 msgid "GStreamer Sample" msgstr "" -#: src/Models/StreamMetadata.vala:50 +#: src/Models/StreamMetadata.vala:54 msgid "GStreamer Date Time" msgstr "" -#: src/Models/StreamMetadata.vala:51 +#: src/Models/StreamMetadata.vala:55 msgid "Date Time" msgstr "" #. #. Display Assets #. -#: src/Widgets/Display.vala:113 +#: src/Widgets/Display.vala:114 msgid "Selections" msgstr "" -#: src/Widgets/Display.vala:114 +#: src/Widgets/Display.vala:115 msgid "Library" msgstr "" -#: src/Widgets/Display.vala:115 +#: src/Widgets/Display.vala:116 msgid "Saved Searches" msgstr "" -#: src/Widgets/Display.vala:116 +#: src/Widgets/Display.vala:117 msgid "Explore" msgstr "" -#: src/Widgets/Display.vala:117 +#: src/Widgets/Display.vala:118 msgid "Genres" msgstr "" -#: src/Widgets/Display.vala:118 +#: src/Widgets/Display.vala:119 msgid "Subgenres" msgstr "" -#: src/Widgets/Display.vala:119 +#: src/Widgets/Display.vala:120 msgid "Eras" msgstr "" -#: src/Widgets/Display.vala:120 +#: src/Widgets/Display.vala:121 msgid "Talk, News, Sport" msgstr "" -#: src/Widgets/Display.vala:440 +#: src/Widgets/Display.vala:449 msgid "Discover" msgstr "" -#: src/Widgets/Display.vala:441 +#: src/Widgets/Display.vala:450 msgid "Stations to Discover" msgstr "" -#: src/Widgets/Display.vala:444 +#: src/Widgets/Display.vala:454 msgid "Discover more stations" msgstr "" -#: src/Widgets/Display.vala:464 +#: src/Widgets/Display.vala:474 msgid "Trending" msgstr "" -#: src/Widgets/Display.vala:465 +#: src/Widgets/Display.vala:475 msgid "Trending Stations in the last 24 hours" msgstr "" -#: src/Widgets/Display.vala:482 +#: src/Widgets/Display.vala:492 msgid "Popular" msgstr "" -#: src/Widgets/Display.vala:483 +#: src/Widgets/Display.vala:493 msgid "Most listened to Stations in the last 24 hours" msgstr "" -#: src/Widgets/Display.vala:520 src/Widgets/Display.vala:521 +#: src/Widgets/Display.vala:530 src/Widgets/Display.vala:531 msgid "Starred by You" msgstr "" -#: src/Widgets/Display.vala:559 +#: src/Widgets/Display.vala:569 msgid "Latest Search" msgstr "" -#: src/Widgets/Display.vala:560 +#: src/Widgets/Display.vala:570 msgid "Search Results" msgstr "" -#: src/Widgets/Display.vala:562 +#: src/Widgets/Display.vala:572 msgid "Save this search" msgstr "" -#: src/Widgets/Display.vala:696 +#: src/Widgets/Display.vala:706 msgid "Jukebox" msgstr "" -#: src/Widgets/Display.vala:698 +#: src/Widgets/Display.vala:708 #, c-format msgid "Double click to shuffle through %1$u stations" msgstr "" -#: src/Widgets/Display.vala:699 +#: src/Widgets/Display.vala:709 msgid "one, every ten minutes, for %2$u days" msgstr "" -#: src/Widgets/Display.vala:765 +#: src/Widgets/Display.vala:775 msgid "Saved Search" msgstr "" -#: src/Widgets/Display.vala:768 +#: src/Widgets/Display.vala:778 msgid "Remove this saved search" msgstr "" -#: src/Widgets/HeaderBar.vala:160 src/Widgets/StationContextMenu.vala:173 +#: src/Widgets/HeaderBar.vala:161 src/Widgets/StationContextMenu.vala:159 msgid "Star this station" msgstr "" @@ -280,19 +289,19 @@ msgstr "" #. RHS Controls #. #. Search button -#: src/Widgets/HeaderBar.vala:182 +#: src/Widgets/HeaderBar.vala:183 msgid "Search" msgstr "" -#: src/Widgets/HeaderBar.vala:190 +#: src/Widgets/HeaderBar.vala:191 msgid "Preferences" msgstr "" -#: src/Widgets/HeaderBar.vala:194 +#: src/Widgets/HeaderBar.vala:195 msgid "History" msgstr "" -#: src/Widgets/HeaderBar.vala:198 +#: src/Widgets/HeaderBar.vala:199 msgid "Heart current track in history" msgstr "" @@ -386,104 +395,112 @@ msgstr "" msgid "Show a movable popup with images discovered in the stream metadata" msgstr "" -#: src/Widgets/PreferencesPopover.vala:108 +#: src/Widgets/PreferencesPopover.vala:94 +msgid "Shrink title dynamically with the text" +msgstr "" + +#: src/Widgets/PreferencesPopover.vala:96 +msgid "Shrink the title according to the length of the displayed text." +msgstr "" + +#: src/Widgets/PreferencesPopover.vala:114 msgid "Language" msgstr "" -#: src/Widgets/PreferencesPopover.vala:109 +#: src/Widgets/PreferencesPopover.vala:115 msgid "Language changes restart Tuner" msgstr "" #. end language selection #. Export starred -#: src/Widgets/PreferencesPopover.vala:117 +#: src/Widgets/PreferencesPopover.vala:123 msgid "Export Starred Stations to Playlist" msgstr "" #. Import starred -#: src/Widgets/PreferencesPopover.vala:127 +#: src/Widgets/PreferencesPopover.vala:133 msgid "Import Station UUIDs as Starred Stations" msgstr "" #. Create the file chooser dialog for saving the exported playlist -#: src/Widgets/PreferencesPopover.vala:212 src/Widgets/Window.vala:530 +#: src/Widgets/PreferencesPopover.vala:219 src/Widgets/Window.vala:553 msgid "Save File" msgstr "" -#: src/Widgets/PreferencesPopover.vala:215 -#: src/Widgets/PreferencesPopover.vala:253 src/Widgets/Window.vala:533 +#: src/Widgets/PreferencesPopover.vala:222 +#: src/Widgets/PreferencesPopover.vala:260 src/Widgets/Window.vala:556 msgid "_Cancel" msgstr "" -#: src/Widgets/PreferencesPopover.vala:216 src/Widgets/Window.vala:522 -#: src/Widgets/Window.vala:534 +#: src/Widgets/PreferencesPopover.vala:223 src/Widgets/Window.vala:545 +#: src/Widgets/Window.vala:557 msgid "_Save" msgstr "" #. warning("Error: $(e.message)"); -#: src/Widgets/PreferencesPopover.vala:237 +#: src/Widgets/PreferencesPopover.vala:244 msgid "Error" msgstr "" -#: src/Widgets/PreferencesPopover.vala:250 +#: src/Widgets/PreferencesPopover.vala:257 msgid "Choose a file" msgstr "" -#: src/Widgets/PreferencesPopover.vala:254 +#: src/Widgets/PreferencesPopover.vala:261 msgid "_Open" msgstr "" #. warning("Error reading file: $(e.message)"); -#: src/Widgets/PreferencesPopover.vala:277 +#: src/Widgets/PreferencesPopover.vala:284 msgid "Error reading file" msgstr "" #. ---------------------------------------------- -#: src/Widgets/StationContextMenu.vala:91 +#: src/Widgets/StationContextMenu.vala:77 msgid "Station info updated Online - View changes" msgstr "" -#: src/Widgets/StationContextMenu.vala:92 +#: src/Widgets/StationContextMenu.vala:78 msgid "Update Station from Online" msgstr "" -#: src/Widgets/StationContextMenu.vala:100 +#: src/Widgets/StationContextMenu.vala:86 msgid "Visit Website" msgstr "" -#: src/Widgets/StationContextMenu.vala:107 +#: src/Widgets/StationContextMenu.vala:93 msgid "Copy Stream-URI to clipboard" msgstr "" #. warning (@"Unable to open website: $(e.message)"); -#: src/Widgets/StationContextMenu.vala:149 +#: src/Widgets/StationContextMenu.vala:135 msgid "Unable to open website" msgstr "" #. item.label = _station.starred ? Application.UNSTAR_CHAR + _("Unstar this station") : Application.STAR_CHAR + _("Star this station"); -#: src/Widgets/StationContextMenu.vala:172 +#: src/Widgets/StationContextMenu.vala:158 msgid "Unstar this station" msgstr "" #. Private -#: src/Widgets/Window.vala:71 +#: src/Widgets/Window.vala:73 msgid "Playing in background" msgstr "" -#: src/Widgets/Window.vala:72 +#: src/Widgets/Window.vala:74 msgid "" "Click here to resume window. To quit Tuner, pause playback and close the " "window." msgstr "" -#: src/Widgets/Window.vala:332 +#: src/Widgets/Window.vala:337 msgid "Stop Playback requested" msgstr "" -#: src/Widgets/Window.vala:519 +#: src/Widgets/Window.vala:542 msgid "Save hearted tracks to a file?" msgstr "" -#: src/Widgets/Window.vala:521 +#: src/Widgets/Window.vala:544 msgid "_Don't Save" msgstr "" diff --git a/po/extra/extra.pot b/po/extra/extra.pot index c9a9341c..7e71c4ab 100644 --- a/po/extra/extra.pot +++ b/po/extra/extra.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: extra\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-30 17:20-0700\n" +"POT-Creation-Date: 2026-04-19 20:37-0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -22,13 +22,13 @@ msgid "Tuner" msgstr "" #: data/io.github.tuner_labs.tuner.desktop.in:10 -msgid "Internet Radio Player" +msgid "Internet streaming radio music player" msgstr "" #: data/io.github.tuner_labs.tuner.desktop.in:11 msgid "Listen to Radio Stations from around the world" msgstr "" -#: data/io.github.tuner_labs.tuner.desktop.in:16 -msgid "Radio;Receiver;FM;Talk;Sport;News" +#: data/io.github.tuner_labs.tuner.desktop.in:17 +msgid "Radio;Receiver;FM;Music;Talk;Sport;News" msgstr "" diff --git a/src/Application.vala b/src/Application.vala index 8a2c0e6f..fe07bd54 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -14,6 +14,7 @@ using Tuner.Coordinators; using Tuner.Controllers; using Tuner.Events; + using Tuner.Models; using Tuner.Providers; using Tuner.Services; using Tuner.Widgets; @@ -249,7 +250,7 @@ namespace Tuner { private bool _has_started = false; // Coordinates startup-only cross-component flows (e.g., deferred autoplay). private StartupCoordinator _startup_coordinator; - private Gst.Element? _startup_jingle; + private GLib.Object? _startup_jingle; // Coordinates playback restart behavior after online/offline transitions. private PlaybackRecoveryCoordinator _playback_recovery_coordinator; // Coordinates provider click/vote updates from player events. @@ -566,35 +567,10 @@ namespace Tuner { if (_startup_jingle != null) return; - var playbin = Gst.ElementFactory.make ("playbin", "startup-jingle"); - if (playbin == null) - return; - var uri = "resource:///io/github/tuner_labs/tuner/sounds/tuner_startup.mp3"; - playbin.set ("uri", uri); - playbin.set ("volume", settings.volume); - _startup_jingle = playbin; - - var bus = playbin.get_bus (); - if (bus != null) - { - bus.add_signal_watch (); - bus.message.connect ((message) => { - switch (message.type) - { - case Gst.MessageType.EOS: - case Gst.MessageType.ERROR: - playbin.set_state (Gst.State.NULL); - bus.remove_signal_watch (); - _startup_jingle = null; - break; - default: - break; - } - }); - } - - playbin.set_state (Gst.State.PLAYING); + _startup_jingle = StreamPlayer.play_file (uri, settings.volume, () => { + _startup_jingle = null; + }); } diff --git a/src/Controllers/PlayerController.vala b/src/Controllers/PlayerController.vala index 214c9851..ef332b4b 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,97 @@ 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 bool TRACE_METADATA_PATH = false; private const uint CLICK_INTERVAL_IN_SECONDS = 606; // tape counter timer - 10 mins plus 1% - - private Player _player; - private Station _station; - private Metadata _metadata; - private Is _player_state; - private string _player_state_name; - private uint _tape_counter_id = 0; + private const uint PLAYING_STATE_DEBOUNCE_MS = 1000; + public bool play_error { get { return _play_error; } } + public string? play_error_message { get { return _play_error_message; } } - 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; - }); - }); + /** The error received when playing, if any */ + private bool _play_error = false; + private string? _play_error_message = null; - _player.media_info_updated.connect ((obj) => - // Stream metadata received - { - if (_metadata.process_media_info_update (obj)) - app().events.metadata_changed_sig (_station, _metadata); - }); + + private StreamPlayer? _player; + private Station _station; + //private StreamMetadata _metadata; + private StreamPlayer.State _player_state = StreamPlayer.State.STOPPED; + private StreamPlayer.State _last_play_state = StreamPlayer.State.STOPPED; + private double _volume_cache = 0.5; + private uint _debounce_state_id = 0; - _player.volume_changed.connect ((obj) => - // Volume changed - { - app().events.volume_changed_sig(obj.volume); - app().settings.volume = obj.volume; - }); + private uint _tape_counter_id = 0; - _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 ()); - } - }); - } // construct + // construct + // { + // _volume_cache = app().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) { + if (_player == null) + return; switch (state) { - case "playing": - Gdk.threads_add_idle (() => { + case StreamPlayer.State.PLAYING: + { if (app().is_offline) { - _play_error = false; + clear_play_error (); _player.stop (); - player_state = Is.STOPPED; - return false; + player_state = StreamPlayer.State.STOPPED; + break; } - _play_error = false; - player_state = Is.PLAYING; - return false; - }); + clear_play_error (); + player_state = StreamPlayer.State.PLAYING; + } break; - case "buffering": - Gdk.threads_add_idle (() => { - if (app().is_offline) + case StreamPlayer.State.BUFFERING: + case StreamPlayer.State.PAUSED: + { + if ( app().is_offline) { - _play_error = false; + clear_play_error (); _player.stop (); - player_state = Is.STOPPED; - return false; + player_state = StreamPlayer.State.STOPPED; + break; } - _play_error = false; - player_state = Is.BUFFERING; - return false; - }); + clear_play_error (); + 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; 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; + clear_play_error (); + player_state = StreamPlayer.State.STOPPED; } - return false; - }); + } break; - } + } // switch } // 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 StreamPlayer.State player_state { get { return _player_state; } // get private set { _player_state = value; - if (_station != null) - app().events.state_changed_sig(_station, value); + + if (_station != null ) + app().events.player_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,7 +137,7 @@ 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, () => { @@ -198,7 +162,6 @@ public class Tuner.Controllers.PlayerController : GLib.Object set { if ( ( _station == null ) || ( _station != value ) ) { - _metadata = new Metadata(); _station = value; play_station (_station); } @@ -211,30 +174,54 @@ 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); + + app().events.volume_changed_sig (value); + if (app().settings != null) + app().settings.volume = value; + } + } // volume /** * @brief Plays the specified station. * + * The player will crossfade from the current station to the new one. + * The current player is detached and a new one created and attached + * * @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; + string stream_url = (_station.urlResolved != null && _station.urlResolved != "") ? _station.urlResolved : _station.url; + + _volume_cache = app().settings.volume; + attach_player (Tuner.create_stream_player (stream_url)); + clear_play_error (); + _station.track_listen (); app().events.station_changed_sig (_station); - _player.uri = (_station.urlResolved != null && _station.urlResolved != "") ? _station.urlResolved : _station.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 + } // play_station /** @@ -252,13 +239,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 (); + clear_play_error (); + if (_player != null) + _player.play (); break; } } // play_pause @@ -269,8 +258,176 @@ 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; + clear_play_error (); + + _player.play_state_changed_sig.connect (on_player_state_changed); + _player.stream_metadata_changed_sig.connect (on_player_metadata_changed); + _player.playback_error_sig.connect (on_player_error); + + on_player_state_changed (_player.play_state); + on_player_metadata_changed (); + } // attach_player + + + /** + * @brief Disconnects the current player instance and clears controller state. + */ + private void detach_player () + { + cancel_state_debounce (); + if (_player != null) + { + _player.play_state_changed_sig.disconnect (on_player_state_changed); + _player.stream_metadata_changed_sig.disconnect (on_player_metadata_changed); + _player.playback_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 () + { + if (_player == null || _station == null) + return; + + if (TRACE_METADATA_PATH) + { + stdout.printf ( + "[TRACE][PlayerController] emit playback_metadata station=%s title='%s' pretty_len=%u\n", + _station.stationuuid, + _player.stream_metadata.title, + _player.stream_metadata.pretty_print.length + ); + } + + app().events.playback_metadata_changed_sig (_station, _player.stream_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; + _play_error_message = _message; + set_play_state (StreamPlayer.State.STOPPED_ERROR); + } // on_player_error + + + /** + * @brief Clears stored playback error state and message. + */ + private void clear_play_error () + { + _play_error = false; + _play_error_message = null; + } // clear_play_error /** diff --git a/src/Coordinators/PlaybackRecoveryCoordinator.vala b/src/Coordinators/PlaybackRecoveryCoordinator.vala index 5e6974d4..e8045d05 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; @@ -40,7 +41,8 @@ namespace Tuner.Coordinators { AppEventBus events, PlayerController player, Settings settings - ) { + ) + { Object(); _app = app; _events = events; @@ -51,61 +53,61 @@ namespace Tuner.Coordinators { on_connectivity_changed(is_online ); }); - _player_state_handler_id = _events.state_changed_sig.connect((station, state) => { + _player_state_handler_id = _events.player_state_changed_sig.connect((station, state) => { on_player_state_changed(station, state); }); - } + } // PlaybackRecoveryCoordinator - /** - * @brief Handles connectivity transitions for playback recovery. - * - * On offline transition, remembers whether playback was active. - * On online transition, optionally restarts playback if enabled. - * - * @param is_online True when app has network connectivity. - * @param is_offline True when app has no network connectivity. - */ - private void on_connectivity_changed(bool is_online ) + /** + * @brief Handles connectivity transitions for playback recovery. + * + * On offline transition, remembers whether playback was active. + * On online transition, optionally restarts playback if enabled. + * + * @param is_online True when app has network connectivity. + * @param is_offline True when app has no network connectivity. + */ + private void on_connectivity_changed(bool is_online ) + { + if (is_online) { - if (is_online) - { - bool already_playing = _player.player_state == PlayerController.Is.PLAYING - || _player.player_state == PlayerController.Is.BUFFERING; - if (_settings.play_restart && _was_playing_before_offline && _player.can_play() && !already_playing) - _player.play_station(_player.station); - _was_playing_before_offline = false; - } - else - { - _was_playing_before_offline = _was_playing_before_offline - || _player.player_state == PlayerController.Is.PLAYING - || _player.player_state == PlayerController.Is.BUFFERING; - } - } // on_connectivity_changed - - - /** - * @brief Tracks player state to refine recovery decisions. - * - * @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) + 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; + } + else { - if (state == PlayerController.Is.PLAYING || state == PlayerController.Is.BUFFERING) + _was_playing_before_offline = _was_playing_before_offline + || _player.player_state == StreamPlayer.State.PLAYING + || _player.player_state == StreamPlayer.State.BUFFERING; + } + } // on_connectivity_changed + + + /** + * @brief Tracks player state to refine recovery decisions. + * + * @param station Current station associated with the state change. + * @param state Current player state. + */ + private void on_player_state_changed(Station station, StreamPlayer.State state) + { + 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; - } + } // on_player_state_changed - /** - * @brief Disconnects coordinator signal handlers and releases resources. - */ - public override void dispose() - { + /** + * @brief Disconnects coordinator signal handlers and releases resources. + */ + public override void dispose() + { if (_connectivity_handler_id > 0) { _events.disconnect(_connectivity_handler_id); @@ -119,6 +121,6 @@ namespace Tuner.Coordinators { } base.dispose(); - } - } -} + } // dispose + } // PlaybackRecoveryCoordinator +} // Tuner.Coordinators diff --git a/src/Coordinators/UsageTrackingCoordinator.vala b/src/Coordinators/UsageTrackingCoordinator.vala index 8c0929b4..c9f062f3 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; @@ -43,7 +44,7 @@ namespace Tuner.Coordinators { _events = events; _provider = provider; - _player_state_handler_id = _events.state_changed_sig.connect((station, state) => { + _player_state_handler_id = _events.player_state_changed_sig.connect((station, state) => { on_player_state_changed(station, state); }); @@ -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..1437a489 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 { @@ -20,20 +21,12 @@ namespace Tuner { /** @brief Fired when connectivity state changes. */ public signal void connectivity_changed_sig (bool is_online); - /** @brief Fired when shuffle mode changes. */ - public signal void shuffle_mode_sig (bool shuffle); - /** @brief Emitted when the starred stations change. */ public signal void starred_stations_changed_sig (Station station); /** Signal emitted when the station changes. */ 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); - - /** Signal emitted when the title changes. */ - public signal void metadata_changed_sig (Station station, Metadata metadata); /** Signal emitted when the volume changes. */ public signal void volume_changed_sig (double volume); @@ -41,9 +34,22 @@ namespace Tuner { /** Signal emitted every ten minutes that a station has been playing continuously. */ public signal void tape_counter_sig (Station station); + + /** @brief Fired when shuffle mode changes. */ + public signal void shuffle_mode_sig (bool shuffle); + /** @brief Signal emitted when the shuffle is requested */ public signal void shuffle_requested_sig(); + + // Stream Player signals + + /** Signal emitted when the player state changes. */ + public signal void player_state_changed_sig (Station station, StreamPlayer.State state); + + // /** Signal emitted when the title changes. */ + public signal void playback_metadata_changed_sig (Station station, StreamMetadata metadata); + } // AppEventBus } // Tuner 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..9d92ce1f --- /dev/null +++ b/src/Ext/GstStreamPlayer.vala @@ -0,0 +1,372 @@ +/** + * 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 : StreamPlayer + { + private const bool TRACE_METADATA_PATH = false; + + 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 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 Create a GStreamer-backed stream player. + * + * @param stream_url Stream URL for the playbin pipeline. + */ + public GstStreamPlayer (string stream_url) + { + base ( stream_url); + + playbin = ElementFactory.make ("playbin", "play"); + //playbin.user_agent = Tuner.user_agent (); + playbin.uri = stream_url; + + set_volume_level (0.5); + setup_level_monitor (); + Gst.Bus bus = playbin.get_bus (); + bus.add_watch (0, bus_callback); + } // constructor + + + /** + * @brief Play a single file URI with the GStreamer backend. + * + * @param file_uri URI to play. + * @param volume Playback volume between 0.0 and 1.0. + * @param on_finished Optional completion callback. + * @return Active playback handle, or null when setup failed. + */ + public static GLib.Object? play_file_backend (string file_uri, double volume, owned StreamPlayer.FilePlaybackFinished? on_finished = null) + { + var startup_playbin = Gst.ElementFactory.make ("playbin", "stream-player-file"); + if (startup_playbin == null) + return null; + + startup_playbin.set ("uri", file_uri); + startup_playbin.set ("volume", volume); + + var bus = startup_playbin.get_bus (); + if (bus != null) + { + bus.add_signal_watch (); + bus.message.connect ((message) => { + switch (message.type) + { + case Gst.MessageType.EOS: + case Gst.MessageType.ERROR: + startup_playbin.set_state (Gst.State.NULL); + bus.remove_signal_watch (); + if (on_finished != null) + on_finished (); + break; + default: + break; + } // switch + }); + } + + startup_playbin.set_state (Gst.State.PLAYING); + return startup_playbin; + } // play_file_backend + + /** @brief Start playback. */ + public override bool play_impl () + { + playbin.set_state (Gst.State.PLAYING); + return true; + } // play + + + /** @brief Stop playback and reset the pipeline. */ + public override bool stop_impl () + { + playbin.set_state (Gst.State.NULL); + return true; + } // stop + + + /** + * @brief Set the output volume level. + * + * @param volume Volume between 0.0 and 1.0. + */ + public override void set_volume_level_impl (double volume) + { + playbin.volume = apply_trim (volume); + } // set_volume_level + + + /** + * @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 override bool crossfade_impl (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 true; + } + + var fader = get_fader (); + fader.crossfade (this, next_gst, target_volume, default_fade_duration_ms, default_fade_interval_ms); + return true; + } // crossfade + + + // ---------------------------------------------------------------- + + + + /** + * @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); + playback_error (err.message); + update_play_state (State.STOPPED_ERROR); + break; + + case MessageType.EOS: + // stdout.printf ("end of stream\n"); + update_play_state (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 (map_play_state(newstate)); + 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; + } + } + if (TRACE_METADATA_PATH) + { + string? title = _metadata.lookup ("title"); + stdout.printf ( + "[TRACE][GstStreamPlayer] TAG stream=%s tags=%u title='%s' changed=%s\n", + stream_url, + count, + title != null ? title : "", + changed ? "true" : "false" + ); + } + if (changed) + metadata_changed(); + } + 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 (); + } + } + } // if + break; + + default: + break; + } // switch + + return true; + } // bus_callback + + + /** + * @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 Pre-roll the pipeline without output. + */ + internal void prepare () + { + playbin.set_state (Gst.State.PAUSED); + update_play_state (State.PAUSED); + } // prepare + + + /** + * @brief Set a per-stream trim in dB. + * + * @param trim_db Trim value in dB. + */ + internal void set_trim_db_level (double trim_db) + { + this.trim_db = trim_db; + playbin.volume = apply_trim (this.volume); + } // set_trim_db_level + + + /** + * @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; + } // apply_trim + + + 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); + } + } // setup_level_monitor + + + /** + * @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 State.PLAYING; + case Gst.State.PAUSED: + return State.PAUSED; + case Gst.State.READY: + return State.BUFFERING; + default: + return 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/Station.vala b/src/Models/Station.vala index 71dbbefb..afc52e04 100644 --- a/src/Models/Station.vala +++ b/src/Models/Station.vala @@ -75,46 +75,52 @@ public class Tuner.Models.Station : Favicon // ---------------------------------------------------------- - // Non-Properties + // Index Properties // ---------------------------------------------------------- /** @property {int} votes - Number of votes for the station. */ - public int votes; + public int votes { get; set ; } /** @property {string} lastchangetime - Last change time of the station. */ - public string lastchangetime; + public string lastchangetime { get; private set ; } /** @property {string} lastchangetime_iso8601 - Last change time in ISO 8601 format. */ - public string lastchangetime_iso8601; + public string lastchangetime_iso8601 { get; private set ; } /** @property {int} lastcheckok - Status of the last check (0 or 1). */ - public int lastcheckok; + public int lastcheckok { get; private set ; } /** @property {string} lastchecktime - Last check time of the station. */ - public string lastchecktime; + public string lastchecktime { get; private set ; } /** @property {string} lastchecktime_iso8601 - Last check time in ISO 8601 format. */ - public string lastchecktime_iso8601; + public string lastchecktime_iso8601 { get; private set ; } /** @property {string} lastcheckoktime - Last successful check time. */ - public string lastcheckoktime ; + public string lastcheckoktime { get; private set ; } /** @property {string} lastcheckoktime_iso8601 - Last successful check time in ISO 8601 format. */ - public string lastcheckoktime_iso8601; + public string lastcheckoktime_iso8601 { get; private set ; } /** @property {string} lastlocalchecktime - Last local check time. */ - public string lastlocalchecktime; + public string lastlocalchecktime { get; private set ; } /** @property {string} lastlocalchecktime_iso8601 - Last local check time in ISO 8601 format. */ - public string lastlocalchecktime_iso8601; + public string lastlocalchecktime_iso8601 { get; private set ; } /** @property {string} clicktimestamp - Timestamp of the last click. */ - public string clicktimestamp; + public string clicktimestamp { get; private set ; } /** @property {string} clicktimestamp_iso8601 - Last click timestamp in ISO 8601 format. */ - public string clicktimestamp_iso8601; + public string clicktimestamp_iso8601 { get; private set ; } /** @property {int} clickcount - Number of clicks on the station. */ - public int clickcount; + public int clickcount { get; set ; } /** @property {int} clicktrend - Trend of clicks on the station. */ - public int clicktrend; + public int clicktrend { get; set ; } /** @property {int} ssl_error - SSL error status. */ - public int ssl_error; + public int ssl_error { get; private set ; } /** @property {string} geo_lat - Latitude of the station's location. */ - public string geo_lat; + public string geo_lat { get; private set ; } /** @property {string} geo_long - Longitude of the station's location. */ - public string geo_long; + public string geo_long { get; private set ; } /** @property {bool} has_extended_info - Indicates if extended info is available. */ - public bool has_extended_info; + public bool has_extended_info { get; private set ; } + + // ---------------------------------------------------------- + // User Properties + // ---------------------------------------------------------- + + public DateTime starred_since { get; private set ; } private bool _starred; /** @property {bool} starred - Indicates if the station is starred. Only set by Favorites*/ public bool starred { @@ -123,12 +129,36 @@ public class Tuner.Models.Station : Favicon if ( _starred == value ) return; _starred = value; station_star_changed_sig(_starred ); + if ( starred_since == null ) starred_since = new DateTime.now_local(); } - } + } // starred + + + /** + * @brief Annotate a Listen to the station + */ + public void track_listen() + { + var now = new DateTime.now_local(); + if ( last_played == null || !is_same_calendar_day(last_played, now) ) + { + last_played = now; + days_played += 1; + } + } // listen + + public DateTime last_played { get; private set ; } + public int days_played { get; private set ; } // Count once per day + + + // ---------------------------------------------------------- + // Temporal Properties + // ---------------------------------------------------------- public bool is_in_index; // Indicates if the station is in the provider index public bool is_up_to_date; // Indicates if the station is up-to-date with the provider index public string up_to_date_difference = _("Station no longer in the index"); + // ---------------------------------------------------------- @@ -139,6 +169,14 @@ public class Tuner.Models.Station : Favicon // private Gdk.Pixbuf _favicon_pixbuf; // Favicon for this station private string _favicon_cache_file; + private bool is_same_calendar_day(DateTime left, DateTime right) + { + return left.get_year() == right.get_year() + && left.get_month() == right.get_month() + && left.get_day_of_month() == right.get_day_of_month(); + } + + string _locale; // Cached locale string for display // ---------------------------------------------------------- // Functions @@ -317,11 +355,54 @@ public class Tuner.Models.Station : Favicon * @brief Returns a string representation of the station. * @return {string} A string in the format "[id] title". */ - public string popularity() { + public string popularity() { return _(@"Votes: $(votes)\t Clicks: $(clickcount)\t Trend: $(clicktrend)"); } // to_string + /** + * @brief Returns a string representation of the station's locale information. + * @return {string} A string in the format "Country: %s\tState: %s\tLanguage: %s". + */ + public string locale() + { + if ( ( _locale == null || _locale != "" ) ) + { + var sb = new StringBuilder (); + + // Country + if ( countrycode != null && countrycode.length > 0 ) + { + sb.append(Countries.get_by_code(countrycode) + "\n"); + if ( state != null && state.length > 0 ) + sb.append (state).append ("\t"); + } + + // Language + if ( language != null && language.length > 0 ) + { + sb.append ("[") + .append (Languages.get_by_code ( languagecodes, language)) + .append ("]"); + } + _locale = sb.str; + } + + return _locale; + + } // locale + + + /** + * @brief Returns a string representation of the station's temporal information. + * @return {string} A string in the format "Last Played: %s\tPlays: %d". + */ + public string temporal() + { + return _("Last Played: %s\tPlays: %d").printf(last_played.format("%x"), days_played); + } // temporal + + /** * @brief Returns a string representation of the station. * @return {string} A string in the format "[id] title". diff --git a/src/Models/StationListBoxConfig.vala b/src/Models/StationListBoxConfig.vala index eedab46d..bc52b438 100644 --- a/src/Models/StationListBoxConfig.vala +++ b/src/Models/StationListBoxConfig.vala @@ -26,6 +26,7 @@ namespace Tuner.Models public string icon { get; construct; } public string title { get; construct; } public string subtitle { get; construct; } + public bool filter { get; construct; default = false; } public StationSet? station_set { get; set; } public StationListHookup? station_list_hookup { get; set; } @@ -40,7 +41,8 @@ namespace Tuner.Models string name, string icon, string title, - string subtitle) + string subtitle, + bool filter = false) { Object ( stack: stack, @@ -49,7 +51,8 @@ namespace Tuner.Models name: name, icon: icon, title: title, - subtitle: subtitle + subtitle: subtitle, + filter: filter ); } } // StationListBoxConfig diff --git a/src/Models/StreamMetadata.vala b/src/Models/StreamMetadata.vala index 1cbb2136..b8493d38 100644 --- a/src/Models/StreamMetadata.vala +++ b/src/Models/StreamMetadata.vala @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: GPL-3.0-or-later * - * @file PlayerController.vala + * @file StreamMetadata.vala */ using Gst; @@ -12,11 +12,15 @@ using Gst; /** * @class Metadata * - * @brief Stream Metadata transform + * @brief Stream Metadata object that extracts and organizes metadata from stream tag tables. * */ -public class Tuner.Models.Metadata : GLib.Object +public class Tuner.Models.StreamMetadata : GLib.Object { + + /** + * @brief Ordered array of tags and descriptions + */ private static string[,] METADATA_TITLES = // Ordered array of tags and descriptions { @@ -52,10 +56,13 @@ public class Tuner.Models.Metadata : GLib.Object }; + // Known tags in a list for easy lookup and to maintain order for pretty printing private static Gee.List METADATA_TAGS = new Gee.ArrayList (); - static construct { + static construct + { + // Construct an ordered list of metadata tags uint8 tag_index = 0; foreach ( var tag in METADATA_TITLES ) // Replicating the order in METADATA_TITLES @@ -63,8 +70,10 @@ public class Tuner.Models.Metadata : GLib.Object if ((tag_index++)%2 == 0) METADATA_TAGS.insert (tag_index/2, tag ); } - } + } // static construct + + // Major metadata fields as proporties public string all_tags { get; private set; default = ""; } public string title { get; private set; default = ""; } public string artist { get; private set; default = ""; } @@ -76,19 +85,69 @@ public class Tuner.Models.Metadata : GLib.Object public string track { get; private set; default = ""; } public string pretty_print { get; private set; default = ""; } - private Gee.Map _metadata_values = new Gee.HashMap(); // Hope it come out in order + // Internal storage for metadata key value pairs, keyed by tag name + private Gee.Map _metadata_values = new Gee.HashMap(); // Hope it comes out in order + - /** - * Extracts the metadata from the media stream. + * Extracts the metadata from a stream tag table and populates the stream metadata object * - * @param media_info The media information stream + * @param tags The tag table from the stream. * @return true if the metadata has changed */ - internal bool process_media_info_update (PlayerMediaInfo media_info) + internal bool process_tag_table (GLib.HashTable tags) { - var streamlist = media_info.get_stream_list ().copy (); + // Sort the keys to ensure consistent ordering for all_tags and pretty_print + 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 (";"); + } // foreach + + if (all_tags == sb.str) // No change in metadata + return false; + + all_tags = sb.str; + reset_fields (); + _metadata_values.clear (); + + foreach (var key in keys) + // Pull out the metadata in a consistent order based on METADATA_TAGS, but note any new tags we haven't seen before + { + 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; + } // if + + _metadata_values.set (key, value); + } // foreach + + update_from_metadata_values (); // Update the fields based on the new metadata values + return true; + } // process_tag_table + + /** + * Resets the fields + */ + private void reset_fields () + { title = ""; artist = ""; image = ""; @@ -98,103 +157,64 @@ public class Tuner.Models.Metadata : GLib.Object org_loc = ""; track = ""; pretty_print = ""; + } // reset_fields - foreach (var stream in streamlist) // Hopefully just one metadata stream - { - var? tags = stream.get_tags (); // Get the raw tags - - if (tags == null) - break; // No tags, break on this metadata stream - - if (all_tags == tags.to_string ()) - return false; // Compare to all tags and if no change return false - - all_tags = tags.to_string (); - debug(@"All Tags: $all_tags"); - - string? s = null; - bool b = false; - uint u = 0; - tags.foreach ((list, tag) => - { - var index = METADATA_TAGS.index_of (tag); - - if (index == -1) - { - warning(@"New meta tag: $tag"); - return; - } - - var type = (list.get_value_index(tag, 0)).type(); - - switch (type) - { - case GLib.Type.STRING: - list.get_string(tag, out s); - _metadata_values.set ( tag, s); - break; - case GLib.Type.UINT: - list.get_uint(tag, out u); - if ( u > 1000) - _metadata_values.set ( tag, @"$(u/1000)K"); - else - _metadata_values.set ( tag, u.to_string ()); - break; - case GLib.Type.BOOLEAN: - list.get_boolean (tag, out b); - _metadata_values.set ( tag, b.to_string ()); - break; - default: - warning(@"New Tag type: $(type.name())"); - break; - } - }); // 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 + /** + * Updates the fields from the metadata values + */ + 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)) { - if (_metadata_values.has_key(tag)) - { - sb.append ( _(METADATA_TITLES[METADATA_TAGS.index_of (tag),1])) - .append(" : ") - .append( _metadata_values.get (tag)) - .append("\n"); - } + 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; - } // foreach + } - return true; - } // process_media_info_update + if (sb.len > 0) + pretty_print = sb.truncate (sb.len-1).str; + else + pretty_print = ""; + } // update_from_metadata_values - /** */ + /** + * Extracts the value for a given metadata key. + * + * @param key The metadata key to extract. + * @return The value associated with the key, or an empty string if not found. + */ private string extract( string key) { if (_metadata_values.has_key (key )) diff --git a/src/Models/StreamPlayer.vala b/src/Models/StreamPlayer.vala new file mode 100644 index 00000000..8dab2416 --- /dev/null +++ b/src/Models/StreamPlayer.vala @@ -0,0 +1,227 @@ +/** + * SPDX-FileCopyrightText: Copyright © 2026 technosf + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @file StreamPlayer.vala + */ + +using Tuner.Events; + +namespace Tuner.Models +{ + /** + * @class StreamPlayer + * + * @brief StreamPlayer is an abstract class and interface to functions implemented by backend libraries + * + * This abstract class handles the player state, volume control, and stream metadata. + * The actual playback logic and metadata extractionis delegated to backend-specific implementations. + * + * {@code StreamPlayer} reperesents one stream Url and is not reused. + * + */ + public abstract class StreamPlayer : GLib.Object + { + private const bool TRACE_METADATA_PATH = false; + + /** @brief Callback invoked when one-shot file playback ends. */ + public delegate void FilePlaybackFinished (); + + /** + * @enum State + * + * @brief Represents the state of the stream player. + * + */ + public enum State + { + BUFFERING, + PAUSED, + PLAYING, + STOPPED, + STOPPED_ERROR + } // State + + // Per StreamPlayer signals + + + internal signal void play_state_changed_sig (StreamPlayer.State state); + + /** Signal emitted when a playback error occurs. */ + internal signal void playback_error_sig (string message); + + //public signal void player_state_changed_sig (StreamPlayer.State state); + internal signal void stream_metadata_changed_sig (); + + + + /** @brief Stream URL configured at construction time. */ + public string stream_url { get; construct; } + + /** @brief App-level state derived from the GStreamer pipeline. */ + public State play_state { get; private set; default = State.STOPPED;} + + /** @brief Latest string metadata from the stream. */ + protected GLib.HashTable _metadata; + //public GLib.HashTable metadata { get { return _metadata; } } + + public StreamMetadata stream_metadata { get; construct; } + + /** @brief Current output volume (0.0 - 1.0). */ + public double volume { get; private set; default = 0.5; } + + + /* + Abstract methods to be implemented by backends. + */ + public abstract bool play_impl(); + public abstract bool stop_impl(); + public abstract bool crossfade_impl(StreamPlayer next_player, double target_volume); + public abstract void set_volume_level_impl (double volume); + + + /** + * @brief Play a single file URI using the active backend implementation. + * + * @param file_uri URI to play (for example resource:///...). + * @param volume Playback volume between 0.0 and 1.0. + * @param on_finished Optional completion callback. + * @return Backend playback handle, or null when setup failed. + */ + public static GLib.Object? play_file (string file_uri, double volume, owned FilePlaybackFinished? on_finished = null) + { + return Tuner.play_stream_file (file_uri, volume, (owned) on_finished); // FIXME + } // play_file + + + /** + * @brief Create a GStreamer-backed stream player. + * + * @param stream_url Stream URL for the playbin pipeline. + */ + protected StreamPlayer (string stream_url) + { + GLib.Object (stream_url: stream_url) ; + _metadata = new GLib.HashTable (GLib.str_hash, GLib.str_equal); + _stream_metadata = new StreamMetadata (); + } // constructor + + + /** @brief Start playback. */ + public void play () + { + if (!play_impl()) { + update_play_state (State.STOPPED_ERROR); + playback_error_sig ( "Failed to start playback."); + return; + } + update_play_state (State.PLAYING); + } // play + + /** @brief Stop playback */ + public void stop () + { + if (!stop_impl()) { + update_play_state (State.STOPPED_ERROR); + playback_error_sig ( "Failed to start playback."); + return; + } + update_play_state (State.STOPPED); + } // stop + + + /** + * @brief Crossfade to the next player from this one + * + * @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) + { + if (next_player == null) + { + stop (); + next_player.set_volume_level (target_volume); + next_player.play (); + return; + } + + crossfade_impl (next_player, target_volume); + } // crossfade_to + + /** + * @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; + set_volume_level_impl (volume); + } // set_volume_level + + + + // + // Methods for implementing classes + // + + /** + * @brief Update and emit the app-level play state. + * + * This method is used by the backend-specific implementations to reflect changes + * in the actual playback state in the app-level state and emit the corresponding signal. + * + * @param state New app-level state. + */ + protected void update_play_state (StreamPlayer.State state) + { + if (_play_state == state) + return; + _play_state = state; + play_state_changed_sig ( _play_state); + // AppEventBus.player_state_changed_sig (this, _play_state); + } // update_play_state + + + /** + * @brief Emit the playback error signal. + * + * @param message Error message. + */ + protected void playback_error (string message) + { + playback_error_sig ( message); + } // playback_error + + + /** + * @brief Emit the stream metadata changed signal. + */ + protected void metadata_changed() + { + bool changed = stream_metadata.process_tag_table (_metadata); + if (TRACE_METADATA_PATH) + { + stdout.printf ( + "[TRACE][StreamPlayer] metadata_changed stream=%s changed=%s title='%s' pretty_len=%u\n", + stream_url, + changed ? "true" : "false", + stream_metadata.title, + stream_metadata.pretty_print.length + ); + } + + if (!changed) + return; + + stream_metadata_changed_sig (); + } // metadata_changed + } // Tuner.Models.StreamPlayer +} // namespace Tuner.Models diff --git a/src/Services/DBusMediaPlayer.vala b/src/Services/DBusMediaPlayer.vala index fd37d4f3..59884a05 100644 --- a/src/Services/DBusMediaPlayer.vala +++ b/src/Services/DBusMediaPlayer.vala @@ -8,6 +8,7 @@ */ using Tuner.Controllers; +using Tuner.Ext; using Tuner.Models; /** @@ -187,15 +188,15 @@ namespace Tuner.Services.DBus _app = app; _player = player; - _app.events.state_changed_sig.connect ((station, state) => + _app.events.player_state_changed_sig.connect ((station, state) => { 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: @@ -205,7 +206,7 @@ namespace Tuner.Services.DBus }); - _app.events.metadata_changed_sig.connect (( station, metadata) => + _app.events.playback_metadata_changed_sig.connect (( station, metadata) => { _station = station; _current_title = station.name; diff --git a/src/Services/HttpClient.vala b/src/Services/HttpClient.vala index a2f05fb4..3f9faf1e 100644 --- a/src/Services/HttpClient.vala +++ b/src/Services/HttpClient.vala @@ -52,7 +52,7 @@ public class Tuner.Services.HttpClient : Object "max-conns", 50, "max-conns-per-host", 2 , "timeout", 3, - "user_agent", @"$(Application.APP_ID)/$(Application.APP_VERSION)" + "user_agent", Tuner.user_agent () ); } debug(@"Conns Max: $(_session.get_max_conns()), Conns PH: $(_session.get_max_conns_per_host())"); diff --git a/src/Services/StarStore.vala b/src/Services/StarStore.vala index 0bdc0220..4820a1b1 100644 --- a/src/Services/StarStore.vala +++ b/src/Services/StarStore.vala @@ -6,13 +6,14 @@ * * @file StarStore.vala * - * @brief Store and retrieve a collection of starred stations. + * @brief Store and retrieve a collection of starred stations and saved searches. + * + * Manages a collection of stations, searches stored in a JSON file. + * Provides methods to add, remove, and persist stations and searches. + * The JSON file store a subset of station data - the minimum required to be able to + * play a station without retrieving its information from radio-browser, plus state + * information such as when it was starred, when it was last played and how many times it has been played. * - * Manages a collection of stations stored in a JSON file. - * Provides methods to add, remove, and persist stations. - * The JSON file store a subset of station data - the minimum - * to be able to play a station without retrieving its information] - * from radio-browser */ using Gee; @@ -33,7 +34,7 @@ using Tuner.Services; */ public class Tuner.Services.StarStore : Object { - + // JSON file constants private const string FAVORITES_PROPERTY_APP = "app"; private const string FAVORITES_PROPERTY_FILE = "file"; private const string FAVORITES_PROPERTY_SCHEMA = "schema"; @@ -42,6 +43,7 @@ public class Tuner.Services.StarStore : Object private const string FAVORITES_SCHEMA_VERSION = "2.0"; + // M3U8 export constants private const string M3U8 = "#EXTM3U\n#EXTENC:UTF-8\n#PLAYLIST:Tuner\n"; private const string M3U8_UUID = "STATIONUUID"; private const string UUID_REGEX = "([a-fA-Z0-9]{8}-[a-fA-Z0-9]{4}-[a-fA-Z0-9]{4}-[a-fA-Z0-9]{4}-[a-fA-Z0-9]{12})"; @@ -194,6 +196,7 @@ public class Tuner.Services.StarStore : Object builder.end_array (); builder.end_object (); + // Serialize Json.Generator generator = new Json.Generator (); generator.set_pretty (true); generator.set_root (builder.get_root ()); diff --git a/src/Settings.vala b/src/Settings.vala index c9a18b69..b02f4c65 100644 --- a/src/Settings.vala +++ b/src/Settings.vala @@ -24,6 +24,7 @@ public class Tuner.Settings : GLib.Settings private const string SETTINGS_STREAM_INFO = "stream-info"; private const string SETTINGS_STREAM_INFO_FAST = "stream-info-fast"; private const string SETTINGS_STREAM_INFO_IMAGE_POPUP = "stream-info-image-popup"; + private const string SETTINGS_STREAM_INFO_DYNAMIC_SHRINK = "stream-info-dynamic-shrink"; private const string SETTINGS_THEME_MODE = "theme-mode"; private const string SETTINGS_LANGUAGE = "language"; private const string SETTINGS_VOLUME = "volume"; @@ -39,6 +40,7 @@ public class Tuner.Settings : GLib.Settings public bool stream_info { get; set; } public bool stream_info_fast { get; set; } public bool stream_info_image_popup { get; set; } + public bool stream_info_dynamic_shrink { get; set; } public string theme_mode { get; set; } public string language { get; set; } public double volume { get; set; } @@ -76,6 +78,7 @@ public class Tuner.Settings : GLib.Settings stream_info = get_boolean(SETTINGS_STREAM_INFO); stream_info_fast = get_boolean(SETTINGS_STREAM_INFO_FAST); stream_info_image_popup = get_boolean(SETTINGS_STREAM_INFO_IMAGE_POPUP); + stream_info_dynamic_shrink = get_boolean(SETTINGS_STREAM_INFO_DYNAMIC_SHRINK); theme_mode = get_string(SETTINGS_THEME_MODE); language = get_string(SETTINGS_LANGUAGE); volume = get_double(SETTINGS_VOLUME); @@ -164,6 +167,7 @@ public class Tuner.Settings : GLib.Settings set_boolean(SETTINGS_STREAM_INFO, stream_info); set_boolean(SETTINGS_STREAM_INFO_FAST, stream_info_fast); set_boolean(SETTINGS_STREAM_INFO_IMAGE_POPUP, stream_info_image_popup); + set_boolean(SETTINGS_STREAM_INFO_DYNAMIC_SHRINK, stream_info_dynamic_shrink); set_string(SETTINGS_THEME_MODE, theme_mode); set_string(SETTINGS_LANGUAGE, language); set_double(SETTINGS_VOLUME, volume); diff --git a/src/Utils.vala b/src/Utils.vala index 45b2ce04..7eef6c2a 100644 --- a/src/Utils.vala +++ b/src/Utils.vala @@ -9,6 +9,8 @@ */ using GLib; + using Tuner.Models; + using Tuner.Ext; /** * @namespace Tuner @@ -19,6 +21,14 @@ namespace Tuner { // Fade duration used for window and image transitions (milliseconds) public const uint WINDOW_FADE_MS = 400; + /** + * @brief Build the app-wide User-Agent string. + */ + public static string user_agent () + { + return @"$(Application.APP_ID)/$(Application.APP_VERSION)"; + } // user_agent + /** * @brief Available themes * @@ -153,4 +163,34 @@ 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 + + + /** + * @brief Play a file URI through the configured stream backend. + * + * Centralizes one-shot playback mapping so `StreamPlayer` remains backend-agnostic. + * + * @param file_uri URI to play. + * @param volume Playback volume between 0.0 and 1.0. + * @param on_finished Optional callback fired on EOS/ERROR. + * @return Backend playback handle, or null when setup failed. + */ + public static GLib.Object? play_stream_file (string file_uri, double volume, owned StreamPlayer.FilePlaybackFinished? on_finished = null) + { + return GstStreamPlayer.play_file_backend (file_uri, volume, (owned) on_finished); + } // play_stream_file + +} // namespace Tuner diff --git a/src/Widgets/AboutDialog.vala b/src/Widgets/AboutDialog.vala index eb4544f6..e5d687f7 100644 --- a/src/Widgets/AboutDialog.vala +++ b/src/Widgets/AboutDialog.vala @@ -29,12 +29,14 @@ public class Tuner.Widgets.AboutDialog : Gtk.AboutDialog { artists = {"https://faleksandar.com/"}; authors = {"Louis Brauer, technosf"}; documenters = null; - translator_credits = """Estonian tranlation by Priit Jõerüüt + translator_credits = """Estonian tranlation by jrthwlate https://hosted.weblate.org/user/jrthwlate/ French translation by NathanBnm https://github.com/NathanBnm +and David D. https://hosted.weblate.org/user/dadu042 Italian translation by DevAlien https://github.com/DevAlien and albanobattistella https://github.com/albanobattistella Dutch translation by Vistaus https://github.com/Vistaus -Turkish translation by safak45x https://github.com/safak45x"""; +Turkish translation by safak45x https://github.com/safak45x +Swedish translation by bittin https://github.com/bittin"""; logo_icon_name = app().get_application_id ()+"-scalable"; // logo_icon_name = app().get_application_id (); program_name = "Tuner"; diff --git a/src/Widgets/CountryList.vala b/src/Widgets/CountryList.vala deleted file mode 100644 index 0f45680e..00000000 --- a/src/Widgets/CountryList.vala +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-or-later - * SPDX-FileCopyrightText: 2020-2022 Louis Brauer - */ - - using Tuner.Widgets.Base; -/** - * @class CountryList - * @brief A widget for displaying a list of countries. - * - * This class extends AbstractContentList to create a specialized list - * for displaying countries, potentially for selecting radio stations by country. - * - * @extends ListFlowBox - */ -public class Tuner.Widgets.CountryList : Base.ListFlowBox -{ - -/** - * @brief Constructs a new CountryList. - * - * Initializes the CountryList with specific layout properties. - */ - public CountryList () - { - Object ( - homogeneous: false, - min_children_per_line: 2, - max_children_per_line: 2, - column_spacing: 5, - row_spacing: 5, - border_width: 20, - valign: Gtk.Align.START, - selection_mode: Gtk.SelectionMode.NONE - ); - } - - /** - * @brief Initializes the CountryList with a sample button. - */ - construct { - var button = new Gtk.Button (); - button.label = "a country"; - - add (button); - } -} diff --git a/src/Widgets/Display.vala b/src/Widgets/Display.vala index 881a1463..3a9a3ca3 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; @@ -188,9 +189,9 @@ public class Tuner.Widgets.Display : Gtk.Paned, StationListHookup { jukebox_shuffle.begin(); }); - _app.events.state_changed_sig.connect((station, state) => + _app.events.player_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, () => { @@ -201,13 +202,21 @@ public class Tuner.Widgets.Display : Gtk.Paned, StationListHookup { }); - var tuner = new Gtk.Image.from_icon_name (BACKGROUND_TUNER, Gtk.IconSize.INVALID); + var tuner = new AnimatedTunerIcon (256, 256); tuner.opacity = BACKGROUND_OPACITY; _background_tuner.transition_duration = BACKGROUND_TRANSITION_TIME_MS; _background_tuner.transition_type = BACKGROUND_TRANSITION_TYPE; _background_tuner.reveal_child = true; _background_tuner.child = tuner; + _app.events.station_changed_sig.connect((station) => + { + string key = station.stationuuid != "" ? station.stationuuid : station.name; + uint hash = GLib.str_hash (key); + double norm = (double) (hash % 1000) / 999.0; + tuner.animate_to (norm); + }); + var jukebox = new Gtk.Image.from_icon_name (BACKGROUND_JUKEBOX, Gtk.IconSize.INVALID); jukebox.opacity = BACKGROUND_OPACITY; _background_jukebox.transition_duration = BACKGROUND_TRANSITION_TIME_MS; @@ -438,7 +447,8 @@ public class Tuner.Widgets.Display : Gtk.Paned, StationListHookup { "discover", "face-smile", _("Discover"), - _("Stations to Discover") + _("Stations to Discover"), + true ) { station_set = _directory.load_random_stations(20), action_tooltip_text = _("Discover more stations"), @@ -518,7 +528,7 @@ public class Tuner.Widgets.Display : Gtk.Paned, StationListHookup { "starred", "starred", _("Starred by You"), - _("Starred by You") + " :" + _("Starred by You") + " : " ) { station_list_hookup = this, stations = _directory.get_starred() @@ -812,7 +822,8 @@ public class Tuner.Widgets.Display : Gtk.Paned, StationListHookup { genre, "tuner:playlist-symbolic", genre, - genre + genre, + true ) { station_set = directory.load_by_tag (genre.down ()) } diff --git a/src/Widgets/HeaderBar.vala b/src/Widgets/HeaderBar.vala index 2baf7955..fd36812d 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; @@ -95,7 +96,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar private VolumeButton _volume_button = new VolumeButton(); - private Base.PlayerInfo _player_info; + private PlayingStationInfo _playing_station_info; /** @property {bool} starred - Station starred. */ private bool _starred = false; @@ -229,8 +230,8 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar pack_start (_play_button); pack_start (_heart_button); - _player_info = new Base.PlayerInfo(window, _player); - custom_title = _player_info; // Station display + _playing_station_info = new PlayingStationInfo(window, _player); + custom_title = _playing_station_info; // Station display // pack RHS pack_end (_prefs_button); @@ -256,14 +257,14 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar update_controls_state(); }); - _app.events.state_changed_sig.connect ((station, state) => + _app.events.player_state_changed_sig.connect ((station, state) => { update_controls_state(); }); update_controls_state(); - _player_info.info_changed_completed_sig.connect(() => + _playing_station_info.info_changed_completed_sig.connect(() => // _player_info is going to signal when it has completed and the lock can be released { if (!_station_locked) @@ -273,7 +274,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar }); - _app.events.metadata_changed_sig.connect ((station, metadata) => + _app.events.playback_metadata_changed_sig.connect ((station, metadata) => { _list_button.append_station_title_pair(station, metadata.title); _last_metadata_station = station; @@ -325,18 +326,19 @@ 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()) // Lock while changing the station to ensure single threading. // Lock is released when the info is updated on emit of info_changed_completed_sig - { - _station_locked = true; - //_player_info.metadata = STREAM_METADATA; + { + _station_locked = true; + //_player_info.metadata = STREAM_METADATA; + _playing_station_info.queue_station_transition (station); - Idle.add (() => - // Initiate the fade out on a non-UI thread + Idle.add (() => + // Initiate the fade out on a non-UI thread { if (_station_handler_id > 0) @@ -346,7 +348,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar _station_handler_id = 0; } - _player_info.change_station.begin(station, () => + _playing_station_info.change_station.begin(station, () => { _station = station; starred = _station.starred; @@ -375,12 +377,12 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar { base.realize(); - _player_info.transition_type = RevealerTransitionType.SLIDE_UP; // Optional: add animation - _player_info.set_transition_duration(REVEAL_DELAY*3); + _playing_station_info.transition_type = RevealerTransitionType.SLIDE_UP; // Optional: add animation + _playing_station_info.set_transition_duration(REVEAL_DELAY*3); // Use Timeout to delay the reveal animation Timeout.add(REVEAL_DELAY*3, () => { - _player_info.set_reveal_child(true); + _playing_station_info.set_reveal_child(true); return Source.REMOVE; }); } // realize @@ -390,7 +392,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar */ public void stream_info(bool show) { - _player_info.title_label.show_metadata = show; + _playing_station_info.title_label.show_metadata = show; } // stream_info @@ -398,10 +400,16 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar */ public void stream_info_fast(bool fast) { - _player_info.title_label.metadata_fast_cycle = fast; + _playing_station_info.title_label.metadata_fast_cycle = fast; } // stream_info_fast + public void stream_info_dynamic_shrink(bool enabled) + { + _playing_station_info.set_dynamic_shrink(enabled); + } // stream_info_dynamic_shrink + + /* Private */ @@ -413,12 +421,12 @@ 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) { - _player_info.favicon_image.opacity = 0.5; + _playing_station_info.favicon_image.opacity = 0.5; _tuner_status.online = false; _star_button.sensitive = false; _play_button.sensitive = is_playing_now; @@ -431,7 +439,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar else // Online - restore full functionality { - _player_info.favicon_image.opacity = 1.0; + _playing_station_info.favicon_image.opacity = 1.0; _tuner_status.online = true; _star_button.sensitive = true; _play_button.sensitive = true; diff --git a/src/Widgets/MetadataImagePopup.vala b/src/Widgets/MetadataImagePopup.vala index 982226c0..9e172da8 100644 --- a/src/Widgets/MetadataImagePopup.vala +++ b/src/Widgets/MetadataImagePopup.vala @@ -63,7 +63,7 @@ public class Tuner.Widgets.MetadataImagePopup : Gtk.Window hide(); - app().events.metadata_changed_sig.connect((station, metadata) => + app().events.playback_metadata_changed_sig.connect((station, metadata) => { handle_metadata(metadata); }); @@ -92,7 +92,7 @@ public class Tuner.Widgets.MetadataImagePopup : Gtk.Window * * @param metadata The latest metadata payload. */ - private void handle_metadata(Metadata metadata) + private void handle_metadata(StreamMetadata metadata) { if (!_enabled) { @@ -130,7 +130,7 @@ public class Tuner.Widgets.MetadataImagePopup : Gtk.Window * @param metadata The metadata payload to inspect. * @return A URL string or an empty string if none found. */ - private string extract_image_url(Metadata metadata) + private string extract_image_url(StreamMetadata metadata) { if (metadata.image != null && metadata.image.strip() != "") return metadata.image.strip(); diff --git a/src/Widgets/PlayButton.vala b/src/Widgets/PlayButton.vala index 6403fbaa..39ed1f92 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(); @@ -62,47 +63,52 @@ public class Tuner.Widgets.PlayButton : Gtk.Button image = PLAY; sensitive = true; - app().events.state_changed_sig.connect ((station, state) => - // Link the button image to the inverse of the player state + app().events.player_state_changed_sig.connect ((station, state) => + // Link the button image to the inverse of the player state { 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. + * The app-level event bus invokes handlers on the main loop, so + * UI updates are safe to apply synchronously here. + * + * @param state The new play state enum. + */ + private void set_inverse_symbol (StreamPlayer.State state) { + + tooltip_text = null; 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; + string? error_message = app().player.play_error_message; // TODO Use signals? + if (error_message == null || error_message.strip () == "") + error_message = "An error occurred during playback."; + tooltip_text = error_message; break; default: // STOPPED: image = PLAY; image.opacity = 1.0; break; - } - } // set_reverse_symbol -} // PlayButton + } // switch + } // set_reverse_symbol +} // PlayButton diff --git a/src/Widgets/PlayingStationInfo.vala b/src/Widgets/PlayingStationInfo.vala new file mode 100644 index 00000000..d1fbf7aa --- /dev/null +++ b/src/Widgets/PlayingStationInfo.vala @@ -0,0 +1,499 @@ +/** + * SPDX-FileCopyrightText: Copyright © 2020-2024 Louis Brauer + * SPDX-FileCopyrightText: Copyright © 2024 technosf + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @file PlayingStationInfo.vala + * + * @brief PlayingStationInfo widget + * + */ + + +using Gtk; +using Gdk; +using Tuner.Widgets.Base; +using Tuner.Controllers; +using Tuner.Models; + +/** + * @class Tuner.Widgets.PlayingStationInfo + * @brief Displays station name, artwork and stream metadata for playing station. + * + * Provides a reveal-based transition when stations change and exposes + * helper hooks for metadata updates and popover display. + */ +public class Tuner.Widgets.PlayingStationInfo : Revealer +{ + private const bool TRACE_METADATA_PATH = false; + + private const string DEFAULT_ICON_NAME = "tuner:internet-radio-symbolic"; + private const uint REVEAL_DELAY = 400u; + private const uint STATION_CHANGE_SETTLE_DELAY_MS = 1200u; + private const string PLACEHOLDER = _("Stream Metadata"); + private const int CONTENT_SPACING_PX = 10; + private const int CONTENT_MARGIN_PX = 24; + private const int ICON_FALLBACK_WIDTH_PX = 48; + + /** 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_string; } + internal set { _metadata_string = value; } + } // metadata + + + private string _metadata_string; + private Station _station; + private Gtk.Popover _metadata_popover; + 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 StreamMetadata? _pending_metadata = null; + private Gtk.Box _text_lane; + + /** + * @brief Emitted after station transition visuals complete. + */ + internal signal void info_changed_completed_sig(); + + /** + * @brief Creates a new PlayerInfo widget. + * + * @param window Parent window hosting the widget. + * @param player Player controller. + */ + public PlayingStationInfo(Window window, PlayerController player) + { + Object(); + + transition_duration = REVEAL_DELAY; + transition_type = RevealerTransitionType.CROSSFADE; + + station_label = new Label("Tuner"); + station_label.get_style_context().add_class("station-label"); + station_label.ellipsize = Pango.EllipsizeMode.MIDDLE; + station_label.halign = Align.CENTER; + + title_label = new CyclingRevealLabel(window, 100); + title_label.get_style_context().add_class("track-info"); + title_label.halign = Align.CENTER; + title_label.valign = Align.CENTER; + title_label.hexpand = false; + title_label.show_metadata = window.settings.stream_info; + title_label.metadata_fast_cycle = window.settings.stream_info_fast; + title_label.dynamic_shrink = window.settings.stream_info_dynamic_shrink; + title_label.set_max_width_px (320); + + _text_lane = new Gtk.Box (Orientation.VERTICAL, 6); + _text_lane.halign = Align.CENTER; + _text_lane.hexpand = false; + _text_lane.pack_start (station_label, false, false, 0); + _text_lane.pack_start (title_label, false, false, 0); + + var content_row = new Gtk.Box (Orientation.HORIZONTAL, CONTENT_SPACING_PX); + content_row.halign = Align.CENTER; + content_row.valign = Align.CENTER; + content_row.hexpand = false; + content_row.pack_start (favicon_image, false, false, 0); + content_row.pack_start (_text_lane, false, false, 0); + + var centered_lane = new Gtk.Box (Orientation.HORIZONTAL, 0); + centered_lane.halign = Align.CENTER; + centered_lane.valign = Align.CENTER; + centered_lane.hexpand = true; + centered_lane.pack_start (content_row, false, false, 0); + + add(centered_lane); + reveal_child = false; + + size_allocate.connect ((allocation) => + { + update_title_width_bound (allocation.width); + }); + + metadata = PLACEHOLDER; + + /* + Hook up title to metadata as a delayed popover. + */ + add_events(EventMask.ENTER_NOTIFY_MASK | EventMask.LEAVE_NOTIFY_MASK); + enter_notify_event.connect((event) => + { + if (_hover_timeout_id > 0 || _popover_visible) + return false; + _hover_timeout_id = Timeout.add(1000, () => + { + _hover_timeout_id = 0; + show_metadata_popover(); + return Source.REMOVE; + }); + return false; + }); + + leave_notify_event.connect((event) => + { + if (_hover_timeout_id > 0) + { + Source.remove(_hover_timeout_id); + _hover_timeout_id = 0; + } + return false; + }); + + window.add_events(EventMask.BUTTON_PRESS_MASK); + window.button_press_event.connect((event) => + { + if (_popover_visible) + hide_metadata_popover(); + return false; + }); + + app().events.playback_metadata_changed_sig.connect(handle_metadata_changed); + } // constructor + + + /** + * @brief Bound metadata label width to current available widget width. + */ + private void update_title_width_bound (int total_width) + { + int icon_width = favicon_image.get_allocated_width (); + if (icon_width <= 0) + icon_width = ICON_FALLBACK_WIDTH_PX; + + int max_title_width = total_width - icon_width - CONTENT_SPACING_PX - CONTENT_MARGIN_PX; + if (max_title_width < 100) + max_title_width = 100; + + title_label.set_max_width_px (max_title_width); + } // update_title_width_bound + + + /** + * @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; + queue_station_transition (station); + + Idle.add(() => + { + Timeout.add(5 * REVEAL_DELAY / 3, () => + { + favicon_image.clear(); + title_label.clear(); + station_label.label = ""; + _metadata_string = PLACEHOLDER; + return Source.REMOVE; + }); + + Timeout.add(STATION_CHANGE_SETTLE_DELAY_MS, () => + { + station.update_favicon_image.begin( + favicon_image, + true, + DEFAULT_ICON_NAME, + () => + { + _station = station; + station_label.label = station.name; + + 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(); + } + ); + + return Source.REMOVE; + }); + + return Source.REMOVE; + }, Priority.HIGH_IDLE); + } // change_station + + + /** + * @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, StreamMetadata metadata) + { + if (TRACE_METADATA_PATH) + { + stdout.printf ( + "[TRACE][PlayingStationInfo] received station=%s current=%s pending=%s transitioning=%s title='%s'\n", + station.stationuuid, + _station != null ? _station.stationuuid : "", + _pending_station != null ? _pending_station.stationuuid : "", + _transitioning ? "true" : "false", + metadata.title + ); + } + + if (_transitioning) + { + if (is_same_station(station, _pending_station)) + { + if (TRACE_METADATA_PATH) + stdout.printf ("[TRACE][PlayingStationInfo] queued pending metadata for station=%s\n", station.stationuuid); + _pending_metadata = metadata; + } + else if (TRACE_METADATA_PATH) + { + stdout.printf ("[TRACE][PlayingStationInfo] dropped during transition (station mismatch)\n"); + } + return; + } + + if (_station != null && !is_same_station(station, _station)) + { + if (TRACE_METADATA_PATH) + stdout.printf ("[TRACE][PlayingStationInfo] dropped (current station mismatch)\n"); + return; + } + + if (_metadata_string == metadata.pretty_print) + { + if (TRACE_METADATA_PATH) + stdout.printf ("[TRACE][PlayingStationInfo] dropped (unchanged pretty metadata)\n"); + return; + } + + apply_metadata(metadata); + } // handle_metadata_changed + + + /** + * @brief Mark a station transition as active so early metadata can be queued. + * + * Preserves already queued metadata for the same station. + */ + internal void queue_station_transition (Station station) + { + _transitioning = true; + if (!is_same_station (station, _pending_station)) + { + _pending_station = station; + _pending_metadata = null; + } + } // queue_station_transition + + + /** + * @brief Compares two stations by identity key. + * + * Uses station UUID to allow matching equivalent station instances. + */ + private bool is_same_station(Station? left, Station? right) + { + if (left == null || right == null) + return false; + return left.stationuuid == right.stationuuid; + } // is_same_station + + + /** + * @brief Applies a metadata payload to the UI. + * + * @param metadata Metadata payload. + */ + private void apply_metadata(StreamMetadata metadata) + { + if (TRACE_METADATA_PATH) + { + stdout.printf ( + "[TRACE][PlayingStationInfo] apply title='%s' pretty_len=%u\n", + metadata.title, + metadata.pretty_print.length + ); + } + + _metadata_string = metadata.pretty_print; + title_label.notify_metadata_changed (); + + title_label.add_sublabel(1, metadata.genre, metadata.homepage); + title_label.add_sublabel(2, metadata.audio_info); + title_label.add_sublabel(3, metadata.org_loc); + + if (!title_label.set_text(metadata.title)) + { + Timeout.add_seconds(3, () => + { + title_label.set_text(metadata.title); + return Source.REMOVE; + }); + } + + if (_popover_visible) + update_metadata_popover_text(); + } // apply_metadata + + + /** + * @brief Set whether the title label can shrink between metadata updates. + */ + public void set_dynamic_shrink (bool enabled) + { + title_label.dynamic_shrink = enabled; + } // set_dynamic_shrink + + + /** + * @brief Shows the metadata popover for the current station. + */ + private void show_metadata_popover() + { + if (_station == null) + return; + + if (_metadata_popover == null) + { + _metadata_popover = new Gtk.Popover(this); + _metadata_popover.position = Gtk.PositionType.BOTTOM; + _metadata_popover.set_border_width(8); + _metadata_popover.get_style_context().add_class("metadata-popover"); + _metadata_popover.add_events(EventMask.BUTTON_PRESS_MASK); + _metadata_popover.button_press_event.connect((event) => + { + if (event.button == 3) + { + copy_metadata_to_clipboard(); + return true; + } + return false; + }); + + _metadata_label = new Gtk.Label(""); + _metadata_label.wrap = true; + _metadata_label.max_width_chars = 48; + _metadata_label.xalign = 0.0f; + _metadata_label.get_style_context().add_class("metadata-label"); + _metadata_popover.add(_metadata_label); + _metadata_popover.show_all(); + _metadata_popover.hide(); + } + + update_metadata_popover_text(); + _metadata_popover.show(); + _popover_visible = true; + } // show_metadata_popover + + + /** + * @brief Hides the metadata popover if visible. + */ + private void hide_metadata_popover() + { + if (_metadata_popover != null) + _metadata_popover.hide(); + _popover_visible = false; + } // hide_metadata_popover + + + /** + * @brief Returns the metadata blurb for the current station + */ + private string blurb() + { + if (_station == null) + return ""; + + StringBuilder sb = new StringBuilder(); + if (_station.starred) + sb.append("\u2605 "); + sb.append(_station.name) + .append("\n\n") + .append(_station.popularity()) + .append("\n\n") + .append(_station.locale()) + .append("\n\n"); + return sb.str; + } // blurb + + + /** + * @brief Updates the metadata popover contents. + */ + private void update_metadata_popover_text() + { + if (_metadata_label == null) + return; + + string preamble = blurb(); + var text = _station != null ? @"$preamble$metadata" : PLACEHOLDER; + _metadata_label.set_text(text); + } // update_metadata_popover_text + + + /** + * @brief Copies the current metadata text to the clipboard. + */ + private void copy_metadata_to_clipboard() + { + string preamble = blurb(); + var text = _station != null ? @"$preamble$metadata" : PLACEHOLDER; + var clipboard = Gtk.Clipboard.get_default(Gdk.Display.get_default()); + if (clipboard != null) + { + clipboard.set_text(text, -1); + show_copy_confirmation(); + } + } // copy_metadata_to_clipboard + + + /** + * @brief Shows a short "Copied to clipboard" confirmation. + */ + private void show_copy_confirmation() + { + if (_metadata_popover == null) + return; + + var original_text = _metadata_label != null ? _metadata_label.get_text() : ""; + _metadata_label.set_text(_("Copied to clipboard")); + _metadata_popover.show(); + _popover_visible = true; + + Timeout.add(1200, () => + { + _metadata_label.set_text(original_text); + if (!_popover_visible) + _metadata_popover.hide(); + return Source.REMOVE; + }); + } // show_copy_confirmation +} // PlayerInfo diff --git a/src/Widgets/PreferencesPopover.vala b/src/Widgets/PreferencesPopover.vala index 9dae4b20..0703336b 100644 --- a/src/Widgets/PreferencesPopover.vala +++ b/src/Widgets/PreferencesPopover.vala @@ -90,6 +90,12 @@ public class Tuner.Widgets.PreferencesPopover : Gtk.Popover stream_info_image_popup.tooltip_text = _("Show a movable popup with images discovered in the stream metadata"); stream_info_image_popup.margin_start = ROW_INDENT; + var stream_info_dynamic_shrink = new Gtk.ModelButton (); + stream_info_dynamic_shrink.text = _("Shrink title dynamically with the text"); + stream_info_dynamic_shrink.action_name = Window.ACTION_PREFIX + Window.ACTION_STREAM_INFO_DYNAMIC_SHRINK; + stream_info_dynamic_shrink.tooltip_text = _("Shrink the title according to the length of the displayed text."); + stream_info_dynamic_shrink.margin_start = ROW_INDENT; + /* Enable in-app language selection for local debug or if specifically set in build options @@ -159,6 +165,7 @@ public class Tuner.Widgets.PreferencesPopover : Gtk.Popover menu_grid.attach (stream_info, 0, vpos++, 4, 1); menu_grid.attach (stream_info_fast, 0, vpos++, 4, 1); menu_grid.attach (stream_info_image_popup, 0, vpos++, 4, 1); + menu_grid.attach (stream_info_dynamic_shrink, 0, vpos++, 4, 1); menu_grid.attach (new Gtk.SeparatorMenuItem (), 0, vpos++, 4, 1); diff --git a/src/Widgets/StationContextMenu.vala b/src/Widgets/StationContextMenu.vala index 54907456..8f46def1 100644 --- a/src/Widgets/StationContextMenu.vala +++ b/src/Widgets/StationContextMenu.vala @@ -47,27 +47,13 @@ public class Tuner.Widgets.StationContextMenu : Gtk.Menu _station = station_button.station; // Name - var name = new Gtk.MenuItem.with_label (station_button.station.name); + var name = new Gtk.MenuItem.with_label (_station.name); name.sensitive = false; append (name); - - // Country - if ( _station.countrycode != null && _station.countrycode.length > 0 ) + if ( _station.locale() != null && _station.locale() != "") { - var sb = new StringBuilder (Countries.get_by_code(_station.countrycode) + "\n"); - if ( _station.state != null && _station.state.length > 0 ) - sb.append (_station.state).append ("\t"); - - // Language - if ( station_button.station.language != null && station_button.station.language.length > 0 ) - { - sb.append ("[") - .append (Languages.get_by_code ( station_button.station.languagecodes, station_button.station.language)) - .append ("]"); - } - - var info = new Gtk.MenuItem.with_label (sb.str); + var info = new Gtk.MenuItem.with_label (_station.locale()); info.sensitive = false; append (info); } @@ -78,7 +64,7 @@ public class Tuner.Widgets.StationContextMenu : Gtk.Menu // ---------------------------------------------- - var popularity = new Gtk.MenuItem.with_label (station_button.station.popularity ()); + var popularity = new Gtk.MenuItem.with_label (_station.popularity ()); popularity.sensitive = false; append (popularity); diff --git a/src/Widgets/TitleBox.vala b/src/Widgets/TitleBox.vala index 41d6976a..2a7d2fca 100644 --- a/src/Widgets/TitleBox.vala +++ b/src/Widgets/TitleBox.vala @@ -154,6 +154,11 @@ public class Tuner.Widgets.TitleBox : Gtk.Box _headerbar.stream_info_fast(fast); } + public void stream_info_dynamic_shrink(bool enabled) + { + _headerbar.stream_info_dynamic_shrink(enabled); + } + public bool update_playing_station(Station station) { return _headerbar.update_playing_station(station); diff --git a/src/Widgets/Window.vala b/src/Widgets/Window.vala index a4bc1815..b0852a7c 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; /** @@ -55,6 +56,7 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow public const string ACTION_STREAM_INFO = "action_stream_info"; public const string ACTION_STREAM_INFO_FAST = "action_stream_info_fast"; public const string ACTION_STREAM_INFO_IMAGE_POPUP = "action_stream_info_image_popup"; + public const string ACTION_STREAM_INFO_DYNAMIC_SHRINK = "action_stream_info_dynamic_shrink"; public Settings settings { get; construct; } @@ -93,6 +95,7 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow { ACTION_STREAM_INFO, on_action_stream_info, null, "true" }, { ACTION_STREAM_INFO_FAST, on_action_stream_info_fast, null, "false" }, { ACTION_STREAM_INFO_IMAGE_POPUP, on_action_stream_info_image_popup, null, "false" }, + { ACTION_STREAM_INFO_DYNAMIC_SHRINK, on_action_stream_info_dynamic_shrink, null, "false" }, }; /* @@ -220,6 +223,7 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow _metadata_image_popup.set_enabled(settings.stream_info_image_popup); _title = new TitleBox(app_ref, this, player_ctrl, app_ref.provider); + _title.stream_info_dynamic_shrink(settings.stream_info_dynamic_shrink); _title.search_has_focus_sig.connect (() => // Show searched stack when cursor hits search text area @@ -275,7 +279,8 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow change_action_state (ACTION_STREAM_INFO, settings.stream_info); change_action_state (ACTION_STREAM_INFO_FAST, settings.stream_info_fast); change_action_state (ACTION_STREAM_INFO_IMAGE_POPUP, settings.stream_info_image_popup); - } + change_action_state (ACTION_STREAM_INFO_DYNAMIC_SHRINK, settings.stream_info_dynamic_shrink); + } // sync_action_states_from_settings /** @@ -302,7 +307,7 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow on_changed(enabled); debug (@"$debug_name: $(enabled ? "enabled" : "disabled")"); - } + } // toggle_setting_action /** @@ -451,6 +456,24 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow } // on_action_stream_info_image_popup + /** + * @brief Handles metadata-title shrink policy changes. + * + * @param action The SimpleAction that triggered this method. + * @param parameter The parameter passed with the action (unused). + */ + public void on_action_stream_info_dynamic_shrink (SimpleAction action, Variant? parameter) + { + toggle_setting_action( + action, + "on_action_stream_info_dynamic_shrink", + () => { return settings.stream_info_dynamic_shrink; }, + (value) => { settings.stream_info_dynamic_shrink = value; }, + (value) => { _title.stream_info_dynamic_shrink(value); } + ); + } // on_action_stream_info_dynamic_shrink + + // ---------------------------------------------------------------------- // @@ -491,7 +514,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/CyclingRevealLabel.vala b/src/Widgets/base/CyclingRevealLabel.vala index ed0e9c51..824f95b4 100644 --- a/src/Widgets/base/CyclingRevealLabel.vala +++ b/src/Widgets/base/CyclingRevealLabel.vala @@ -23,10 +23,11 @@ public class Tuner.Widgets.Base.CyclingRevealLabel : RevealLabel { private const int SUBTITLE_MIN_DISPLAY_SECONDS = 3; - private const int LABEL_WIDTH_MIN = 100; - private const int LABEL_RESIZE_BUFFER = 10; - // private const int DISPLAY_WIDTH_OFFSET = 761; - // private const int BORDER_WIDTH_OFFSET = 7; + private const int WIDTH_PADDING_PX = 24; + private const uint WIDTH_ANIMATION_INTERVAL_MS = 16; + private const int DEFAULT_MAX_LABEL_WIDTH = 420; + private const uint WIDTH_ANIMATION_MIN_MS = 120; + private const uint WIDTH_ANIMATION_MAX_MS = 360; public bool show_metadata { get; set; } @@ -49,19 +50,26 @@ public class Tuner.Widgets.Base.CyclingRevealLabel : RevealLabel { } // set } // metadata_fast_cycle - private signal void flourish_complete_sig(); + public bool dynamic_shrink { + get { return _dynamic_shrink; } + set { _dynamic_shrink = value; } + } // dynamic_shrink private bool _metadata_fast_cycle; - private int _last_parent_width = 0; // tracks window width private int _window_width_previous = 0; // tracks window width - private int _parent_unused_growth = 0; // Tracks the maximum width the label can occupy - private int _min_label_width; // Minimum label width - private int _peak_label_width; // Peak label width - private bool _followed_width_change; // Followed width - // private int _current_label_width; // Current label width + private bool _dynamic_shrink = false; + private bool _metadata_changed_since_last_set = true; private uint _label_cycle_id = 0; - private uint _flourish_id = 0; + private uint _width_animation_id = 0; private int _min_count_down; + private int _min_label_width; + private int _current_width; + private int _target_width; + private int _max_label_width = DEFAULT_MAX_LABEL_WIDTH; + private int _animation_start_width; + private int _animation_end_width; + private int64 _animation_start_us; + private uint _animation_duration_ms; private uint16 _display_seconds = 0; // Mix up the cycle phase start point private uint16[] _cycle_phases_fast = {5,11,17,19,23}; // Fast cycle times - primes so everyone gets a chance private uint16[] _cycle_phases_slow = {23,37,43,47,53}; // Title, plus four subtitles @@ -70,60 +78,22 @@ public class Tuner.Widgets.Base.CyclingRevealLabel : RevealLabel { private Gee.Map sublabels = new Gee.HashMap(); - public CyclingRevealLabel (Widget follow, int min_label_width, string? str = null) + public CyclingRevealLabel (Widget _follow, int min_label_width, string? str = null) { Object(); label_child.set_line_wrap(false); label_child.set_justify(Justification.CENTER); base.label_child.set_text( str); - - _min_label_width = min_label_width; - - follow.size_allocate.connect((widget, allocation) => - { - if ( _last_parent_width == 0 ) _last_parent_width = allocation.width; - var delta = allocation.width - _last_parent_width; - - if ( delta == 0 ) return; - - if ( delta > 0 ) - // Growing parent - { - _parent_unused_growth += delta; - } - else - // Shrinking parent - { - if ( -delta <= _parent_unused_growth) - // Track the delta back - { - debug(@"Shrink delta: $delta _parent_unused_growth: $_parent_unused_growth"); - _parent_unused_growth += delta; - return; - } - - debug(@"Delta: $delta Peak old: $_peak_label_width new: $(_peak_label_width + delta + _parent_unused_growth) follow: $_last_parent_width"); - _peak_label_width += ((2*delta) + _parent_unused_growth); - _peak_label_width = int.max(_peak_label_width, LABEL_WIDTH_MIN); - _parent_unused_growth = 0; - set_size_request(_peak_label_width, -1); - _followed_width_change = true; - } - _last_parent_width = allocation.width; - }); + // Width is animated by text content and bounded by parent width. + hexpand = false; + halign = Align.CENTER; - size_allocate.connect((allocation) => - { - if (!_followed_width_change && allocation.width <= ( _peak_label_width + LABEL_RESIZE_BUFFER )) - { - _followed_width_change = false; - return; - } - debug(@"Size Alloc: $(allocation.width) Peak: $(_peak_label_width)"); - _peak_label_width = int.max(_peak_label_width,allocation.width - LABEL_RESIZE_BUFFER); - set_size_request(9*_peak_label_width/10, -1); - }); + _min_label_width = min_label_width; + _current_width = min_label_width; + _target_width = min_label_width; + set_size_request (_min_label_width, -1); + // Caller controls max width via `set_max_width_px`. _cycle_phases = _cycle_phases_fast; } // CyclingRevealLabel @@ -142,27 +112,19 @@ public class Tuner.Widgets.Base.CyclingRevealLabel : RevealLabel { public new bool set_text( string text ) { if ( text == base.get_text() ) return true; - - - // Make the peak width smaller than allocated by the apparent size of the boarder, plus a fudge - // _peak_label_width = int.max(_peak_label_width,get_allocated_width()-BORDER_WIDTH_OFFSET); debug(@"CL set text: $(base.get_text()) > $text"); if ( base.set_text(text) ) { - - debug(@"CL set text - Success: $text"); - // Measure the natural width of the label with the new text - // int min_width, natural_width; - // get_preferred_width(out min_width, out natural_width); - - // // Update _max_label_width only if the new text exceeds it - // if (natural_width > _max_label_width) { - // _max_label_width = natural_width; - // } - - // // Apply the new width constraints - // update_size( ); + int target_width = measure_target_width (text); + if (!_dynamic_shrink + && !_metadata_changed_since_last_set + && target_width < _current_width) + { + target_width = _current_width; + } + animate_width_to (target_width); + _metadata_changed_since_last_set = false; return true; } @@ -171,49 +133,29 @@ public class Tuner.Widgets.Base.CyclingRevealLabel : RevealLabel { } // label - // /** - // */ - // private void update_size(bool flourish = true) - // { - // if ( _flourish_id > 0 ) - // { - // Source.remove(_flourish_id); - // _flourish_id = 0; - // } - - // // var size = int.max(int.min(_max_label_width, _peak_label_width), _min_label_width ); - // // if ( size == _current_label_width ) return; - // // if ( !flourish || size == _min_label_width ) - // // { - // // set_size_request( size, -1); - // // _current_label_width = size; - // // return; - // // } + /** + * @brief Marks that a new metadata payload was applied. + */ + public void notify_metadata_changed () + { + _metadata_changed_since_last_set = true; + } // notify_metadata_changed - // // Flourish - - // // Idle.add (() => - // // // Initiate the fade out in another thread - // // { - // // _flourish_id = Timeout.add_full(Priority.DEFAULT, 3, () => - // // { - // // if ( _current_label_width >= size ) - // // { - // // _flourish_id = 0; - // // flourish_complete_sig(); - // // return Source.REMOVE; - // // } - - // // _current_label_width++;// += 4; - // // set_size_request( _current_label_width, -1); - - // // return Source.CONTINUE; // Leave timer to be recalled - // // }); - // // debug(@"Flourish target: $size from: $_current_label_width id: $_flourish_id"); - // // return Source.REMOVE; - // // }); - // } // update_size + /** + * @brief Set the hard maximum width for this label. + * + * Long text is ellipsized beyond this width. + */ + public void set_max_width_px (int max_width) + { + if (max_width < _min_label_width) + max_width = _min_label_width; + if (_max_label_width == max_width) + return; + _max_label_width = max_width; + animate_width_to (measure_target_width (base.get_text ())); + } // set_max_width_px /** @@ -267,10 +209,10 @@ public class Tuner.Widgets.Base.CyclingRevealLabel : RevealLabel { _label_cycle_id = 0; } - if ( _flourish_id > 0 ) + if ( _width_animation_id > 0 ) { - Source.remove(_flourish_id); - _flourish_id = 0; + Source.remove(_width_animation_id); + _width_animation_id = 0; } } // stop @@ -284,8 +226,7 @@ public class Tuner.Widgets.Base.CyclingRevealLabel : RevealLabel { stop(); base.clear(); sublabels.clear(); - _peak_label_width = 0; - set_size_request(LABEL_WIDTH_MIN, -1); + animate_width_to (_min_label_width); } // clear @@ -338,5 +279,84 @@ public class Tuner.Widgets.Base.CyclingRevealLabel : RevealLabel { return Source.REMOVE; }); } // cycle -} // CyclingRevealLabel + + private int measure_target_width (string text) + { + int max_width = _max_label_width; + + if (text == null || text.strip ().length == 0) + return _min_label_width; + + var layout = label_child.create_pango_layout (text); + int text_width = 0; + int text_height = 0; + layout.get_pixel_size (out text_width, out text_height); + + int desired_width = text_width + WIDTH_PADDING_PX; + if (desired_width < _min_label_width) + desired_width = _min_label_width; + if (desired_width > max_width) + desired_width = max_width; + + return desired_width; + } // measure_target_width + + + private void animate_width_to (int width) + { + _target_width = width; + if (_target_width == _current_width) + { + if (_width_animation_id > 0) + { + Source.remove (_width_animation_id); + _width_animation_id = 0; + } + return; + } + + _animation_start_width = _current_width; + _animation_end_width = _target_width; + _animation_start_us = GLib.get_monotonic_time (); + int distance = _animation_end_width - _animation_start_width; + if (distance < 0) + distance = -distance; + distance = int.max (1, distance); + _animation_duration_ms = (uint) int.min ( + (int) WIDTH_ANIMATION_MAX_MS, + int.max ((int) WIDTH_ANIMATION_MIN_MS, distance * 2) + ); + + if (_width_animation_id > 0) + return; + + _width_animation_id = Timeout.add (WIDTH_ANIMATION_INTERVAL_MS, () => + { + int64 elapsed_us = GLib.get_monotonic_time () - _animation_start_us; + double progress = (double) elapsed_us / ((double) _animation_duration_ms * 1000.0); + if (progress >= 1.0) + progress = 1.0; + if (progress < 0.0) + progress = 0.0; + + // Smoothstep easing to reduce visible snap while shrinking. + double eased = progress * progress * (3.0 - 2.0 * progress); + _current_width = (int) Math.round ( + _animation_start_width + ((_animation_end_width - _animation_start_width) * eased) + ); + + set_size_request (_current_width, -1); + + if (progress >= 1.0) + { + _current_width = _animation_end_width; + set_size_request (_current_width, -1); + _width_animation_id = 0; + return Source.REMOVE; + } + + return Source.CONTINUE; + }); + } // animate_width_to +} // CyclingRevealLabel diff --git a/src/Widgets/base/HistoryList.vala b/src/Widgets/base/HistoryList.vala index ce328231..8af74eab 100644 --- a/src/Widgets/base/HistoryList.vala +++ b/src/Widgets/base/HistoryList.vala @@ -22,18 +22,43 @@ public class Tuner.Widgets.Base.HistoryEntry : GLib.Object } } +/** + * @brief Tracks a linear history of station/title entries. + * + * Emits signals when entries are added/removed or when the list is cleared. + * Consecutive duplicate entries are ignored, and empty titles can be replaced + * in-place when later metadata arrives. + */ public class Tuner.Widgets.Base.HistoryList : GLib.Object { + /** + * @brief Emitted after an entry is appended. + */ public signal void entry_added_sig(HistoryEntry entry); + /** + * @brief Emitted after an entry is removed. + */ public signal void entry_removed_sig(HistoryEntry entry); + /** + * @brief Emitted after the list is cleared. + */ public signal void cleared_sig(); private Gee.List _entries = new Gee.ArrayList(); private HistoryEntry _last_entry = null; + /** + * @brief Current list of history entries, in chronological order. + */ public Gee.List entries { get { return _entries; } } + /** + * @brief Most recent entry, or null when empty. + */ public HistoryEntry last_entry { get { return _last_entry; } } + /** + * @brief Remove all entries and emit per-entry removal plus a clear signal. + */ public void clear() { foreach (var entry in _entries) @@ -43,6 +68,11 @@ public class Tuner.Widgets.Base.HistoryList : GLib.Object cleared_sig(); } + /** + * @brief Append a new entry unless it duplicates the latest entry. + * + * If the last entry has the same station and an empty title, it is replaced. + */ public void append(Station station, string title) { if (_last_entry != null && _last_entry.station == station && _last_entry.title == title) @@ -57,6 +87,11 @@ public class Tuner.Widgets.Base.HistoryList : GLib.Object entry_added_sig(entry); } + /** + * @brief Replace the last entry when it matches a station and title. + * + * @return true when a replacement occurred; otherwise false. + */ public bool replace_last_if_matches(Station station, string title_to_match, string replacement_title) { if (_last_entry == null) @@ -69,6 +104,11 @@ public class Tuner.Widgets.Base.HistoryList : GLib.Object return true; } + /** + * @brief Return titles with the heart prefix stripped. + * + * Only titles prefixed with "♥ " are included, and empty results are skipped. + */ public Gee.List get_hearted_titles() { var results = new Gee.ArrayList(); diff --git a/src/Widgets/base/PlayerInfo.vala b/src/Widgets/base/PlayerInfo.vala deleted file mode 100644 index 31ea99cc..00000000 --- a/src/Widgets/base/PlayerInfo.vala +++ /dev/null @@ -1,292 +0,0 @@ -/** - * SPDX-FileCopyrightText: Copyright © 2020-2024 Louis Brauer - * SPDX-FileCopyrightText: Copyright © 2024 technosf - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * @file PlayerInfo.vala - * - * @brief PlayerInfo widget - * - */ - - -using Gtk; -using Gdk; -using Tuner.Controllers; -using Tuner.Models; - -/** - * PlayerInfo widget for displaying station and track information. - */ -public class Tuner.Widgets.Base.PlayerInfo : Revealer -{ - private const string DEFAULT_ICON_NAME = "tuner:internet-radio-symbolic"; - private const uint REVEAL_DELAY = 400u; - private const uint STATION_CHANGE_SETTLE_DELAY_MS = 1200u; - private const string STREAM_METADATA = _("Stream Metadata"); - - public Label station_label { get; private set; } - public CyclingRevealLabel title_label { get; private set; } - //public StationContextMenu menu { get; private set; } - - public Image favicon_image = new Image.from_icon_name(DEFAULT_ICON_NAME, IconSize.DIALOG); - - public string metadata { - get { return _metadata; } - internal set { _metadata = value; } - } - - private string _metadata; - private Station _station; - private uint grid_min_width = 0; - private Gtk.Popover _metadata_popover; - private Gtk.Label _metadata_label; - private uint _hover_timeout_id = 0; - private bool _popover_visible = false; - - internal signal void info_changed_completed_sig(); - - /** - * Creates a new PlayerInfo widget. - * - * @param window Parent window - * @param player Player controller - */ - public PlayerInfo(Window window, PlayerController player) - { - Object(); - - transition_duration = REVEAL_DELAY; - transition_type = RevealerTransitionType.CROSSFADE; - - station_label = new Label("Tuner"); - station_label.get_style_context().add_class("station-label"); - station_label.ellipsize = Pango.EllipsizeMode.MIDDLE; - - title_label = new CyclingRevealLabel(window, 100); - title_label.get_style_context().add_class("track-info"); - title_label.halign = Align.CENTER; - title_label.valign = Align.CENTER; - title_label.show_metadata = window.settings.stream_info; - title_label.metadata_fast_cycle = window.settings.stream_info_fast; - - var station_grid = new Grid(); - station_grid.column_spacing = 10; - station_grid.set_halign(Align.FILL); - station_grid.set_valign(Align.CENTER); - - station_grid.attach(favicon_image, 0, 0, 1, 2); - station_grid.attach(station_label, 1, 0, 1, 1); - station_grid.attach(title_label, 1, 1, 1, 1); - - station_grid.size_allocate.connect((allocate) => - { - if (grid_min_width == 0) - grid_min_width = allocate.width; - }); - - add(station_grid); - reveal_child = false; - - metadata = STREAM_METADATA; - - /* - Hook up title to metadata as a delayed popover. - */ - add_events(EventMask.ENTER_NOTIFY_MASK | EventMask.LEAVE_NOTIFY_MASK); - enter_notify_event.connect((event) => - { - if (_hover_timeout_id > 0 || _popover_visible) - return false; - _hover_timeout_id = Timeout.add(1000, () => - { - _hover_timeout_id = 0; - show_metadata_popover(); - return Source.REMOVE; - }); - return false; - }); - - leave_notify_event.connect((event) => - { - if (_hover_timeout_id > 0) - { - Source.remove(_hover_timeout_id); - _hover_timeout_id = 0; - } - return false; - }); - - window.add_events(EventMask.BUTTON_PRESS_MASK); - window.button_press_event.connect((event) => - { - if (_popover_visible) - hide_metadata_popover(); - return false; - }); - - app().events.metadata_changed_sig.connect(handle_metadata_changed); - } - - /** - * Handles display transition when station changes. - */ - internal async void change_station(Station station) - { - hide_metadata_popover(); - reveal_child = false; - - Idle.add(() => - { - Timeout.add(5 * REVEAL_DELAY / 3, () => - { - favicon_image.clear(); - title_label.clear(); - station_label.label = ""; - _metadata = STREAM_METADATA; - return Source.REMOVE; - }); - - Timeout.add(STATION_CHANGE_SETTLE_DELAY_MS, () => - { - station.update_favicon_image.begin( - favicon_image, - true, - DEFAULT_ICON_NAME, - () => - { - _station = station; - station_label.label = station.name; - - reveal_child = true; - title_label.cycle(); - - info_changed_completed_sig(); - } - ); - - return Source.REMOVE; - }); - - return Source.REMOVE; - }, Priority.HIGH_IDLE); - } - - /** - * Handles metadata updates from the player. - */ - public void handle_metadata_changed(Station station, Metadata metadata) - { - if (_metadata == metadata.pretty_print) - return; - - _metadata = metadata.pretty_print; - - if (_metadata == "") - { - _metadata = STREAM_METADATA; - return; - } - - title_label.add_sublabel(1, metadata.genre, metadata.homepage); - title_label.add_sublabel(2, metadata.audio_info); - title_label.add_sublabel(3, metadata.org_loc); - - if (!title_label.set_text(metadata.title)) - { - Timeout.add_seconds(3, () => - { - title_label.set_text(metadata.title); - return Source.REMOVE; - }); - } - - if (_popover_visible) - update_metadata_popover_text(); - } - - private void show_metadata_popover() - { - if (_station == null) - return; - - if (_metadata_popover == null) - { - _metadata_popover = new Gtk.Popover(this); - _metadata_popover.position = Gtk.PositionType.BOTTOM; - _metadata_popover.set_border_width(8); - _metadata_popover.get_style_context().add_class("metadata-popover"); - _metadata_popover.add_events(EventMask.BUTTON_PRESS_MASK); - _metadata_popover.button_press_event.connect((event) => - { - if (event.button == 3) - { - copy_metadata_to_clipboard(); - return true; - } - return false; - }); - - _metadata_label = new Gtk.Label(""); - _metadata_label.wrap = true; - _metadata_label.max_width_chars = 48; - _metadata_label.xalign = 0.0f; - _metadata_label.get_style_context().add_class("metadata-label"); - _metadata_popover.add(_metadata_label); - _metadata_popover.show_all(); - _metadata_popover.hide(); - } - - update_metadata_popover_text(); - _metadata_popover.show(); - _popover_visible = true; - } - - private void hide_metadata_popover() - { - if (_metadata_popover != null) - _metadata_popover.hide(); - _popover_visible = false; - } - - private void update_metadata_popover_text() - { - if (_metadata_label == null) - return; - var popularity = _station != null ? _station.popularity() : ""; - var text = _station != null ? @"$popularity\n\n$(metadata)" : STREAM_METADATA; - _metadata_label.set_text(text); - } - - private void copy_metadata_to_clipboard() - { - var popularity = _station != null ? _station.popularity() : ""; - var text = _station != null ? @"$popularity\n\n$(metadata)" : STREAM_METADATA; - var clipboard = Gtk.Clipboard.get_default(Gdk.Display.get_default()); - if (clipboard != null) - { - clipboard.set_text(text, -1); - show_copy_confirmation(); - } - } - - private void show_copy_confirmation() - { - if (_metadata_popover == null) - return; - - var original_text = _metadata_label != null ? _metadata_label.get_text() : ""; - _metadata_label.set_text(_("Copied to clipboard")); - _metadata_popover.show(); - _popover_visible = true; - - Timeout.add(1200, () => - { - _metadata_label.set_text(original_text); - if (!_popover_visible) - _metadata_popover.hide(); - return Source.REMOVE; - }); - } -} diff --git a/src/Widgets/base/StationListBox.vala b/src/Widgets/base/StationListBox.vala index 08b46726..ae587e28 100644 --- a/src/Widgets/base/StationListBox.vala +++ b/src/Widgets/base/StationListBox.vala @@ -7,6 +7,7 @@ using Gtk; using Gee; +using Gdk; using Tuner.Models; using Tuner.Widgets.Granite; @@ -46,6 +47,7 @@ namespace Tuner.Widgets.Base cfg.icon, cfg.title, cfg.subtitle, + cfg.filter, prepopulated, cfg.station_set, cfg.action_tooltip_text, @@ -130,6 +132,8 @@ namespace Tuner.Widgets.Base private StationListBoxContent _content_view; private Stack _stack; private SourceList _source_list; + private bool _filter; + private Button? _filter_button; @@ -150,6 +154,7 @@ namespace Tuner.Widgets.Base string icon, string title, string subtitle, + bool filter, bool prepopulated = false, StationSet? data, string? action_tooltip_text, @@ -166,6 +171,7 @@ namespace Tuner.Widgets.Base _stack = stack; _source_list = source_list; _category = category; + _filter = filter; pager = new StationListBoxPager (data); _icon = new ThemedIcon (icon); @@ -182,6 +188,19 @@ namespace Tuner.Widgets.Base { _header_view.set_parameter (parameter); }); + + if (_filter) { + _filter_button = new Button.from_icon_name ( + "view-more-horizontal", + IconSize.BUTTON + ); + + _filter_button.relief = Gtk.ReliefStyle.NONE; + _filter_button.valign = Align.CENTER; + _filter_button.opacity = 0.4; + _header_view.pack_end (_filter_button, false, false,3); + } + pack_start (_header_view, false, false); diff --git a/src/Widgets/base/TunerStatus.vala b/src/Widgets/base/TunerStatus.vala index 67fc1b5c..961abafa 100644 --- a/src/Widgets/base/TunerStatus.vala +++ b/src/Widgets/base/TunerStatus.vala @@ -11,17 +11,257 @@ using Gtk; +using Rsvg; using Tuner.Controllers; using Tuner.Models; using Tuner.Services; +/** + * @brief Animated tuner icon that slides the needle and rotates the knob indicator. + * + * Renders the tuner SVG from resources, then animates sub-layers: + * - `tuner-base`: static art + * - `needle-position`: translated horizontally across the dial + * - `knob-rotation`: rotated around the knob center + */ +public class Tuner.Widgets.Base.AnimatedTunerIcon : Gtk.DrawingArea +{ + // SVG coordinate system is 128x128. These match the artwork geometry. + private const double DIAL_OVERSHOOT = 5.0; + private const double DIAL_LEFT_X = 23.0; + private const double DIAL_RIGHT_X = 105.0; + private const double NEEDLE_BASE_X = 54.0; // original needle x in SVG + private const double NEEDLE_MIN_X = DIAL_LEFT_X - DIAL_OVERSHOOT; + private const double NEEDLE_MAX_X = DIAL_RIGHT_X + DIAL_OVERSHOOT; + // Layer_2 is translated by (0, -8) in the SVG, so the root-space center + // is original center + LAYER_OFFSET_Y. + private const double LAYER_OFFSET_Y = -8.0; + private const double KNOB_CENTER_X = 64.5; + private const double KNOB_CENTER_Y = 98.5 + LAYER_OFFSET_Y; + private const double KNOB_TURNS = 3.0; // full needle sweep = 3 full knob rotations + private const int ANIMATION_DURATION_MS = 2000; // full sweep duration + + private Rsvg.Handle _handle; + private uint _animation_tick_id = 0; + private int64 _animation_start_us = 0; + private double _needle_offset = 0.0; + private double _knob_angle = 0.0; + private double _start_needle_offset = 0.0; + private double _start_knob_angle = 0.0; + private double _target_needle_offset = 0.0; + private double _target_knob_angle = 0.0; + private double _target_norm = 0.5; + private bool _return_on_complete = false; + private double _return_norm = 0.5; + + /** + * @brief Create an animated tuner icon with an optional fixed size. + * @param width Optional pixel width (defaults to IconSize.DIALOG). + * @param height Optional pixel height (defaults to IconSize.DIALOG). + */ + public AnimatedTunerIcon (int? width = null, int? height = null) + { + set_halign (Align.CENTER); + set_valign (Align.CENTER); + + int icon_width = 64; + int icon_height = 64; + IconSize.lookup (IconSize.DIALOG, out icon_width, out icon_height); + set_size_request (width ?? icon_width, height ?? icon_height); + + // Load the SVG icon from GResource so we can render individual elements. + try { + var bytes = GLib.resources_lookup_data ("/io/github/tuner_labs/tuner/icons/tuner:tuner-on.svg", GLib.ResourceLookupFlags.NONE); + _handle = new Rsvg.Handle.from_data (bytes.get_data ()); + } catch (GLib.Error e) { + warning ("Failed to load tuner SVG for animation: %s", e.message); + } + + // Drive drawing via the widget's draw signal. + // Initialize to the SVG's original needle position. + _needle_offset = 0.0; + _knob_angle = 0.0; + + draw.connect (on_draw); + destroy.connect (() => + { + if (_animation_tick_id != 0) { + remove_tick_callback (_animation_tick_id); + _animation_tick_id = 0; + } + }); + } + + /** + * @brief Animate the tuner to a normalized dial position. + * @param normalized_position 0.0 = dial-left, 1.0 = dial-right. + */ + public void animate_to (double normalized_position) + { + start_animation (normalized_position, false, 0.5); + } + + /** + * @brief Animate out-and-back around a center position. + * @param center_norm Center position to return to. + * @param delta_norm Offset to move away before returning. + */ + public void animate_pulse (double center_norm, double delta_norm) + { + double target = center_norm + delta_norm; + start_animation (target, true, center_norm); + } + + /** + * @brief Internal animation setup shared by animate_to and animate_pulse. + */ + private void start_animation (double normalized_position, bool return_on_complete, double return_norm) + { + // Clamp the target and store optional return target. + _target_norm = Math.fmax (0.0, Math.fmin (1.0, normalized_position)); + _return_on_complete = return_on_complete; + _return_norm = Math.fmax (0.0, Math.fmin (1.0, return_norm)); + + // Map normalized value to needle offset and knob rotation. + _target_needle_offset = (NEEDLE_MIN_X + _target_norm * (NEEDLE_MAX_X - NEEDLE_MIN_X)) - NEEDLE_BASE_X; + _target_knob_angle = (_target_norm - 0.5) * 360.0 * KNOB_TURNS; + + // Capture the current state as the animation start. + _start_needle_offset = _needle_offset; + _start_knob_angle = _knob_angle; + _animation_start_us = 0; + + // Use frame clock ticks to keep motion smooth and synced to refresh. + if (_animation_tick_id == 0) + _animation_tick_id = add_tick_callback (on_animation_tick); + } + + /** + * @brief Frame-clock tick callback. Updates interpolation and triggers redraw. + * @return false when the animation completes (stops tick), true to continue. + */ + private bool on_animation_tick (Gtk.Widget widget, Gdk.FrameClock frame_clock) + { + int64 frame_us = frame_clock.get_frame_time (); + if (_animation_start_us == 0) + _animation_start_us = frame_us; + + double elapsed_ms = (frame_us - _animation_start_us) / 1000.0; + double progress = elapsed_ms / ANIMATION_DURATION_MS; + + if (progress >= 1.0) { + _needle_offset = _target_needle_offset; + _knob_angle = _target_knob_angle; + queue_draw (); + + if (_return_on_complete) { + // Start the return leg without stopping the tick. + _return_on_complete = false; + start_animation (_return_norm, false, _return_norm); + return true; + } + + _animation_tick_id = 0; + _animation_start_us = 0; + return false; + } + + // Ease-out for a smoother glide instead of a linear jump. + double eased = 1.0 - Math.pow (1.0 - progress, 3.0); + _needle_offset = _start_needle_offset + ((_target_needle_offset - _start_needle_offset) * eased); + _knob_angle = _start_knob_angle + ((_target_knob_angle - _start_knob_angle) * eased); + queue_draw (); + return true; + } + /** + * @brief Draw handler. Renders SVG layers with animation transforms. + */ + private bool on_draw (Cairo.Context cr) + { + if (_handle == null) { + return false; + } + + int width = get_allocated_width (); + int height = get_allocated_height (); + var dims = _handle.get_dimensions (); + + if (dims.width == 0 || dims.height == 0 || width == 0 || height == 0) { + return false; + } + + // Keep uniform scaling so rotations don't skew when the widget isn't square. + double scale = Math.fmin ((double) width / (double) dims.width, + (double) height / (double) dims.height); + double tx = ((double) width - ((double) dims.width * scale)) / 2.0; + double ty = ((double) height - ((double) dims.height * scale)) / 2.0; + + cr.save (); + cr.translate (tx, ty); + cr.scale (scale, scale); + + // Draw base first, then move the needle horizontally, then rotate knob indicator. + render_static (cr, "tuner-base"); + render_translated (cr, "needle-position", _needle_offset, 0.0); + render_rotated (cr, "knob-rotation", _knob_angle); + + cr.restore (); + return false; + } + + /** + * @brief Render an SVG element by id without transforms. + */ + private void render_static (Cairo.Context cr, string id) + { + _handle.render_cairo_sub (cr, "#" + id); + } + + /** + * @brief Render an SVG element by id with a translation. + * @param dx X translation in SVG units. + * @param dy Y translation in SVG units. + */ + private void render_translated (Cairo.Context cr, string id, double dx, double dy) + { + cr.save (); + cr.translate (dx, dy); + _handle.render_cairo_sub (cr, "#" + id); + cr.restore (); + } + + /** + * @brief Render an SVG element by id with rotation around the knob center. + * @param angle_deg Rotation angle in degrees. + */ + private void render_rotated (Cairo.Context cr, string id, double angle_deg) + { + cr.save (); + // 1) Move the origin to the knob center so rotation pivots around it. + cr.translate (KNOB_CENTER_X, KNOB_CENTER_Y); + // 2) Rotate the coordinate system by the desired angle (degrees -> radians). + cr.rotate (angle_deg * Math.PI / 180.0); + // 3) Move the origin back so SVG element coordinates remain in their original space. + cr.translate (-KNOB_CENTER_X, -KNOB_CENTER_Y); + + // The indicator path is authored in absolute SVG coordinates. After the + // translate/rotate/translate, any point (x, y) is rotated around + // (KNOB_CENTER_X, KNOB_CENTER_Y), so its locus is a circle centered there. + _handle.render_cairo_sub (cr, "#" + id); + + cr.restore (); + } +} + /** * TunerStatus widget for displaying tuner on-line status and data provider information. */ public class Tuner.Widgets.Base.TunerStatus : Fixed { - private Overlay _tuner_icon = new Overlay(); - private Image _tuner_on = new Image.from_icon_name("tuner:tuner-on", IconSize.DIALOG); + private Overlay _tuner_icon = new Overlay(); + private AnimatedTunerIcon _tuner_on = new AnimatedTunerIcon(); + private double _last_norm = 0.5; + private const double ONLINE_PULSE_DELTA = 0.12; public TunerStatus(Application app, Window window, DataProvider.API provider) { @@ -49,6 +289,19 @@ public class Tuner.Widgets.Base.TunerStatus : Fixed return true; }); + + // Animate the needle/knob on station changes using a stable per-station hash. + app.events.station_changed_sig.connect((station) => + { + if (app.is_offline) + return; + + // Deterministic placement per station so the needle lands consistently. + string key = station.stationuuid != "" ? station.stationuuid : station.name; + uint hash = GLib.str_hash (key); + _last_norm = (double) (hash % 1000) / 999.0; + _tuner_on.animate_to (_last_norm); + }); } // construct @@ -60,8 +313,12 @@ public class Tuner.Widgets.Base.TunerStatus : Fixed { set { _tuner_on.opacity = value ? 1.0 : 0.0; + if (value) { + // Online pulse: move away then return to the current station position. + _tuner_on.animate_pulse (_last_norm, ONLINE_PULSE_DELTA); + } } } // online -} // TunerStatus \ No newline at end of file +} // TunerStatus diff --git a/src/meson.build b/src/meson.build index 0c1baac2..43fc81e8 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', @@ -32,7 +39,6 @@ sources = files ( 'Services/HttpClient.vala', 'Services/StarStore.vala', - 'Widgets/base/PlayerInfo.vala', 'Widgets/base/ListFlowBox.vala', 'Widgets/base/DisplayButton.vala', 'Widgets/base/RevealLabel.vala', @@ -55,6 +61,7 @@ sources = files ( 'Widgets/TitleBox.vala', 'Widgets/HeaderBar.vala', 'Widgets/PlayButton.vala', + 'Widgets/PlayingStationInfo.vala', 'Widgets/ListButton.vala', 'Widgets/StationButton.vala', 'Widgets/StationContextMenu.vala', @@ -62,7 +69,6 @@ sources = files ( 'Widgets/Display.vala', 'Widgets/Window.vala', 'Widgets/AboutDialog.vala', - 'Widgets/CountryList.vala', 'Widgets/PreferencesPopover.vala', 'Settings.vala',