diff --git a/.gitignore b/.gitignore index 3db4e750..af28ee9f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: GPL-3.0-or-later # - +AGENTS.md build builddir build-dir diff --git a/data/css/Tuner-system.css b/data/css/Tuner-system.css index 2a6ffefa..300f20ca 100644 --- a/data/css/Tuner-system.css +++ b/data/css/Tuner-system.css @@ -152,6 +152,81 @@ tooltip label { color: #d00000; } +.history-popover { + background-color: mix(@theme_base_color, @theme_bg_color, 0.45); + border: 1px solid alpha(@theme_fg_color, 0.14); + border-radius: 6px; +} + +.history-summary-box { + margin-bottom: 2px; +} + +.history-summary-tile { + background-color: alpha(@theme_fg_color, 0.05); + border-radius: 6px; + padding: 6px 8px; +} + +.history-summary-value { + font-weight: 700; + font-size: 13pt; +} + +.history-summary-caption { + font-size: 8.5pt; + opacity: 0.72; +} + +.history-action-row button { + min-width: 32px; +} + +.history-list { + background: transparent; +} + +.history-row { + border-radius: 6px; + background-color: alpha(@theme_fg_color, 0.03); +} + +.history-row-alt { + background-color: alpha(@theme_selected_bg_color, 0.08); +} + +.history-row-hearted { + background-color: alpha(#c52828, 0.10); + border-left: 3px solid alpha(#c52828, 0.70); +} + +.history-row-station { + font-weight: 700; +} + +.history-row-title { + opacity: 0.88; +} + +.history-row-title-hearted { + color: #b61f1f; + font-weight: 600; +} + +.history-heart-badge { + color: #c52828; + background-color: transparent; + border-radius: 0; + font-size: 14pt; + font-weight: 700; + padding: 0; +} + +.history-play-time, +.history-total-time { + font-family: monospace; +} + /* * Search revealer */ diff --git a/data/icons/tuner-jukebox.svg b/data/icons/tuner-jukebox.svg index 39b502ef..65b0dbe4 100644 --- a/data/icons/tuner-jukebox.svg +++ b/data/icons/tuner-jukebox.svg @@ -5,655 +5,541 @@ SPDX-FileCopyrightText: © 2026 SPDX-License-Identifier: GPL-3.0-or-later --> + - Jukebox - - - - - - - - - - - - - - - - - + xmlns:svg="http://www.w3.org/2000/svg"> + + + + + id="stop2843" + offset="0" + style="stop-color:#90193a" /> - + id="stop2845" + offset="1" + style="stop-color:#dd3f80" /> + + + id="SVGID_5_" + x1="64.734001" + x2="64.734001" + y1="79" + y2="47" + gradientTransform="matrix(1.0987,0,0,1,-7.122,0)" + gradientUnits="userSpaceOnUse"> + id="stop2850" + offset="0" + style="stop-color:#de3e80" /> + id="stop2852" + offset="1" + style="stop-color:#910e38" /> + + id="SVGID_6_" + x1="64" + x2="64" + y1="50" + y2="75.999977" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.67609536,0,0,0.8987666,21.555072,-0.6032984)"> + id="stop2857" + offset="0.1" + style="stop-color:#fafafa" /> + id="stop2859" + offset="0.95159948" + style="stop-color:#abacae" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + id="outline" + style="display:inline;fill:url(#linearGradient34);fill-opacity:1;fill-rule:nonzero;stroke:#90193a" + transform="translate(-1.4412969,-13)" + sodipodi:insensitive="true"> + - - - - - - - - - - - - - - - - - - - - - - - - - - + id="knob" + sodipodi:insensitive="true"> + id="SVGID_7_" + cx="65.028999" + cy="109.392" + r="28.579" + gradientTransform="matrix(1.0041,0,0,0.757,0.071,26.351)" + gradientUnits="userSpaceOnUse"> + id="stop2894" + offset="0" + style="stop-color:#fafafa" /> + id="stop2896" + offset="1" + style="stop-color:#d4d4d4" /> + id="circle2899" + cx="64.5" + cy="98.5" + r="17.5" + style="fill:url(#SVGID_7_)" /> + id="circle2901" + cx="64.5" + cy="98.5" + r="14" + style="fill:#273445;stroke:#de3e80;stroke-width:0.9135;stroke-linecap:round;stroke-linejoin:round" /> + id="SVGID_8_" + cx="64.310997" + cy="105.077" + r="25.112" + gradientTransform="matrix(0.9834,0,0,0.5716,1.401,44.637)" + gradientUnits="userSpaceOnUse"> + id="stop2903" + offset="0" + style="stop-color:#7e8087" /> + id="stop2905" + offset="1" + style="stop-color:#fafafa" /> + id="knob_inner" + cx="64.5" + cy="98.5" + r="12" + style="fill:url(#SVGID_8_);stroke:#abacae;stroke-width:0.6558;stroke-linecap:round;stroke-linejoin:round" /> + + + + + + id="line2872" + d="M 50,57 V 68" + class="st8" + style="fill:none;stroke:#abacae;stroke-width:1.7875;stroke-miterlimit:10" /> + + + + + + + + + id="grill" + sodipodi:insensitive="true"> - + id="grill-right"> + + + + + + + + - - - - - - - - - - - - - - + id="g2934"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - Jukebox - 2024-12-15 - - - technosf <https://github.com/technosf> - - - - - Copyright © 2024 technosf <https://github.com/technosf> - - - https://github.com/louis77/tuner - Jukebox icon for Tuner app - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/data/io.github.tuner_labs.tuner.gschema.xml b/data/io.github.tuner_labs.tuner.gschema.xml index 81869db0..92bb8d95 100644 --- a/data/io.github.tuner_labs.tuner.gschema.xml +++ b/data/io.github.tuner_labs.tuner.gschema.xml @@ -74,6 +74,11 @@ SPDX-License-Identifier: GPL-3.0-or-later Auto-play last station If enabled, when Tuner starts it will automatically start to play the last played station + + true + Play startup sound + If enabled, Tuner plays the startup sound when the main window opens + "" Last played station diff --git a/data/io.github.tuner_labs.tuner.metainfo.xml b/data/io.github.tuner_labs.tuner.metainfo.xml index 1e60b8a3..6fddff94 100644 --- a/data/io.github.tuner_labs.tuner.metainfo.xml +++ b/data/io.github.tuner_labs.tuner.metainfo.xml @@ -109,6 +109,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
  • Icon animations
  • Refactored audio player and removed use of deprecated GstPlayer
  • Refactored metadata processing and title transitions
  • +
  • Jingle preference
  • diff --git a/po/application/application.pot b/po/application/application.pot index eb32094c..d56b7282 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-04-24 23:06-0700\n" +"POT-Creation-Date: 2026-06-09 10:10-0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -106,178 +106,182 @@ msgid "Audio Codec" msgstr "" #: src/Models/StreamMetadata.vala:38 -msgid "Channel Mode" +msgid "Video Codec" msgstr "" #: src/Models/StreamMetadata.vala:39 -msgid "Track Number" +msgid "Channel Mode" msgstr "" #: src/Models/StreamMetadata.vala:40 -msgid "Track Count" +msgid "Track Number" msgstr "" #: src/Models/StreamMetadata.vala:41 -msgid "Nominal Bitrate" +msgid "Track Count" msgstr "" #: src/Models/StreamMetadata.vala:42 -msgid "Minimum Bitrate" +msgid "Nominal Bitrate" msgstr "" #: src/Models/StreamMetadata.vala:43 -msgid "Maximum Bitrate" +msgid "Minimum Bitrate" msgstr "" #: src/Models/StreamMetadata.vala:44 -msgid "Has CRC" +msgid "Maximum Bitrate" msgstr "" #: src/Models/StreamMetadata.vala:45 -msgid "Container Format" +msgid "Has CRC" msgstr "" #: src/Models/StreamMetadata.vala:46 -msgid "Track Id" +msgid "Container Format" msgstr "" #: src/Models/StreamMetadata.vala:47 -msgid "Application Name" +msgid "Track Id" msgstr "" #: src/Models/StreamMetadata.vala:48 -msgid "Encoder" +msgid "Application Name" msgstr "" #: src/Models/StreamMetadata.vala:49 -msgid "Encoder Version" +msgid "Encoder" msgstr "" #: src/Models/StreamMetadata.vala:50 -msgid "Encoded by" +msgid "Encoder Version" msgstr "" #: src/Models/StreamMetadata.vala:51 -msgid "Private Data" +msgid "Encoded by" msgstr "" #: src/Models/StreamMetadata.vala:52 -msgid "ID3 Private" +msgid "Private Data" msgstr "" #: src/Models/StreamMetadata.vala:53 -msgid "GStreamer Sample" +msgid "ID3 Private" msgstr "" #: src/Models/StreamMetadata.vala:54 -msgid "GStreamer Date Time" +msgid "GStreamer Sample" msgstr "" #: src/Models/StreamMetadata.vala:55 +msgid "GStreamer Date Time" +msgstr "" + +#: src/Models/StreamMetadata.vala:56 msgid "Date Time" msgstr "" #. #. Display Assets #. -#: src/Widgets/Display.vala:114 +#: src/Widgets/Display.vala:118 msgid "Selections" msgstr "" -#: src/Widgets/Display.vala:115 +#: src/Widgets/Display.vala:119 msgid "Library" msgstr "" -#: src/Widgets/Display.vala:116 +#: src/Widgets/Display.vala:120 msgid "Saved Searches" msgstr "" -#: src/Widgets/Display.vala:117 +#: src/Widgets/Display.vala:121 msgid "Explore" msgstr "" -#: src/Widgets/Display.vala:118 +#: src/Widgets/Display.vala:122 msgid "Genres" msgstr "" -#: src/Widgets/Display.vala:119 +#: src/Widgets/Display.vala:123 msgid "Subgenres" msgstr "" -#: src/Widgets/Display.vala:120 +#: src/Widgets/Display.vala:124 msgid "Eras" msgstr "" -#: src/Widgets/Display.vala:121 +#: src/Widgets/Display.vala:125 msgid "Talk, News, Sport" msgstr "" -#: src/Widgets/Display.vala:449 +#: src/Widgets/Display.vala:454 msgid "Discover" msgstr "" -#: src/Widgets/Display.vala:450 +#: src/Widgets/Display.vala:455 msgid "Stations to Discover" msgstr "" -#: src/Widgets/Display.vala:454 +#: src/Widgets/Display.vala:459 msgid "Discover more stations" msgstr "" -#: src/Widgets/Display.vala:474 +#: src/Widgets/Display.vala:479 msgid "Trending" msgstr "" -#: src/Widgets/Display.vala:475 +#: src/Widgets/Display.vala:480 msgid "Trending Stations in the last 24 hours" msgstr "" -#: src/Widgets/Display.vala:492 +#: src/Widgets/Display.vala:497 msgid "Popular" msgstr "" -#: src/Widgets/Display.vala:493 +#: src/Widgets/Display.vala:498 msgid "Most listened to Stations in the last 24 hours" msgstr "" -#: src/Widgets/Display.vala:530 src/Widgets/Display.vala:531 +#: src/Widgets/Display.vala:535 src/Widgets/Display.vala:536 msgid "Starred by You" msgstr "" -#: src/Widgets/Display.vala:569 +#: src/Widgets/Display.vala:574 msgid "Latest Search" msgstr "" -#: src/Widgets/Display.vala:570 +#: src/Widgets/Display.vala:575 msgid "Search Results" msgstr "" -#: src/Widgets/Display.vala:572 +#: src/Widgets/Display.vala:577 msgid "Save this search" msgstr "" -#: src/Widgets/Display.vala:706 +#: src/Widgets/Display.vala:711 msgid "Jukebox" msgstr "" -#: src/Widgets/Display.vala:708 +#: src/Widgets/Display.vala:713 #, c-format msgid "Double click to shuffle through %1$u stations" msgstr "" -#: src/Widgets/Display.vala:709 +#: src/Widgets/Display.vala:714 msgid "one, every ten minutes, for %2$u days" msgstr "" -#: src/Widgets/Display.vala:775 +#: src/Widgets/Display.vala:780 msgid "Saved Search" msgstr "" -#: src/Widgets/Display.vala:778 +#: src/Widgets/Display.vala:783 msgid "Remove this saved search" msgstr "" -#: src/Widgets/HeaderBar.vala:161 src/Widgets/StationContextMenu.vala:159 +#: src/Widgets/HeaderBar.vala:159 src/Widgets/StationContextMenu.vala:159 msgid "Star this station" msgstr "" @@ -289,19 +293,19 @@ msgstr "" #. RHS Controls #. #. Search button -#: src/Widgets/HeaderBar.vala:183 +#: src/Widgets/HeaderBar.vala:181 msgid "Search for Stations" msgstr "" -#: src/Widgets/HeaderBar.vala:191 +#: src/Widgets/HeaderBar.vala:189 msgid "Preferences" msgstr "" -#: src/Widgets/HeaderBar.vala:195 +#: src/Widgets/HeaderBar.vala:194 msgid "History" msgstr "" -#: src/Widgets/HeaderBar.vala:199 +#: src/Widgets/HeaderBar.vala:198 msgid "Heart current track in history" msgstr "" @@ -368,90 +372,99 @@ msgstr "" msgid "If enabled, when Tuner starts it will open to the starred stations view" msgstr "" -#. Play Display +#. Startup sound #: src/Widgets/PreferencesPopover.vala:76 -msgid "Show stream info when playing" +msgid "Play startup jingle" msgstr "" #: src/Widgets/PreferencesPopover.vala:78 +msgid "If enabled, Tuner plays a short sound when it starts" +msgstr "" + +#. Play Display +#: src/Widgets/PreferencesPopover.vala:84 +msgid "Show stream info when playing" +msgstr "" + +#: src/Widgets/PreferencesPopover.vala:86 msgid "Cycle through the metadata from the playing stream" msgstr "" -#: src/Widgets/PreferencesPopover.vala:82 +#: src/Widgets/PreferencesPopover.vala:90 msgid "Faster cycling through stream info" msgstr "" -#: src/Widgets/PreferencesPopover.vala:84 +#: src/Widgets/PreferencesPopover.vala:92 msgid "" "Fast cycle through the metadata from the playing stream if show stream info " "is enabled" msgstr "" -#: src/Widgets/PreferencesPopover.vala:88 +#: src/Widgets/PreferencesPopover.vala:96 msgid "Stream metadata image popup" msgstr "" -#: src/Widgets/PreferencesPopover.vala:90 +#: src/Widgets/PreferencesPopover.vala:98 msgid "Show a movable popup with images discovered in the stream metadata" msgstr "" -#: src/Widgets/PreferencesPopover.vala:94 +#: src/Widgets/PreferencesPopover.vala:102 msgid "Shrink title dynamically with the text" msgstr "" -#: src/Widgets/PreferencesPopover.vala:96 +#: src/Widgets/PreferencesPopover.vala:104 msgid "Shrink the title according to the length of the displayed text." msgstr "" -#: src/Widgets/PreferencesPopover.vala:114 +#: src/Widgets/PreferencesPopover.vala:122 msgid "Language" msgstr "" -#: src/Widgets/PreferencesPopover.vala:115 +#: src/Widgets/PreferencesPopover.vala:123 msgid "Language changes restart Tuner" msgstr "" #. end language selection #. Export starred -#: src/Widgets/PreferencesPopover.vala:123 +#: src/Widgets/PreferencesPopover.vala:131 msgid "Export Starred Stations to Playlist" msgstr "" #. Import starred -#: src/Widgets/PreferencesPopover.vala:133 +#: src/Widgets/PreferencesPopover.vala:141 msgid "Import Station UUIDs as Starred Stations" msgstr "" #. Create the file chooser dialog for saving the exported playlist -#: src/Widgets/PreferencesPopover.vala:219 src/Widgets/Window.vala:553 +#: src/Widgets/PreferencesPopover.vala:229 src/Widgets/Window.vala:572 msgid "Save File" msgstr "" -#: src/Widgets/PreferencesPopover.vala:222 -#: src/Widgets/PreferencesPopover.vala:260 src/Widgets/Window.vala:556 +#: src/Widgets/PreferencesPopover.vala:232 +#: src/Widgets/PreferencesPopover.vala:270 src/Widgets/Window.vala:575 msgid "_Cancel" msgstr "" -#: src/Widgets/PreferencesPopover.vala:223 src/Widgets/Window.vala:545 -#: src/Widgets/Window.vala:557 +#: src/Widgets/PreferencesPopover.vala:233 src/Widgets/Window.vala:564 +#: src/Widgets/Window.vala:576 msgid "_Save" msgstr "" #. warning("Error: $(e.message)"); -#: src/Widgets/PreferencesPopover.vala:244 +#: src/Widgets/PreferencesPopover.vala:254 msgid "Error" msgstr "" -#: src/Widgets/PreferencesPopover.vala:257 +#: src/Widgets/PreferencesPopover.vala:267 msgid "Choose a file" msgstr "" -#: src/Widgets/PreferencesPopover.vala:261 +#: src/Widgets/PreferencesPopover.vala:271 msgid "_Open" msgstr "" #. warning("Error reading file: $(e.message)"); -#: src/Widgets/PreferencesPopover.vala:284 +#: src/Widgets/PreferencesPopover.vala:294 msgid "Error reading file" msgstr "" @@ -483,24 +496,24 @@ msgid "Unstar this station" msgstr "" #. Private -#: src/Widgets/Window.vala:73 +#: src/Widgets/Window.vala:74 msgid "Playing in background" msgstr "" -#: src/Widgets/Window.vala:74 +#: src/Widgets/Window.vala:75 msgid "" "Click here to resume window. To quit Tuner, pause playback and close the " "window." msgstr "" -#: src/Widgets/Window.vala:337 +#: src/Widgets/Window.vala:340 msgid "Stop Playback requested" msgstr "" -#: src/Widgets/Window.vala:542 +#: src/Widgets/Window.vala:561 msgid "Save hearted tracks to a file?" msgstr "" -#: src/Widgets/Window.vala:544 +#: src/Widgets/Window.vala:563 msgid "_Don't Save" msgstr "" diff --git a/src/Application.vala b/src/Application.vala index fe07bd54..e534909b 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -195,6 +195,9 @@ namespace Tuner { /** @brief Player controller */ public StarStore stars { get; construct; } + + /** @brief Shared session playback history */ + public History history { get; private set; } /** @brief API DataProvider */ public DataProvider.API provider { get; construct; } @@ -287,6 +290,7 @@ namespace Tuner { player = create_player(); stars = create_star_store(starred_file); directory = create_directory_controller(provider, stars); + history = new History(); initialize_coordinators(); register_application_actions (); @@ -560,15 +564,17 @@ namespace Tuner { /** - * @brief Play the startup jingle (resource-backed WAV) once per launch. + * @brief Play the startup jingle (resource-backed sound) once per launch. */ private void play_startup_jingle () { - if (_startup_jingle != null) + if ( _startup_jingle != null || !settings.startup_jingle ) + { return; + } // if var uri = "resource:///io/github/tuner_labs/tuner/sounds/tuner_startup.mp3"; - _startup_jingle = StreamPlayer.play_file (uri, settings.volume, () => { + _startup_jingle = StreamPlayer.play_file (uri, settings.volume/2, () => { _startup_jingle = null; }); } diff --git a/src/Ext/GstStreamPlayer.vala b/src/Ext/GstStreamPlayer.vala index 9d92ce1f..cb168320 100644 --- a/src/Ext/GstStreamPlayer.vala +++ b/src/Ext/GstStreamPlayer.vala @@ -197,11 +197,12 @@ namespace Tuner.Ext { 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; - } + var tag_string = value_to_tag_string (value); + if (tag_string == null || tag_string.strip () == "") + continue; + + _metadata.insert (tag, tag_string); + changed = true; } if (TRACE_METADATA_PATH) { @@ -257,6 +258,26 @@ namespace Tuner.Ext } // bus_callback + private string? value_to_tag_string (GLib.Value? value) + { + if (value == null) + return null; + + if (value.holds (typeof (string))) + return value.get_string (); + + if (value.holds (typeof (GLib.DateTime))) + { + var dt = (GLib.DateTime?) value.get_boxed (); + if (dt == null) + return null; + return dt.format_iso8601 (); + } + + return value.strdup_contents (); + } // value_to_tag_string + + /** * @brief Update default crossfade settings for all players. * diff --git a/src/Main.vala b/src/Main.vala index 7da7f248..a5ea1d33 100644 --- a/src/Main.vala +++ b/src/Main.vala @@ -32,6 +32,23 @@ void on_signal (int sig) { Posix._exit (128 + sig); } +private void install_filtered_json_warning_handler () +{ + GLib.Log.set_handler ( + "Json", + GLib.LogLevelFlags.LEVEL_WARNING, + (log_domain, log_level, message) => { + if (message != null + && message.contains ("Boxed type 'GDateTime' is not handled by JSON-GLib")) + { + return; + } + + GLib.Log.default_handler (log_domain, log_level, message); + } + ); +} // install_filtered_json_warning_handler + public static int main (string[] args) { Posix.signal (Posix.Signal.SEGV, on_signal); @@ -39,6 +56,7 @@ public static int main (string[] args) Posix.signal (Posix.Signal.BUS, on_signal); Intl.setlocale (LocaleCategory.ALL, ""); + install_filtered_json_warning_handler (); Gst.init (ref args); var app = Tuner.Application.instance; try { diff --git a/src/Models/History.vala b/src/Models/History.vala new file mode 100644 index 00000000..97f8d43f --- /dev/null +++ b/src/Models/History.vala @@ -0,0 +1,349 @@ +/** + * SPDX-FileCopyrightText: Copyright © 2026 + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * @file History.vala + * + * @brief Session history model for played tracks. + */ + +using Gee; + +namespace Tuner.Models +{ + /** + * @brief One played track in the current session history. + */ + public class HistoryEntry : GLib.Object + { + public Station station { get; private set; } + public string title { get; private set; default = ""; } + public bool hearted { get; private set; default = false; } + public DateTime played_at { get; private set; } + public int played_seconds { get; private set; default = 0; } + public bool playing { get; private set; default = false; } + + private int64 _play_started_monotonic_usec = 0; + + + public HistoryEntry(Station station, string title, bool hearted = false) + { + Object(); + this.station = station; + this.title = title; + this.hearted = hearted; + this.played_at = new DateTime.now_local(); + } // HistoryEntry + + + public void update_title(string title) + { + this.title = title; + notify_property("title"); + } // update_title + + + public void update_hearted(bool hearted) + { + if (this.hearted == hearted) + return; + + this.hearted = hearted; + notify_property("hearted"); + } // update_hearted + + + public void start_playback() + { + if (playing) + return; + + _play_started_monotonic_usec = GLib.get_monotonic_time(); + playing = true; + notify_property("playing"); + } // start_playback + + + public void stop_playback() + { + if (!playing) + return; + + played_seconds = get_total_played_seconds(); + _play_started_monotonic_usec = 0; + playing = false; + notify_property("played-seconds"); + notify_property("playing"); + } // stop_playback + + + public int get_total_played_seconds() + { + if (!playing || _play_started_monotonic_usec == 0) + return played_seconds; + + var elapsed_usec = GLib.get_monotonic_time() - _play_started_monotonic_usec; + if (elapsed_usec <= 0) + return played_seconds; + + return played_seconds + (int) (elapsed_usec / 1000000); + } // get_total_played_seconds + } // HistoryEntry + + + /** + * @brief Tracks a linear history of played station/title pairs. + */ + public class History : GLib.Object + { + public signal void entry_added_sig(HistoryEntry entry); + public signal void entry_removed_sig(HistoryEntry entry); + public signal void entry_changed_sig(HistoryEntry entry); + public signal void cleared_sig(); + + private Gee.List _entries = new Gee.ArrayList(); + private HistoryEntry _last_entry = null; + + public Gee.List entries + { + get { return _entries; } + } + + public HistoryEntry last_entry + { + get { return _last_entry; } + } + + public int station_change_count + { + get + { + if (_entries.size == 0) + return 0; + + int changes = 1; + Station previous_station = _entries[0].station; + for (int index = 1; index < _entries.size; index++) + { + var entry = _entries[index]; + if (entry.station != previous_station) + { + changes++; + previous_station = entry.station; + } + } + + return changes; + } + } + + public int distinct_track_count + { + get + { + var track_keys = new Gee.HashSet(); + foreach (var entry in _entries) + { + var title = entry.title.strip(); + if (title == "") + continue; + track_keys.add(create_track_key(entry.station, title)); + } + + return track_keys.size; + } + } + + public int hearted_track_count + { + get + { + int count = 0; + foreach (var entry in _entries) + { + if (entry.hearted) + count++; + } + return count; + } + } + + public int total_played_seconds + { + get + { + int total = 0; + foreach (var entry in _entries) + total += entry.get_total_played_seconds(); + return total; + } + } + + + public void clear() + { + stop_last_entry_playback(); + + foreach (var entry in _entries) + entry_removed_sig(entry); + + _entries.clear(); + _last_entry = null; + cleared_sig(); + } // clear + + + public void clear_preserving_last() + { + if (_entries.size <= 1) + return; + + var current_entry = _last_entry; + var removed_entries = new Gee.ArrayList(); + foreach (var entry in _entries) + { + if (entry != current_entry) + removed_entries.add(entry); + } + + foreach (var entry in removed_entries) + { + _entries.remove(entry); + entry_removed_sig(entry); + } + } // clear_preserving_last + + + public void append(Station station, string title) + { + var normalized_title = title != null ? title : ""; + if (_last_entry != null + && _last_entry.station == station + && _last_entry.title == normalized_title) + { + return; + } + + if (_last_entry != null + && _last_entry.station == station + && _last_entry.title == "") + { + remove_last(); + } + else + { + stop_last_entry_playback(); + } + + var entry = new HistoryEntry(station, normalized_title); + _entries.add(entry); + _last_entry = entry; + entry_added_sig(entry); + } // append + + + public bool set_last_entry_hearted_if_matches(Station station, string title, bool hearted) + { + if (_last_entry == null) + return false; + + if (_last_entry.station != station || _last_entry.title != title) + return false; + + if (_last_entry.hearted == hearted) + return true; + + _last_entry.update_hearted(hearted); + entry_changed_sig(_last_entry); + return true; + } // set_last_entry_hearted_if_matches + + + public bool is_last_entry_hearted_for(Station station, string title) + { + return _last_entry != null + && _last_entry.station == station + && _last_entry.title == title + && _last_entry.hearted; + } // is_last_entry_hearted_for + + + public void sync_play_state(Station station, StreamPlayer.State state) + { + if (_last_entry == null || _last_entry.station != station) + return; + + switch (state) + { + case StreamPlayer.State.PLAYING: + _last_entry.start_playback(); + entry_changed_sig(_last_entry); + break; + default: + if (_last_entry.playing) + { + _last_entry.stop_playback(); + entry_changed_sig(_last_entry); + } + break; + } + } // sync_play_state + + + public Gee.List get_hearted_titles() + { + var results = new Gee.ArrayList(); + foreach (var entry in _entries) + { + var title = entry.title.strip(); + if (!entry.hearted || title == "") + continue; + results.add(title); + } + return results; + } // get_hearted_titles + + + public Gee.List get_hearted_history_lines() + { + var results = new Gee.ArrayList(); + foreach (var entry in _entries) + { + var title = entry.title.strip(); + if (!entry.hearted || title == "") + continue; + results.add(entry.station.name + ": " + title); + } + return results; + } // get_hearted_history_lines + + + private string create_track_key(Station station, string title) + { + return station.stationuuid + "\n" + title; + } // create_track_key + + + private void remove_last() + { + if (_last_entry == null) + return; + + _last_entry.stop_playback(); + _entries.remove(_last_entry); + entry_removed_sig(_last_entry); + _last_entry = _entries.size > 0 ? _entries.get(_entries.size - 1) : null; + } // remove_last + + + private void stop_last_entry_playback() + { + if (_last_entry == null || !_last_entry.playing) + return; + + _last_entry.stop_playback(); + entry_changed_sig(_last_entry); + } // stop_last_entry_playback + } // History +} // Tuner.Models diff --git a/src/Models/StreamMetadata.vala b/src/Models/StreamMetadata.vala index b8493d38..d3ae31e7 100644 --- a/src/Models/StreamMetadata.vala +++ b/src/Models/StreamMetadata.vala @@ -35,6 +35,7 @@ public class Tuner.Models.StreamMetadata : GLib.Object {"extended-comment", N_("Extended Comment") }, {"bitrate", N_("Bitrate") }, {"audio-codec", N_("Audio Codec") }, + {"video-codec", N_("Video Codec") }, {"channel-mode", N_("Channel Mode") }, {"track-number", N_("Track Number") }, {"track-count", N_("Track Count") }, @@ -171,13 +172,14 @@ public class Tuner.Models.StreamMetadata : GLib.Object _genre = extract ("genre"); _homepage = extract ("homepage"); - _audio_info = extract ("audio_codec "); - _audio_info += extract ("bitrate "); - _audio_info += extract ("channel_mode"); + _audio_info = extract ("audio-codec"); + _audio_info += extract ("video-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("organization"); _org_loc += extract ("location"); if (_org_loc != null && _org_loc.length > 0) org_loc = safestrip(_org_loc); diff --git a/src/Settings.vala b/src/Settings.vala index 08f034cf..b3d53886 100644 --- a/src/Settings.vala +++ b/src/Settings.vala @@ -21,6 +21,7 @@ public class Tuner.Settings : GLib.Settings private const string SETTINGS_POS_X = "pos-x"; private const string SETTINGS_POS_Y = "pos-y"; private const string SETTINGS_START_ON_STARRED = "start-on-starred"; + private const string SETTINGS_STARTUP_JINGLE = "startup-jingle"; 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"; @@ -37,6 +38,7 @@ public class Tuner.Settings : GLib.Settings public bool do_not_vote { get; set; } public string last_played_station { get; set; } public bool start_on_starred { get; set; } + public bool startup_jingle { get; set; } public bool stream_info { get; set; } public bool stream_info_fast { get; set; } public bool stream_info_image_popup { get; set; } @@ -71,6 +73,7 @@ public class Tuner.Settings : GLib.Settings bind (SETTINGS_DO_NOT_VOTE, this, "do_not_vote", SettingsBindFlags.DEFAULT); bind (SETTINGS_LAST_PLAYED_STATION, this, "last_played_station", SettingsBindFlags.DEFAULT); bind (SETTINGS_START_ON_STARRED, this, "start_on_starred", SettingsBindFlags.DEFAULT); + bind (SETTINGS_STARTUP_JINGLE, this, "startup_jingle", SettingsBindFlags.DEFAULT); bind (SETTINGS_STREAM_INFO, this, "stream_info", SettingsBindFlags.DEFAULT); bind (SETTINGS_STREAM_INFO_FAST, this, "stream_info_fast", SettingsBindFlags.DEFAULT); bind (SETTINGS_STREAM_INFO_IMAGE_POPUP, this, "stream_info_image_popup", SettingsBindFlags.DEFAULT); diff --git a/src/Widgets/Display.vala b/src/Widgets/Display.vala index 3a9a3ca3..c08f1001 100644 --- a/src/Widgets/Display.vala +++ b/src/Widgets/Display.vala @@ -12,6 +12,10 @@ * features such as a source list, content stack that display and manage Station * settings and handles user actions like station selection. * + * Display consists of a source list (LHS) for navigation and a stack (RHS) for content presentation, with background visuals that transition based on user interactions. + * The class manages various categories of stations, including selections, library, explore, genres, subgenres, eras, and talk stations. + * It also handles search functionality and starred stations management. + * * @since 2.0.0 * * @see Tuner.Application @@ -209,21 +213,22 @@ public class Tuner.Widgets.Display : Gtk.Paned, StationListHookup { _background_tuner.reveal_child = true; _background_tuner.child = tuner; + var jukebox = new AnimatedJukeboxIcon (256, 256); + jukebox.opacity = BACKGROUND_OPACITY; + _background_jukebox.transition_duration = BACKGROUND_TRANSITION_TIME_MS; + _background_jukebox.transition_type = BACKGROUND_TRANSITION_TYPE; + _background_jukebox.reveal_child = false; + _background_jukebox.child = jukebox; + _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); + jukebox.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; - _background_jukebox.transition_type = BACKGROUND_TRANSITION_TYPE; - _background_jukebox.reveal_child = false; - _background_jukebox.child = jukebox; - var background = new Gtk.Fixed(); background.add(_background_tuner); background.add(_background_jukebox); @@ -447,8 +452,8 @@ public class Tuner.Widgets.Display : Gtk.Paned, StationListHookup { "discover", "face-smile", _("Discover"), - _("Stations to Discover"), - true + _("Stations to Discover") + // true // TODO Implement filter ) { station_set = _directory.load_random_stations(20), action_tooltip_text = _("Discover more stations"), @@ -822,8 +827,8 @@ public class Tuner.Widgets.Display : Gtk.Paned, StationListHookup { genre, "tuner:playlist-symbolic", genre, - genre, - true + genre + // true // TODO Implement filter ) { station_set = directory.load_by_tag (genre.down ()) } diff --git a/src/Widgets/HeaderBar.vala b/src/Widgets/HeaderBar.vala index 5741e6b1..039cc579 100644 --- a/src/Widgets/HeaderBar.vala +++ b/src/Widgets/HeaderBar.vala @@ -73,7 +73,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar private PlayButton _play_button = new PlayButton (); private MenuButton _prefs_button = new MenuButton (); private Button _search_button = new Button.from_icon_name ("system-search-symbolic", IconSize.LARGE_TOOLBAR); - private ListButton _list_button = new ListButton.from_icon_name ("mark-location-symbolic", IconSize.LARGE_TOOLBAR); + private ListButton _list_button; private Button _heart_button = new Button(); /* @@ -85,8 +85,6 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar private Station _station; private Station _last_metadata_station; private string _last_metadata_title = ""; - private string _heart_favorited_title = ""; - private bool _heart_is_favorited = false; private Mutex _station_update_lock = Mutex(); // Lock out concurrent updates private bool _station_locked = false; private ulong _station_handler_id = 0; @@ -191,6 +189,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar _prefs_button.tooltip_text = _("Preferences"); _prefs_button.popover = new PreferencesPopover(); + _list_button = new ListButton.from_icon_name(_app.history, "mark-location-symbolic", IconSize.LARGE_TOOLBAR); _list_button.valign = Align.CENTER; _list_button.tooltip_text = _("History"); @@ -202,20 +201,15 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar { if (_last_metadata_station == null || _last_metadata_title == "") return; - var hearted_title = "♥ " + _last_metadata_title; - if (_heart_is_favorited && _heart_favorited_title == _last_metadata_title) + + var next_hearted_state = !_app.history.is_last_entry_hearted_for(_last_metadata_station, _last_metadata_title); + if (!_list_button.set_last_entry_hearted_if_matches(_last_metadata_station, _last_metadata_title, next_hearted_state)) { - if (!_list_button.replace_last_title_if_matches(_last_metadata_station, hearted_title, _last_metadata_title)) - _list_button.append_station_title_pair(_last_metadata_station, _last_metadata_title); - _heart_favorited_title = ""; - set_heart_favorited(false); - return; + _list_button.append_station_title_pair(_last_metadata_station, _last_metadata_title); + _list_button.set_last_entry_hearted_if_matches(_last_metadata_station, _last_metadata_title, next_hearted_state); } - if (!_list_button.replace_last_title_if_matches(_last_metadata_station, _last_metadata_title, hearted_title)) - _list_button.append_station_title_pair(_last_metadata_station, hearted_title); - _heart_favorited_title = _last_metadata_title; - set_heart_favorited(true); + set_heart_favorited(next_hearted_state); }); /* @@ -238,13 +232,6 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar pack_end (_list_button); pack_end (_search_button); - /* Test fixture */ - // private Button _off_button = new Button.from_icon_name ("list-add", IconSize.LARGE_TOOLBAR); - // pack_end (_off_button); - // _off_button.clicked.connect (() => { - // app().is_online = !app().is_online; - // }); - show_close_button = true; @@ -259,6 +246,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar _app.events.player_state_changed_sig.connect ((station, state) => { + _app.history.sync_play_state(station, state); update_controls_state(); }); @@ -277,11 +265,11 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar _app.events.playback_metadata_changed_sig.connect ((station, metadata) => { _list_button.append_station_title_pair(station, metadata.title); + _app.history.sync_play_state(station, _player.player_state); _last_metadata_station = station; _last_metadata_title = metadata.title != null ? metadata.title : ""; _heart_button.sensitive = _last_metadata_title != ""; - if (_last_metadata_title == "" || _last_metadata_title != _heart_favorited_title) - set_heart_favorited(false); + set_heart_favorited(_app.history.is_last_entry_hearted_for(_last_metadata_station, _last_metadata_title)); }); _list_button.item_station_selected_sig.connect((station) => @@ -298,8 +286,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar ctx.add_class("heart-favorited"); else ctx.remove_class("heart-favorited"); - _heart_is_favorited = favorited; - } + } // set_heart_favorited public Gee.List get_hearted_titles() { @@ -309,7 +296,7 @@ public class Tuner.Widgets.HeaderBar : Gtk.HeaderBar public Gee.List get_hearted_history_lines_without_hearts() { return _list_button.get_hearted_history_lines_without_hearts(); - } + } // get_hearted_history_lines_without_hearts /* diff --git a/src/Widgets/ListButton.vala b/src/Widgets/ListButton.vala index 81c099fd..0d93b237 100644 --- a/src/Widgets/ListButton.vala +++ b/src/Widgets/ListButton.vala @@ -13,10 +13,10 @@ using Gtk; using Gee; using Tuner.Models; -using Tuner.Widgets.Base; /** * @class ListButton + * * @brief A custom button with a dropdown menu for station selection and context actions * * The ListButton class provides a button that displays a dropdown menu of stations @@ -27,227 +27,483 @@ using Tuner.Widgets.Base; */ public class Tuner.Widgets.ListButton : Gtk.Button { -/** - * @signal item_station_selected_sig - * @brief Emitted when a station is selected from the dropdown menu. - * @param station The selected station. - */ + private const int MAX_VISIBLE_ROWS = 12; + + /** + * @signal item_station_selected_sig + * @brief Emitted when a station is selected from the dropdown menu. + * @param station The selected station. + */ public signal void item_station_selected_sig(Station station); - private Gtk.Menu dropdown_menu; - private Gtk.Menu context_menu; - private Gee.HashMap menu_items; - private StringBuilder clipboard_text = new StringBuilder(); - private HistoryList _history; + private Gtk.Popover history_popover; + private Gtk.ListBox history_list; + private Gtk.ScrolledWindow history_scroller; + private Gtk.Label summary_plays_value; + private Gtk.Label summary_changes_value; + private Gtk.Label summary_distinct_value; + private Gtk.Label summary_hearted_value; + private Gtk.Label total_play_time_value; + private Gee.HashMap rows_by_entry; + private uint row_refresh_timeout_id = 0; -/** - * @brief Constructs a new ListButton with an icon. - * @param icon_name The name of the icon to display on the button. - * @param size The size of the icon. - */ - public ListButton.from_icon_name(string? icon_name, IconSize size = IconSize.BUTTON) + /** + * @brief Constructs a new ListButton with an icon. + * @param icon_name The name of the icon to display on the button. + * @param size The size of the icon. + */ + public ListButton.from_icon_name(History history, string? icon_name, IconSize size = IconSize.BUTTON) { - Object(); + Object(history: history); var image = new Image.from_icon_name(icon_name, size); this.set_image(image); - this.dropdown_menu = new Gtk.Menu(); - menu_items = new Gee.HashMap(); - this.clicked.connect(() => { - if (menu_items.size > 0) - { - this.dropdown_menu.popup_at_widget(this, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null); - limit_dropdown_menu_width(); - } - }); - initialize_context_menu(); - } + initialize(); + } // ListButton -/** - * @brief Constructs a new ListButton without an icon. - */ - public ListButton() + + /** + * @brief Constructs a new ListButton without an icon. + */ + public ListButton(History history) + { + Object(history: history); + initialize(); + } // ListButton + + + public History history { get; construct; } + + + private void initialize() { - Object(); - this.dropdown_menu = new Gtk.Menu(); - menu_items = new Gee.HashMap(); + rows_by_entry = new Gee.HashMap(); + build_popover(); + start_row_refresh_timer(); this.clicked.connect(() => { - if (menu_items.size > 0) + if (history.entries.size > 0) { - this.dropdown_menu.popup_at_widget(this, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null); - limit_dropdown_menu_width(); + history_popover.popup(); } }); - initialize_context_menu(); - } + bind_history(); + refresh_summary(); + } // initialize -/** - * @brief Initializes the context menu with copy and clear actions. - */ - private void initialize_context_menu() + private void build_popover() { - context_menu = new Gtk.Menu(); + history_popover = new Gtk.Popover(this); + history_popover.position = Gtk.PositionType.BOTTOM; + history_popover.border_width = 0; + history_popover.get_style_context().add_class("history-popover"); + + var content = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); + content.margin_top = 10; + content.margin_bottom = 10; + content.margin_start = 10; + content.margin_end = 10; - var copy_item = new Gtk.MenuItem.with_label(_("Copy List to Clipboard")); - copy_item.activate.connect(() => { + var summary_box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6); + summary_box.homogeneous = true; + summary_box.get_style_context().add_class("history-summary-box"); + summary_box.pack_start(create_summary_tile(_("Tracks"), out summary_plays_value), true, true, 0); + summary_box.pack_start(create_summary_tile(_("Stations"), out summary_changes_value), true, true, 0); + summary_box.pack_start(create_summary_tile(_("Unique"), out summary_distinct_value), true, true, 0); + summary_box.pack_start(create_summary_tile(_("Hearted"), out summary_hearted_value), true, true, 0); + content.pack_start(summary_box, false, false, 0); + + var action_row = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6); + action_row.margin_top = 10; + action_row.margin_bottom = 10; + action_row.get_style_context().add_class("history-action-row"); + + var copy_button = new Gtk.Button.from_icon_name("edit-copy-symbolic", IconSize.BUTTON); + copy_button.tooltip_text = _("Copy history to clipboard"); + copy_button.clicked.connect(() => { copy_list_to_clipboard(); - context_menu.popdown(); - dropdown_menu.popdown(); + history_popover.popdown(); }); - context_menu.append(copy_item); - var clear_item = new Gtk.MenuItem.with_label(_("Clear All Items")); - clear_item.activate.connect(() => { + var clear_button = new Gtk.Button.from_icon_name("edit-clear-symbolic", IconSize.BUTTON); + clear_button.tooltip_text = _("Clear history"); + clear_button.clicked.connect(() => { clear_all_items(); - context_menu.popdown(); - dropdown_menu.popdown(); }); - context_menu.append(clear_item); - context_menu.show_all(); - } -/** - * @brief Copies the list of menu items to the clipboard. - */ + action_row.pack_start(copy_button, false, false, 0); + action_row.pack_start(clear_button, false, false, 0); + + var action_spacer = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0); + action_spacer.hexpand = true; + action_row.pack_start(action_spacer, true, true, 0); + + total_play_time_value = new Gtk.Label(format_total_play_time()); + total_play_time_value.width_chars = 7; + total_play_time_value.xalign = 1.0f; + total_play_time_value.tooltip_text = _("Total play time"); + total_play_time_value.get_style_context().add_class("history-total-time"); + action_row.pack_start(total_play_time_value, false, false, 12); + + content.pack_start(action_row, false, false, 0); + + history_list = new Gtk.ListBox(); + history_list.selection_mode = Gtk.SelectionMode.NONE; + history_list.activate_on_single_click = true; + history_list.get_style_context().add_class("history-list"); + + history_scroller = new Gtk.ScrolledWindow(null, null); + history_scroller.hscrollbar_policy = Gtk.PolicyType.NEVER; + history_scroller.vscrollbar_policy = Gtk.PolicyType.AUTOMATIC; + history_scroller.min_content_width = 420; + history_scroller.min_content_height = 320; + history_scroller.max_content_height = 420; + history_scroller.add(history_list); + content.pack_start(history_scroller, true, true, 0); + + history_popover.add(content); + content.show_all(); + history_popover.hide(); + } // build_popover + + + private Gtk.Widget create_summary_tile(string label_text, out Gtk.Label value_label) + { + var tile = new Gtk.Box(Gtk.Orientation.VERTICAL, 2); + tile.hexpand = true; + tile.margin_top = 4; + tile.margin_bottom = 4; + tile.margin_start = 6; + tile.margin_end = 6; + tile.get_style_context().add_class("history-summary-tile"); + + value_label = new Gtk.Label("0"); + value_label.xalign = 0.5f; + value_label.get_style_context().add_class("history-summary-value"); + + var caption_label = new Gtk.Label(label_text); + caption_label.xalign = 0.5f; + caption_label.get_style_context().add_class("history-summary-caption"); + + tile.pack_start(value_label, false, false, 0); + tile.pack_start(caption_label, false, false, 0); + return tile; + } // create_summary_tile + + + /** + * @brief Copies the list of menu items to the clipboard. + */ private void copy_list_to_clipboard() { var clipboard = Gtk.Clipboard.get_default(Gdk.Display.get_default()); - clipboard.set_text(clipboard_text.str, -1); - } + clipboard.set_text(build_clipboard_text(), -1); + } // copy_list_to_clipboard -/** - * @brief Clears all items from the dropdown menu. - */ + + /** + * @brief Clears all items from the dropdown menu. + */ private void clear_all_items() { - foreach (var item in menu_items.values) - dropdown_menu.remove(item); - menu_items.clear(); - clipboard_text.truncate(); - if (_history != null) - _history.clear(); - } + history.clear_preserving_last(); + } // clear_all_items -/** - * @brief Appends a station-title pair to the dropdown menu. - * @param station The station to add. - * @param title The title associated with the station. - */ + + /** + * @brief Appends a station-title pair to the dropdown menu. + * @param station The station to add. + * @param title The title associated with the station. + */ public void append_station_title_pair(Station station, string title) { - ensure_history(); - _history.append(station, title); - } + history.append(station, title); + } // append_station_title_pair -/** - * @brief Replaces the last station-title pair if it matches the provided title. - * - * @param station Station associated with the last item. - * @param title_to_match Title to match against the last item. - * @param replacement_title Title to use when replacing. - * @return True if the last item was replaced. - */ - public bool replace_last_title_if_matches(Station station, string title_to_match, string replacement_title) + + /** + * @brief Replaces the last station-title pair if it matches the provided title. + * + * @param station Station associated with the last item. + * @param title_to_match Title to match against the last item. + * @param replacement_title Title to use when replacing. + * @return True if the last item was replaced. + */ + public bool set_last_entry_hearted_if_matches(Station station, string title, bool hearted) { - ensure_history(); - return _history.replace_last_if_matches(station, title_to_match, replacement_title); - } + return history.set_last_entry_hearted_if_matches(station, title, hearted); + } // set_last_entry_hearted_if_matches -/** - * @brief Returns all hearted track titles from the list. - * - * @return List of track titles without the heart prefix. - */ + + /** + * @brief Returns all hearted track titles from the list. + * + * @return List of track titles without the heart prefix. + */ public Gee.List get_hearted_titles() { - ensure_history(); - return _history.get_hearted_titles(); - } + return history.get_hearted_titles(); + } // get_hearted_titles + + /** + * @brief Returns all hearted history lines from the list. + * + * @return List of history lines without the heart prefix. + */ public Gee.List get_hearted_history_lines_without_hearts() { - ensure_history(); - var results = new Gee.ArrayList(); - foreach (var entry in _history.entries) + return history.get_hearted_history_lines(); + } // get_hearted_history_lines_without_hearts + + + private void bind_history() + { + history.entry_added_sig.connect((entry) => { add_menu_item(entry); }); + history.entry_removed_sig.connect((entry) => { remove_menu_item(entry); }); + history.entry_changed_sig.connect((entry) => { update_menu_item(entry); }); + history.cleared_sig.connect(() => { + clear_menu_items(); + refresh_summary(); + }); + + foreach (var entry in history.entries) + add_menu_item(entry); + } // bind_history + + + /** + * @brief Adds a menu item for the given history entry. + * @param entry The history entry to add. + */ + private void add_menu_item(HistoryEntry entry) + { + var row = build_history_row(entry); + rows_by_entry.set(entry, row); + history_list.insert(row, 0); + refresh_row_styles(); + refresh_summary(); + } // add_menu_item + + + /** + * @brief Removes the menu item for the given history entry. + * @param entry The history entry to remove. + */ + private void remove_menu_item(HistoryEntry entry) + { + var row = rows_by_entry.get(entry); + if (row == null) + return; + history_list.remove(row); + rows_by_entry.unset(entry); + refresh_row_styles(); + refresh_summary(); + } // remove_menu_item + + + private void update_menu_item(HistoryEntry entry) + { + var row = rows_by_entry.get(entry); + if (row == null) + return; + rebuild_history_row(row, entry); + refresh_summary(); + } // update_menu_item + + + private void clear_menu_items() + { + foreach (var row in rows_by_entry.values) + history_list.remove(row); + rows_by_entry.clear(); + refresh_row_styles(); + } // clear_menu_items + + + private Gtk.ListBoxRow build_history_row(HistoryEntry entry) + { + var row = new Gtk.ListBoxRow(); + row.activatable = true; + row.selectable = false; + row.get_style_context().add_class("history-row"); + rebuild_history_row(row, entry); + row.activate.connect(() => { + item_station_selected_sig(entry.station); + history_popover.popdown(); + }); + return row; + } // build_history_row + + + private void rebuild_history_row(Gtk.ListBoxRow row, HistoryEntry entry) + { + var existing_child = row.get_child(); + if (existing_child != null) + row.remove(existing_child); + + var shell = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 10); + shell.margin_top = 8; + shell.margin_bottom = 8; + shell.margin_start = 10; + shell.margin_end = 10; + + var text_box = new Gtk.Box(Gtk.Orientation.VERTICAL, 3); + text_box.hexpand = true; + + var station_label = new Gtk.Label(entry.station.name); + station_label.xalign = 0.0f; + station_label.ellipsize = Pango.EllipsizeMode.END; + station_label.get_style_context().add_class("history-row-station"); + + var title_label = new Gtk.Label(format_entry_title(entry)); + title_label.xalign = 0.0f; + title_label.wrap = true; + title_label.wrap_mode = Pango.WrapMode.WORD_CHAR; + title_label.max_width_chars = 52; + title_label.ellipsize = Pango.EllipsizeMode.END; + title_label.tooltip_text = format_entry_title(entry); + title_label.get_style_context().add_class("history-row-title"); + if (entry.hearted) + title_label.get_style_context().add_class("history-row-title-hearted"); + + text_box.pack_start(station_label, false, false, 0); + text_box.pack_start(title_label, false, false, 0); + shell.pack_start(text_box, true, true, 0); + + var meta_box = new Gtk.Box(Gtk.Orientation.VERTICAL, 4); + meta_box.valign = Gtk.Align.START; + meta_box.halign = Gtk.Align.END; + meta_box.margin_start = 8; + + var time_label = new Gtk.Label(format_play_time(entry)); + time_label.width_chars = 7; + time_label.xalign = 1.0f; + time_label.get_style_context().add_class("history-play-time"); + meta_box.pack_start(time_label, false, false, 0); + + if (entry.hearted) { - var title = entry.title; - if (!title.has_prefix("♥ ")) - continue; - title = strip_heart_prefix(title); - results.add(entry.station.name + ": " + title); + var heart_label = new Gtk.Label("♥"); + heart_label.valign = Gtk.Align.START; + heart_label.halign = Gtk.Align.END; + heart_label.get_style_context().add_class("history-heart-badge"); + meta_box.pack_start(heart_label, false, false, 0); + row.get_style_context().add_class("history-row-hearted"); } - return results; - } + else + { + row.get_style_context().remove_class("history-row-hearted"); + } + + shell.pack_start(meta_box, false, false, 0); + + row.add(shell); + row.tooltip_text = format_entry_title(entry); + row.show_all(); + } // rebuild_history_row - private string strip_heart_prefix(string title) + + private string format_entry_title(HistoryEntry entry) { - if (!title.has_prefix("♥ ")) - return title; - var space_index = title.index_of(" "); - if (space_index < 0) - return ""; - return title.substring(space_index + 1).strip(); - } + var title = entry.title.strip(); + if (title == "") + return _("Unknown Track"); + return title; + } // format_entry_title + - private void ensure_history() + private void refresh_row_styles() { - if (_history != null) - return; + var children = history_list.get_children(); + int index = 0; + foreach (var child in children) + { + var row = child as Gtk.ListBoxRow; + if (row == null) + continue; - _history = new HistoryList(); - _history.entry_added_sig.connect((entry) => { add_menu_item(entry); }); - _history.entry_removed_sig.connect((entry) => { remove_menu_item(entry); }); - _history.cleared_sig.connect(() => { menu_items.clear(); clipboard_text.truncate(); }); - } + if ((index % 2) == 1) + row.get_style_context().add_class("history-row-alt"); + else + row.get_style_context().remove_class("history-row-alt"); - private void add_menu_item(HistoryEntry entry) + index++; + } + } // refresh_row_styles + + + private void refresh_summary() { - var label_text = entry.station.name + "\n\t" + entry.title; - var item = new Gtk.MenuItem.with_label(label_text); - menu_items.set(entry, item); + summary_plays_value.label = history.entries.size.to_string(); + summary_changes_value.label = history.station_change_count.to_string(); + summary_distinct_value.label = history.distinct_track_count.to_string(); + summary_hearted_value.label = history.hearted_track_count.to_string(); + if (total_play_time_value != null) + total_play_time_value.label = format_total_play_time(); - item.button_press_event.connect((event) => { - if (event.button == 1) // Left click - { - item_station_selected_sig(entry.station); - dropdown_menu.popdown(); - return true; - } - else if (event.button == 3) // Right click + bool has_entries = history.entries.size > 0; + this.sensitive = has_entries; + + if (history_scroller != null) + history_scroller.min_content_height = history.entries.size > MAX_VISIBLE_ROWS ? 420 : 320; + } // refresh_summary + + + private void start_row_refresh_timer() + { + row_refresh_timeout_id = Timeout.add_seconds(1, () => + { + if (!history_popover.visible) + return Source.CONTINUE; + + foreach (var entry in history.entries) { - context_menu.popup_at_pointer(event); - return true; + if (entry.playing) + update_menu_item(entry); } - return false; + + return Source.CONTINUE; }); + } // start_row_refresh_timer - item.show(); - dropdown_menu.prepend(item); - clipboard_text.prepend(label_text + "\n"); - } - private void remove_menu_item(HistoryEntry entry) + private string format_play_time(HistoryEntry entry) { - var item = menu_items.get(entry); - if (item == null) - return; - dropdown_menu.remove(item); - menu_items.unset(entry); - var label_text = entry.station.name + "\n\t" + entry.title; - if (clipboard_text.str.has_prefix(label_text + "\n")) - clipboard_text.erase(0, label_text.length + 1); - } + int total_seconds = entry.get_total_played_seconds(); + return format_seconds(total_seconds); + } // format_play_time -/** - * @brief Limits the width of the dropdown menu to 2/3 of the header bar width. - */ - private void limit_dropdown_menu_width() + + private string format_total_play_time() + { + return format_seconds(history.total_played_seconds); + } // format_total_play_time + + + private string format_seconds(int total_seconds) + { + int hours = total_seconds / 3600; + int minutes = (total_seconds % 3600) / 60; + int seconds = total_seconds % 60; + + if (hours > 0) + return "%d:%02d:%02d".printf(hours, minutes, seconds); + + return "%d:%02d".printf(minutes, seconds); + } // format_seconds + + + private string build_clipboard_text() { - var header_bar = this.get_toplevel() as Gtk.HeaderBar; - if (header_bar != null) + var builder = new StringBuilder(); + for (int index = history.entries.size - 1; index >= 0; index--) { - var max_width = header_bar.get_allocated_width() * 2 / 3; - dropdown_menu.set_size_request(max_width, -1); + var entry = history.entries[index]; + builder.append(entry.station.name).append("\n\t"); + if (entry.hearted) + builder.append("♥ "); + builder.append(format_entry_title(entry)).append("\n"); } - } -} + return builder.str; + } // build_clipboard_text +} // ListButton diff --git a/src/Widgets/PlayButton.vala b/src/Widgets/PlayButton.vala index 39ed1f92..e105b28a 100644 --- a/src/Widgets/PlayButton.vala +++ b/src/Widgets/PlayButton.vala @@ -99,7 +99,7 @@ public class Tuner.Widgets.PlayButton : Gtk.Button case StreamPlayer.State.STOPPED_ERROR: image = ERROR; image.opacity = 0.5; - string? error_message = app().player.play_error_message; // TODO Use signals? + string? error_message = app().player.play_error_message; if (error_message == null || error_message.strip () == "") error_message = "An error occurred during playback."; tooltip_text = error_message; diff --git a/src/Widgets/PreferencesPopover.vala b/src/Widgets/PreferencesPopover.vala index 0703336b..a78cdffd 100644 --- a/src/Widgets/PreferencesPopover.vala +++ b/src/Widgets/PreferencesPopover.vala @@ -71,6 +71,14 @@ public class Tuner.Widgets.PreferencesPopover : Gtk.Popover start_on_starred.margin_start = ROW_INDENT; + // Startup sound + var startup_jingle = new Gtk.ModelButton (); + startup_jingle.text = _("Play startup jingle"); + startup_jingle.action_name = Window.ACTION_PREFIX + Window.ACTION_STARTUP_JINGLE; + startup_jingle.tooltip_text = _("If enabled, Tuner plays a short sound when it starts"); + startup_jingle.margin_start = ROW_INDENT; + + // Play Display var stream_info = new Gtk.ModelButton (); stream_info.text = _("Show stream info when playing"); @@ -157,9 +165,11 @@ public class Tuner.Widgets.PreferencesPopover : Gtk.Popover menu_grid.attach (new Gtk.SeparatorMenuItem (), 0, vpos++, 4, 1); menu_grid.attach (autoplay_item, 0, vpos++, 4, 1); - menu_grid.attach (play_restart_item, 0, vpos++, 4, 1); + menu_grid.attach (startup_jingle, 0, vpos++, 4, 1); + + menu_grid.attach (new Gtk.SeparatorMenuItem (), 0, vpos++, 4, 1); menu_grid.attach (stream_info, 0, vpos++, 4, 1); diff --git a/src/Widgets/TitleBox.vala b/src/Widgets/TitleBox.vala index c30d1d4c..2ca63bf9 100644 --- a/src/Widgets/TitleBox.vala +++ b/src/Widgets/TitleBox.vala @@ -175,7 +175,7 @@ public class Tuner.Widgets.TitleBox : Gtk.Box public Gee.List get_hearted_history_lines_without_hearts() { return _headerbar.get_hearted_history_lines_without_hearts(); - } + } // get_hearted_history_lines_without_hearts } // Tuner.Widgets.TitleBox diff --git a/src/Widgets/Window.vala b/src/Widgets/Window.vala index b0852a7c..8b772a6b 100644 --- a/src/Widgets/Window.vala +++ b/src/Widgets/Window.vala @@ -53,6 +53,7 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow public const string ACTION_ENABLE_AUTOPLAY = "action_enable_autoplay"; public const string ACTION_ENABLE_PLAY_RESTART = "action_enable_play_restart"; public const string ACTION_START_ON_STARRED = "action_starred_start"; + public const string ACTION_STARTUP_JINGLE = "action_startup_jingle"; 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"; @@ -92,6 +93,7 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow { ACTION_ENABLE_AUTOPLAY, on_action_enable_autoplay, null, "false" }, { ACTION_ENABLE_PLAY_RESTART, on_action_enable_play_restart, null, "false" }, { ACTION_START_ON_STARRED, on_action_start_on_starred, null, "false" }, + { ACTION_STARTUP_JINGLE, on_action_startup_jingle, null, "true" }, { 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" }, @@ -276,6 +278,7 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow change_action_state (ACTION_ENABLE_AUTOPLAY, settings.auto_play); change_action_state (ACTION_ENABLE_PLAY_RESTART, settings.play_restart); change_action_state (ACTION_START_ON_STARRED, settings.start_on_starred); + change_action_state (ACTION_STARTUP_JINGLE, settings.startup_jingle); 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); @@ -403,6 +406,22 @@ public class Tuner.Widgets.Window : Gtk.ApplicationWindow } // on_action_start_on_starred + /** + * @brief Handles the startup-jingle action. + * @param action The SimpleAction that triggered this method. + * @param parameter The parameter passed with the action (unused). + */ + public void on_action_startup_jingle (SimpleAction action, Variant? parameter) + { + toggle_setting_action( + action, + "on_action_startup_jingle", + () => { return settings.startup_jingle; }, + (value) => { settings.startup_jingle = value; } + ); + } // on_action_startup_jingle + + /** * @brief Handles stream metadata display preference changes. * diff --git a/src/Widgets/base/HistoryList.vala b/src/Widgets/base/HistoryList.vala deleted file mode 100644 index 8af74eab..00000000 --- a/src/Widgets/base/HistoryList.vala +++ /dev/null @@ -1,144 +0,0 @@ -/** - * SPDX-FileCopyrightText: Copyright © 2026 - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * @file HistoryList.vala - * - * @brief History list model for station/title entries. - */ - -using Gee; -using Tuner.Models; - -public class Tuner.Widgets.Base.HistoryEntry : GLib.Object -{ - public Station station { get; construct; } - public string title { get; construct; } - - public HistoryEntry(Station station, string title) - { - Object(station: station, title: title); - } -} - -/** - * @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) - entry_removed_sig(entry); - _entries.clear(); - _last_entry = null; - 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) - return; - - if (_last_entry != null && _last_entry.station == station && _last_entry.title == "") - remove_last(); - - var entry = new HistoryEntry(station, title); - _entries.add(entry); - _last_entry = entry; - 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) - return false; - if (_last_entry.station != station || _last_entry.title != title_to_match) - return false; - - remove_last(); - append(station, replacement_title); - 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(); - foreach (var entry in _entries) - { - if (!entry.title.has_prefix("♥ ")) - continue; - var title = strip_heart_prefix(entry.title); - if (title != "") - results.add(title); - } - return results; - } - - private string strip_heart_prefix(string title) - { - if (!title.has_prefix("♥ ")) - return title; - var space_index = title.index_of(" "); - if (space_index < 0) - return ""; - return title.substring(space_index + 1).strip(); - } - - private void remove_last() - { - if (_last_entry == null) - return; - _entries.remove(_last_entry); - entry_removed_sig(_last_entry); - _last_entry = _entries.size > 0 ? _entries.get(_entries.size - 1) : null; - } -} diff --git a/src/Widgets/base/StationListBoxContent.vala b/src/Widgets/base/StationListBoxContent.vala index 5d779039..ad5824ea 100644 --- a/src/Widgets/base/StationListBoxContent.vala +++ b/src/Widgets/base/StationListBoxContent.vala @@ -70,7 +70,7 @@ namespace Tuner.Widgets.Base foreach (var child in _content.get_children ()) { child.destroy (); } _content_list = content; - _content.add (_content_list); // FIXME analyze why when 'saving a search' content is double wrapped? + _content.add (_content_list); show_content (); show_all (); } diff --git a/src/Widgets/base/TunerStatus.vala b/src/Widgets/base/TunerStatus.vala index 961abafa..46975078 100644 --- a/src/Widgets/base/TunerStatus.vala +++ b/src/Widgets/base/TunerStatus.vala @@ -253,6 +253,215 @@ public class Tuner.Widgets.Base.AnimatedTunerIcon : Gtk.DrawingArea } } + +/** + * @brief Animated jukebox icon that moves the tonearm and spins the record. + * + * Renders the jukebox SVG from resources, then animates sub-layers: + * - `tuner-base`: static cabinet art + * - `window`: static upper glass + * - `needle-position`: translated across the record + * - `record-position`: stationary clipped record for static icon rendering + * - `knob-rotation`: record art rotated inside a Cairo clip + */ +public class Tuner.Widgets.Base.AnimatedJukeboxIcon : Gtk.DrawingArea +{ + // SVG coordinate system is 128x128. These match the jukebox artwork geometry. + private const double NEEDLE_BASE_X = 0.0; + private const double NEEDLE_MIN_X = -4.0; + private const double NEEDLE_MAX_X = 18.0; + private const double RECORD_POSITION_X = 27.591421; + private const double RECORD_POSITION_Y = 23.087348; + private const double RECORD_SOURCE_CENTER = 232.5; + private const double RECORD_SCALE_X = 0.15870427; + private const double RECORD_SCALE_Y = 0.15939803; + private const double RECORD_CENTER_X = RECORD_POSITION_X + (RECORD_SOURCE_CENTER * RECORD_SCALE_X); + private const double RECORD_CENTER_Y = RECORD_POSITION_Y + (RECORD_SOURCE_CENTER * RECORD_SCALE_Y); + private const double RECORD_CLIP_X = 27.594669; + private const double RECORD_CLIP_Y = 23.079477; + private const double RECORD_CLIP_WIDTH = 73.808601; + private const double RECORD_CLIP_HEIGHT = 37.085617; + private const double RECORD_TURNS = 4.0; + private const int ANIMATION_DURATION_MS = 2000; + + private Rsvg.Handle _handle; + private Rsvg.Handle _record_handle; + private uint _animation_tick_id = 0; + private int64 _animation_start_us = 0; + private double _needle_offset = 0.0; + private double _record_angle = 0.0; + private double _start_needle_offset = 0.0; + private double _start_record_angle = 0.0; + private double _target_needle_offset = 0.0; + private double _target_record_angle = 0.0; + + /** + * @brief Create an animated jukebox 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 AnimatedJukeboxIcon (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); + + try + { + var bytes = GLib.resources_lookup_data ("/io/github/tuner_labs/tuner/icons/tuner:background-jukebox.svg", GLib.ResourceLookupFlags.NONE); + _handle = new Rsvg.Handle.from_data (bytes.get_data ()); + string svg = (string) bytes.get_data (); + string animation_svg = svg.replace ("clip-path=\"url(#clipPath2)\"", ""); + _record_handle = new Rsvg.Handle.from_data (animation_svg.data); + } + catch (GLib.Error e) + { + warning ("Failed to load jukebox SVG for animation: %s", e.message); + } + + draw.connect (on_draw); + destroy.connect (() => + { + if (_animation_tick_id != 0) + { + remove_tick_callback (_animation_tick_id); + _animation_tick_id = 0; + } + }); + } // AnimatedJukeboxIcon + + + /** + * @brief Animate the jukebox to a normalized station position. + * @param normalized_position 0.0 = tonearm-left, 1.0 = tonearm-right. + */ + public void animate_to (double normalized_position) + { + double target_norm = Math.fmax (0.0, Math.fmin (1.0, normalized_position)); + + _target_needle_offset = (NEEDLE_MIN_X + target_norm * (NEEDLE_MAX_X - NEEDLE_MIN_X)) - NEEDLE_BASE_X; + _target_record_angle = _record_angle + (360.0 * RECORD_TURNS) + (target_norm * 360.0); + + _start_needle_offset = _needle_offset; + _start_record_angle = _record_angle; + _animation_start_us = 0; + + if (_animation_tick_id == 0) + _animation_tick_id = add_tick_callback (on_animation_tick); + } // animate_to + + + /** + * @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; + _record_angle = _target_record_angle; + queue_draw (); + + _animation_tick_id = 0; + _animation_start_us = 0; + return false; + } + + double eased = 1.0 - Math.pow (1.0 - progress, 3.0); + _needle_offset = _start_needle_offset + ((_target_needle_offset - _start_needle_offset) * eased); + _record_angle = _start_record_angle + ((_target_record_angle - _start_record_angle) * eased); + queue_draw (); + return true; + } // on_animation_tick + + + /** + * @brief Draw handler. Renders SVG layers with animation transforms. + */ + private bool on_draw (Cairo.Context cr) + { + if (_handle == null || _record_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; + + 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); + + render_static (cr, "tuner-base"); + render_static (cr, "window"); + render_clipped_rotated_record (cr, _record_angle); + render_translated (cr, "needle-position", _needle_offset, 0.0); + + cr.restore (); + return false; + } // on_draw + + + /** + * @brief Render an SVG element by id without transforms. + */ + private void render_static (Cairo.Context cr, string id) + { + _handle.render_cairo_sub (cr, "#" + id); + } // render_static + + + /** + * @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 (); + } // render_translated + + + /** + * @brief Render the record rotation inside the stationary SVG clip path. + * @param angle_deg Rotation angle in degrees. + */ + private void render_clipped_rotated_record (Cairo.Context cr, double angle_deg) + { + cr.save (); + cr.rectangle (RECORD_CLIP_X, RECORD_CLIP_Y, RECORD_CLIP_WIDTH, RECORD_CLIP_HEIGHT); + cr.clip (); + cr.translate (RECORD_CENTER_X, RECORD_CENTER_Y); + cr.rotate (angle_deg * Math.PI / 180.0); + cr.translate (-RECORD_CENTER_X, -RECORD_CENTER_Y); + _record_handle.render_cairo_sub (cr, "#knob-rotation"); + cr.restore (); + } // render_clipped_rotated_record +} // AnimatedJukeboxIcon + + /** * TunerStatus widget for displaying tuner on-line status and data provider information. */ diff --git a/src/meson.build b/src/meson.build index 43fc81e8..b1e93746 100644 --- a/src/meson.build +++ b/src/meson.build @@ -28,6 +28,7 @@ sources = files ( 'Models/Favicon.vala', 'Models/StreamPlayer.vala', 'Models/StreamMetadata.vala', + 'Models/History.vala', 'Models/StationListBoxConfig.vala', 'Models/StationListBoxPager.vala', @@ -44,7 +45,6 @@ sources = files ( 'Widgets/base/RevealLabel.vala', 'Widgets/base/CyclingRevealLabel.vala', 'Widgets/base/SelectorButton.vala', - 'Widgets/base/HistoryList.vala', 'Widgets/base/StationListBox.vala', 'Widgets/base/StationListBoxHeader.vala', 'Widgets/base/StationListBoxContent.vala',