diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2c8d484..1599701 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: # This job runs in a special container designed for building Flatpaks for AppCenter container: - image: ghcr.io/elementary/flatpak-platform/runtime:6 + image: ghcr.io/elementary/flatpak-platform/runtime:8 options: --privileged # Steps represent a sequence of tasks that will be executed as part of the job @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 # Builds your flatpak manifest using the Flatpak Builder action - - uses: bilelmoussaoui/flatpak-github-actions/flatpak-builder@v3 + - uses: bilelmoussaoui/flatpak-github-actions/flatpak-builder@v6.3 with: # This is the name of the Bundle file we're building and can be anything you like bundle: Tuner.flatpak diff --git a/.gitignore b/.gitignore index 871c25d..06d16bb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ build-aux *~ .vscode .buildconfig -.flatpak-builder \ No newline at end of file +.flatpak-builder +code.sh +favicon.ico +tuner.code-workspace diff --git a/DEVELOP.md b/DEVELOP.md new file mode 100644 index 0000000..c0b57f6 --- /dev/null +++ b/DEVELOP.md @@ -0,0 +1,47 @@ +# ![icon](docs/logo_01.png) Develop, Build and Contribute to Tuner [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](http://www.gnu.org/licenses/gpl-3.0) + + +Discover and Listen to your favourite internet radio stations. + +## Overview + +Tuner is hosted on Github, and linked to Flathub so as releases are pushed Flathub will automatically update its repositary. It is writen in [Vala](https://vala.dev/), a C#/Java/JavaFX-like language with a self-hosting compiler that generates C code and uses the GObject type system and wrapping a number of GTK libraries. It uses [Meson](https://mesonbuild.com/) as its build system. + + + +### Dependencies + +```bash +granite +gstreamer-1.0 +gstreamer-player-1.0 +gtk+-3.0 +json-glib-1.0 +libgee-0.8 +libsoup-3.0 +meson +vala +``` + +### Building + +Make sure you have the dependencies installed: + +```bash +sudo apt install git valac meson +sudo apt install libgtk-3-dev libgee-0.8-dev libgranite-dev libgstreamer1.0-dev libgstreamer-plugins-bad1.0-dev libsoup-3.0-dev libjson-glib-dev +``` + +Clone the repo and drop into the Tuner directory. Configure Meson for development debug build, build Tuner with Ninja, and run the result: + +```bash +meson setup --buildtype=debug builddir +ninja -C builddir +./builddir/com.github.louis77.tuner +``` + + +```bash +meson configure -Dprefix=/usr +sudo ninja install +``` diff --git a/README.md b/README.md index 766c5c7..b4aa859 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Things I need help with: ### Flathub -Tuner is available on Flathub, but there are some known bugs: +Tuner is available asa Flatpak on Flathub: https://flathub.org/apps/details/com.github.louis77.tuner ### elementary OS @@ -83,7 +83,7 @@ While I hacked on this App, I discovered so many cool and new stations, which ma ## Features -- Uses radio-browser.info catalog +- Uses [radio-browser.info](https://www.radio-browser.info/) radio station catalog - Presets various selection of stations (random, top, trending, genres) - Save favourite stations - Sends a click count to radio-browser.info on station click @@ -102,38 +102,18 @@ While I hacked on this App, I discovered so many cool and new stations, which ma * `TUNER_API` - a `:` separated list of API servers to read from, e.g. * `export TUNER_API="de1.api.radio-browser.info:nl1.api.radio-browser.info"; com.github.louis77.tuner` -## Dependencies - -```bash -granite -gtk+-3.0 -gstreamer-1.0 -gstreamer-player-1.0 -libsoup-3.0 -json-glib-1.0 -libgee-0.8 -meson -vala -``` -## Building +## Build, Maintance and Development of Tuner -Make sure you have the dependencies installed: +Building, developing and maintianing **Tuner** is detailed seperately and in detail in the [DEVELOP](DEVELOP.md) markdown. -```bash -sudo apt install git valac meson -sudo apt install libgtk-3-dev libgee-0.8-dev libgranite-dev libgstreamer1.0-dev libgstreamer-plugins-bad1.0-dev libsoup-3.0-dev libjson-glib-dev -``` -Then clone this repo and build it locally: +## Support + +Feature request, observations and Issues can be documented with tickets on [Github](https://github.com/louis77/tuner/issues) -```bash -meson build && cd build -meson configure -Dprefix=/usr -sudo ninja install -``` -## Known Issues +### Known Issues - If AAC/AAC+ streams don't play (found on Elementary OS 6) install the following dependency: diff --git a/data/com.github.louis77.tuner.appdata.xml.in b/data/com.github.louis77.tuner.appdata.xml.in index 20f4f1e..524c798 100644 --- a/data/com.github.louis77.tuner.appdata.xml.in +++ b/data/com.github.louis77.tuner.appdata.xml.in @@ -62,6 +62,18 @@ + + +

Maintanance release:

+
    +
  • Added API server lookup via SRV and backup via JSON, plus randomization
  • +
  • Added JSON validation for empty nodes
  • +
  • Refactored libsoup calls out to HttpClient.vala
  • +
  • Refactored loading of favicons to Favicon.vala
  • +
  • Turned off TLS checks for HTTP calls to avoid non loading of favicons with invalid certs
  • +
+
+

Maintanance release:

diff --git a/data/meson.build b/data/meson.build index 2f962e3..1ce764f 100644 --- a/data/meson.build +++ b/data/meson.build @@ -16,7 +16,7 @@ endforeach i18n.merge_file ( input: meson.project_name () + '.desktop.in', output: meson.project_name () + '.desktop', - po_dir: join_paths (meson.source_root (), 'po', 'extra'), + po_dir: join_paths (meson.project_source_root(), 'po', 'extra'), type: 'desktop', install: true, install_dir: join_paths (get_option ('datadir'), 'applications') @@ -26,7 +26,7 @@ i18n.merge_file ( i18n.merge_file ( input: meson.project_name () + '.appdata.xml.in', output: meson.project_name () + '.appdata.xml', - po_dir: join_paths (meson.source_root (), 'po', 'extra'), + po_dir: join_paths (meson.project_source_root(), 'po', 'extra'), install: true, install_dir: join_paths (get_option ('datadir'), 'metainfo') ) diff --git a/meson.build b/meson.build index 5977853..412fce1 100644 --- a/meson.build +++ b/meson.build @@ -2,7 +2,7 @@ project ( 'com.github.louis77.tuner', 'vala', 'c', - version: '1.5.3' + version: '1.5.4' ) # if meson.get_compiler ('vala').get_id() == 'valac' @@ -24,6 +24,7 @@ add_project_arguments ( config_data = configuration_data() config_data.set_quoted('LOCALEDIR', join_paths(get_option('prefix'), get_option('localedir'))) config_data.set_quoted('GETTEXT_PACKAGE', meson.project_name()) +config_data.set_quoted('VERSION', meson.project_version()) config_file = configure_file( input: 'src/Config.vala.in', output: '@BASENAME@', diff --git a/po/extra/meson.build b/po/extra/meson.build index 526e40f..43f05c5 100644 --- a/po/extra/meson.build +++ b/po/extra/meson.build @@ -1,7 +1,7 @@ # Install metadata translations i18n.gettext ('extra', args: [ - '--directory=' + meson.source_root (), + '--directory=' + meson.project_source_root(), '--from-code=UTF-8' ], install: false diff --git a/po/meson.build b/po/meson.build index 011a4ae..e3a09fa 100644 --- a/po/meson.build +++ b/po/meson.build @@ -1,7 +1,7 @@ # Install main translations i18n.gettext (meson.project_name (), args: [ - '--directory=' + meson.source_root (), + '--directory=' + meson.project_source_root(), '--from-code=UTF-8', '-cTRANSLATORS' ], diff --git a/src/Application.vala b/src/Application.vala index 22e59a0..95c01f1 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -3,6 +3,14 @@ * SPDX-FileCopyrightText: 2020-2022 Louis Brauer */ + /** + Application + + Entry point for Tuner + */ +/** + * @brief Entry point for Tuner application + */ public class Tuner.Application : Gtk.Application { public GLib.Settings settings { get; construct; } @@ -12,7 +20,7 @@ public class Tuner.Application : Gtk.Application { public Window window; - public const string APP_VERSION = "1.5.3"; + public const string APP_VERSION = VERSION; public const string APP_ID = "com.github.louis77.tuner"; public const string STAR_CHAR = "★ "; public const string UNSTAR_CHAR = "☆ "; @@ -21,6 +29,9 @@ public class Tuner.Application : Gtk.Application { { "resume-window", on_resume_window } }; + /** + * @brief Constructor for the Application + */ public Application () { Object ( application_id: APP_ID, @@ -28,6 +39,9 @@ public class Tuner.Application : Gtk.Application { ); } + /** + * @brief Construct block for initializing the application + */ construct { GLib.Intl.setlocale (LocaleCategory.ALL, ""); GLib.Intl.bindtextdomain (GETTEXT_PACKAGE, LOCALEDIR); @@ -46,8 +60,15 @@ public class Tuner.Application : Gtk.Application { add_action_entries(ACTION_ENTRIES, this); } + /** + * @brief Singleton instance of the Application + */ public static Application _instance = null; + /** + * @brief Getter for the singleton instance + * @return The Application instance + */ public static Application instance { get { if (_instance == null) { @@ -57,6 +78,9 @@ public class Tuner.Application : Gtk.Application { } } + /** + * @brief Activates the application + */ protected override void activate() { if (window == null) { window = new Window (this, player); @@ -68,10 +92,17 @@ public class Tuner.Application : Gtk.Application { } + /** + * @brief Resumes the window + */ private void on_resume_window() { window.present(); } + /** + * @brief Ensures a directory exists + * @param path The directory path to ensure + */ private void ensure_dir (string path) { var dir = File.new_for_path (path); diff --git a/src/Config.vala.in b/src/Config.vala.in index af62dcf..30582b0 100644 --- a/src/Config.vala.in +++ b/src/Config.vala.in @@ -1,2 +1,3 @@ public const string GETTEXT_PACKAGE = @GETTEXT_PACKAGE@; public const string LOCALEDIR = @LOCALEDIR@; +public const string VERSION = @VERSION@; diff --git a/src/Main.vala b/src/Main.vala index 37f7787..09a20a5 100644 --- a/src/Main.vala +++ b/src/Main.vala @@ -2,7 +2,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later * SPDX-FileCopyrightText: 2020-2022 Louis Brauer */ - + public static int main (string[] args) { Gst.init (ref args); diff --git a/src/Models/StationStore.vala b/src/Models/StationStore.vala index ae7c27d..88f1074 100644 --- a/src/Models/StationStore.vala +++ b/src/Models/StationStore.vala @@ -3,8 +3,13 @@ * SPDX-FileCopyrightText: 2020-2022 Louis Brauer */ -// StationStore can store and retrieve a collection of stations -// in a JSON file + /** + StationStore + + Store and retrieve a collection of stations in a JSON file, i.e. favorites + + Uses libgee for data structures. + */ using Gee; @@ -12,16 +17,16 @@ namespace Tuner.Model { public class StationStore : Object { private ArrayList _store; - private File _data_file; + private File _favorites_file; - public StationStore (string data_path) { + public StationStore (string favorites_path) { Object (); _store = new ArrayList (); - _data_file = File.new_for_path (data_path); + _favorites_file = File.new_for_path (favorites_path); ensure (); load (); - debug (@"store initialized in path $data_path"); + debug (@"store initialized in path $favorites_path"); } public void add (Station station) { @@ -43,7 +48,7 @@ public class StationStore : Object { // Non-racy approach is to try to create the file first // and ignore errors if it already exists try { - var df = _data_file.create (FileCreateFlags.PRIVATE); + var df = _favorites_file.create (FileCreateFlags.PRIVATE); df.close (); debug (@"store created"); } catch (Error e) { @@ -56,16 +61,19 @@ public class StationStore : Object { Json.Parser parser = new Json.Parser (); try { - var stream = _data_file.read (); + var stream = _favorites_file.read (); parser.load_from_stream (stream); stream.close (); } catch (Error e) { warning (@"store: unable to load data, does it exist? $(e.message)"); } - Json.Node node = parser.get_root (); - Json.Array array = node.get_array (); - array.foreach_element ((a, i, elem) => { + Json.Node? node = parser.get_root (); + + if ( node == null ) return; // No favorites store + + Json.Array array = node.get_array (); // Json-CRITICAL **: 21:02:51.821: json_node_get_array: assertion 'JSON_NODE_IS_VALID (node)' failed + array.foreach_element ((a, i, elem) => { // json_array_foreach_element: assertion 'array != NULL' failed Station station = Json.gobject_deserialize (typeof (Station), elem) as Station; // TODO This should probably not be here but in // DirectoryController @@ -88,8 +96,8 @@ public class StationStore : Object { var data = serialize (); try { - _data_file.delete (); - var stream = _data_file.create ( + _favorites_file.delete (); + var stream = _favorites_file.create ( FileCreateFlags.REPLACE_DESTINATION | FileCreateFlags.PRIVATE ); var s = new DataOutputStream (stream); @@ -130,4 +138,4 @@ public class StationStore : Object { } } -} \ No newline at end of file +} diff --git a/src/Services/Favicon.vala b/src/Services/Favicon.vala new file mode 100644 index 0000000..1b56169 --- /dev/null +++ b/src/Services/Favicon.vala @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2020-2022 Louis Brauer + */ + +/** + * @file Favicon.vala + * @author technosf + * @date 2024-10-01 + * @brief Get, cache and serve favicons + * @version 1.5.4 + * + * This file contains the Tuner.Favicon class, which handles the retrieval, + * caching, and serving of favicons for radio stations. + */ + +/** + * @brief Get, cache and serve favicons + * + * This class handles the retrieval, caching, and serving of favicons for radio stations. + * It provides methods to load favicons from cache or fetch them from the internet. + * + * @class Tuner.Favicon + * @extends Object + */ +public class Tuner.Favicon : GLib.Object { + + /** + * @brief Asynchronously load the favicon for a given station + * + * This method attempts to load the favicon from the cache first. If not found in the cache + * or if forceReload is true, it will fetch the favicon from the internet asynchronously + * scale it to 48x48 pixels, and save it to a cache file. + * + * @param station The station for which to load the favicon + * @param forceReload If true, bypass the cache and fetch the favicon from the internet + * @return The loaded favicon as a Gdk.Pixbuf, or null if loading fails + */ + public static async Gdk.Pixbuf? load_async(Model.Station station, bool forceReload = false) + { + var favicon_cache_file = Path.build_filename(Application.instance.cache_dir, station.id); + + // Check cache first if not forcing reload + if (!forceReload && FileUtils.test(favicon_cache_file, FileTest.EXISTS)) { + try { + return new Gdk.Pixbuf.from_file_at_scale(favicon_cache_file, 48, 48, true); + } catch (Error e) { + warning("Failed to load cached favicon: %s", e.message); + } + } + + // If not in cache or force reload, fetch from internet + uint status_code; + InputStream? stream = yield HttpClient.GETasync(station.favicon_url, out status_code); + + if (stream != null && status_code == 200) { + try { + var pixbuf = yield new Gdk.Pixbuf.from_stream_async(stream, null); + var scaled_pixbuf = pixbuf.scale_simple(48, 48, Gdk.InterpType.BILINEAR); + + // Save to cache + scaled_pixbuf.save(favicon_cache_file, "png"); + + return scaled_pixbuf; + } catch (Error e) { + warning("Failed to process favicon %s: %s", station.favicon_url,e.message); + } + } + return null; + } + } \ No newline at end of file diff --git a/src/Services/HttpClient.vala b/src/Services/HttpClient.vala new file mode 100644 index 0000000..d0c3f89 --- /dev/null +++ b/src/Services/HttpClient.vala @@ -0,0 +1,174 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2020-2022 Louis Brauer + */ + +/** + * @file HttpClient.vala + * @author technosf + * @date 2024-10-01 + * @since 1.5.4 + * @brief HTTP client implementation using Soup library + */ + +using Gee; + +/** + * @class Tuner.HttpClient + * @brief HTTP functions abstracting Soup library + * + * This class provides static methods for making HTTP requests using the Soup library. + * It includes a singleton Soup.Session instance for efficient request handling. + */ +public class Tuner.HttpClient : Object { + + /** + * @brief Singleton instance of Soup.Session + * + * This private static variable holds the single instance of Soup.Session + * used for all HTTP requests in the application. It is initialized lazily + * in the getSession() method. + */ + private static Soup.Session _session; + + /** + * @brief Get the singleton Soup.Session instance + * + * This method returns the singleton Soup.Session instance, creating it + * if it doesn't already exist. The session is configured with a custom + * user agent string and a timeout of 3 seconds. + * + * @return The singleton Soup.Session instance + */ + private static Soup.Session getSession() + { + if (_session == null) + { + _session = new Soup.Session(); + _session.user_agent = @"$(Application.APP_ID)/$(Application.APP_VERSION)"; + _session.timeout = 5; + } + return _session; + } + + /** + * @brief Perform a GET request to the specified URL + * + * @param url_string The URL to send the GET request to + * @param status_code Output parameter for the HTTP status code of the response + * @return InputStream containing the response body, or null if the request failed + * @throws Error if there's an error sending the request or receiving the response + */ + public static InputStream? GET(string url_string, out uint status_code) + { + status_code = 0; + + if (url_string == null || url_string.length < 4) // domains are at least 4 chars + { + warning("GET - Invalid URL: %s", url_string ?? "null"); + return null; + } + + string sanitized_url = ensure_https_prefix(url_string); + + var msg = new Soup.Message("GET", sanitized_url); + + /* + Ignore all TLS certificate errors + */ + msg.accept_certificate.connect ((msg, cert, errors) => { + return true; + }); + + try { + + if (Uri.is_valid(sanitized_url, UriFlags.NONE)) + { + var inputStream = getSession().send(msg); + status_code = msg.status_code; + return inputStream; + } else { + debug("GET - Invalid URL format: %s", sanitized_url); + } + } catch (Error e) { + warning("GET - Error accessing URL: %s (%s)", + sanitized_url, + e.message); + } + + return null; + } + + /** + * @brief Perform an asynchronous GET request to the specified URL + * + * @param url_string The URL to send the GET request to + * @param status_code Output parameter for the HTTP status code of the response + * @return InputStream containing the response body, or null if the request failed + */ + public static async InputStream? GETasync(string url_string, out uint status_code) + { + status_code = 0; + + if (url_string == null || url_string.length < 4 ) // domains are at least 4 chars + { + debug("GETasync - Invalid URL: %s", url_string ?? "null"); + return null; + } + + string sanitized_url = ensure_https_prefix(url_string); + + try { + if (!Uri.is_valid(sanitized_url, UriFlags.NONE)) { + debug("GETasync - Invalid URL format: %s", sanitized_url); + return null; + } + } catch (GLib.UriError e) { + debug("GETasync - URI error: %s", e.message); + return null; + } + + var msg = new Soup.Message("GET", sanitized_url); + + /* + Ignore all TLS certificate errors + */ + msg.accept_certificate.connect ((msg, cert, errors) => { + return true; + }); + + try { + + var inputStream = yield getSession().send_async(msg, Priority.DEFAULT, null); + status_code = msg.status_code; + return inputStream; + + } catch (Error e) { + warning("GETasync - Couldn't fetch resource: %s (%s)", + sanitized_url, + e.message); + } + + return null; + } + + /** + * @brief Ensures that the given URL has an HTTPS prefix + * + * This method checks if the provided URL starts with either "http://" or "https://". + * If it doesn't have either prefix, it adds "https://" to the beginning of the URL. + * + * @param url The input URL string to be checked and potentially modified + * @return A string representing the URL with an HTTPS prefix + * + * @note This method does not validate the URL structure beyond checking for the protocol prefix + */ + private static string ensure_https_prefix(string url) { + // Check if the string starts with "http://" or "https://" + if (!url.has_prefix("http://") && !url.has_prefix("https://")) { + // If it doesn't, prefix the string with "https://" + return "https://" + url; + } + return url; + } +} \ No newline at end of file diff --git a/src/Services/RadioBrowser.vala b/src/Services/RadioBrowser.vala new file mode 100644 index 0000000..9262eb0 --- /dev/null +++ b/src/Services/RadioBrowser.vala @@ -0,0 +1,472 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2020-2022 Louis Brauer + */ + + +using Gee; + +/** + * @namespace Tuner.RadioBrowser + * @brief Interface to radio-browser.info API and servers + * + * This namespace provides functionality to interact with the radio-browser.info API, + * including searching for stations, retrieving station information, and managing user actions + * such as voting and tracking listens. + */ +namespace Tuner.RadioBrowser { + + private const string SRV_SERVICE = "api"; + private const string SRV_PROTOCOL = "tcp"; + private const string SRV_DOMAIN = "radio-browser.info"; + private const string ALL_API = "https://all.api.radio-browser.info"; + + /** + * @class Station + * @brief Station data subset returned from radio-browser API + * + * This class represents a radio station with its properties as returned by the radio-browser API. + */ + public class Station : Object { + /** @brief Unique identifier for the station */ + public string stationuuid { get; set; } + /** @brief Name of the station */ + public string name { get; set; } + /** @brief Resolved URL of the station's stream */ + public string url_resolved { get; set; } + /** @brief Country where the station is located */ + public string country { get; set; } + /** @brief Country code of the station's location */ + public string countrycode { get; set; } + /** @brief URL of the station's favicon */ + public string favicon { get; set; } + /** @brief Number of clicks/listens for the station */ + public uint clickcount { get; set; } + /** @brief URL of the station's homepage */ + public string homepage { get; set; } + /** @brief Audio codec used by the station */ + public string codec { get; set; } + /** @brief Bitrate of the station's stream */ + public int bitrate { get; set; } + } + + /** + * @struct SearchParams + * @brief Parameters for searching radio stations + * + * This struct defines the parameters used for searching radio stations. + */ + public struct SearchParams { + /** @brief Text to search for in station names */ + string text; + /** @brief List of tags to filter stations */ + ArrayList tags; + /** @brief List of station UUIDs to retrieve */ + ArrayList uuids; + /** @brief Country code to filter stations */ + string countrycode; + /** @brief Sort order for the search results */ + SortOrder order; + /** @brief Whether to reverse the sort order */ + bool reverse; + } + + /** + * @brief Error domain for data-related errors + */ + public errordomain DataError { + PARSE_DATA, + NO_CONNECTION + } + + /** + * @enum SortOrder + * @brief Enumeration of sorting options for station search results + */ + public enum SortOrder { + NAME, + URL, + HOMEPAGE, + FAVICON, + TAGS, + COUNTRY, + STATE, + LANGUAGE, + VOTES, + CODEC, + BITRATE, + LASTCHECKOK, + LASTCHECKTIME, + CLICKTIMESTAMP, + CLICKCOUNT, + CLICKTREND, + RANDOM; + + /** + * @brief Convert SortOrder enum to string representation + * @return String representation of the SortOrder + */ + public string to_string () { + switch (this) { + case NAME: + return "name"; + case URL: + return "url"; + case HOMEPAGE: + return "homepage"; + case FAVICON: + return "favicon"; + case TAGS: + return "tags"; + case COUNTRY: + return "country"; + case STATE: + return "state"; + case LANGUAGE: + return "language"; + case VOTES: + return "votes"; + case CODEC: + return "codec"; + case BITRATE: + return "bitrate"; + case LASTCHECKOK: + return "lastcheckok"; + case LASTCHECKTIME: + return "lastchecktime"; + case CLICKTIMESTAMP: + return "clicktimestamp"; + case CLICKCOUNT: + return "clickcount"; + case CLICKTREND: + return "clicktrend"; + case RANDOM: + return "random"; + default: + assert_not_reached (); + } + } + } + + /** + * @class Tag + * @brief Represents a tag associated with radio stations + */ + public class Tag : Object { + /** @brief Name of the tag */ + public string name { get; set; } + /** @brief Number of stations associated with this tag */ + public uint stationcount { get; set; } + } + + /** + * @brief Compare two strings for equality + * @param a First string to compare + * @param b Second string to compare + * @return True if strings are equal, false otherwise + */ + public bool EqualCompareString (string a, string b) { + return a == b; + } + + /** + * @class Client + * @brief RadioBrowser API Client + * + * This class provides methods to interact with the RadioBrowser API, including + * searching for stations, retrieving station information, and managing user actions. + */ + public class Client : Object { + private string current_server; + + /** + * @brief Constructor for RadioBrowser Client + * @throw DataError if unable to initialize the client + */ + public Client() throws DataError { + Object(); + + ArrayList servers; + string _servers = GLib.Environment.get_variable ("TUNER_API"); + if ( _servers != null ){ + servers = new Gee.ArrayList.wrap(_servers.split(":")); + } else { + //servers = DEFAULT_STATION_SERVERS; + servers = get_api_servers(); + } + + if ( servers.size == 0 ) { + throw new DataError.NO_CONNECTION ("Unable to resolve API servers for radio-browser.info"); + } + + var chosen_server = Random.int_range(0, servers.size); + + current_server = @"https://$(servers[chosen_server])"; + debug (@"RadioBrowser Client - Chosen radio-browser.info server: $current_server"); + } + + /** + * @brief Track a station listen event + * @param stationuuid UUID of the station being listened to + */ + public void track (string stationuuid) { + debug (@"sending listening event for station $stationuuid"); + uint status_code; + HttpClient.GET (@"$current_server/json/url/$stationuuid", out status_code); + debug (@"response: $(status_code)"); + } + + /** + * @brief Vote for a station + * @param stationuuid UUID of the station being voted for + */ + public void vote (string stationuuid) { + debug (@"sending vote event for station $stationuuid"); + uint status_code; + HttpClient.GET(@"$current_server/json/vote/$stationuuid", out status_code); + debug (@"response: $(status_code)"); + } + + /** + * @brief Get stations from a specific API resource + * @param resource API resource path + * @return ArrayList of Station objects + * @throw DataError if unable to retrieve or parse station data + */ + public ArrayList get_stations (string resource) throws DataError { + debug (@"RB $resource"); + + Json.Node rootnode; + + try { + uint status_code; + var response = HttpClient.GET(@"$current_server/$resource", out status_code); + + debug (@"Response from 'radio-browser.info': $(status_code)"); + + try { + var parser = new Json.Parser(); + parser.load_from_stream (response, null); + rootnode = parser.get_root(); + } catch (Error e) { + throw new DataError.PARSE_DATA (@"Unable to parse JSON response: $(e.message)"); + } + var rootarray = rootnode.get_array (); + + var stations = jarray_to_stations (rootarray); + return stations; + } catch (GLib.Error e) { + warning (@"Unknown error: $(e.message)"); + } + + return new ArrayList(); + } + + /** + * @brief Search for stations based on given parameters + * @param params Search parameters + * @param rowcount Maximum number of results to return + * @param offset Offset for pagination + * @return ArrayList of Station objects matching the search criteria + * @throw DataError if unable to retrieve or parse station data + */ + public ArrayList search (SearchParams params, + uint rowcount, + uint offset = 0) throws DataError { + // by uuids + if (params.uuids != null) { + var stations = new ArrayList (); + foreach (var uuid in params.uuids) { + var station = this.by_uuid(uuid); + if (station != null) { + stations.add (station); + } + } + return stations; + } + + // by text or tags + var resource = @"json/stations/search?limit=$rowcount&order=$(params.order)&offset=$offset"; + if (params.text != null && params.text != "") { + resource += @"&name=$(params.text)"; + } + if (params.tags == null) { + warning ("param tags is null"); + } + if (params.tags.size > 0 ) { + string tag_list = params.tags[0]; + if (params.tags.size > 1) { + tag_list = string.joinv (",", params.tags.to_array()); + } + resource += @"&tagList=$tag_list&tagExact=true"; + } + if (params.countrycode.length > 0) { + resource += @"&countrycode=$(params.countrycode)"; + } + if (params.order != SortOrder.RANDOM) { + // random and reverse doesn't make sense + resource += @"&reverse=$(params.reverse)"; + } + return get_stations (resource); + } + + /** + * @brief Get a station by its UUID + * @param uuid UUID of the station to retrieve + * @return Station object if found, null otherwise + * @throw DataError if unable to retrieve or parse station data + */ + public Station? by_uuid (string uuid) throws DataError { + var resource = @"json/stations/byuuid/$uuid"; + var result = get_stations (resource); + if (result.size == 0) { + return null; + } + return result[0]; + } + + /** + * @brief Get all available tags + * @return ArrayList of Tag objects + * @throw DataError if unable to retrieve or parse tag data + */ + public ArrayList get_tags () throws DataError { + + Json.Node rootnode; + + try { + uint status_code; + var stream = HttpClient.GET(@"$current_server/json/tags", out status_code); + + debug (@"response from radio-browser.info: $(status_code)"); + + try { + var parser = new Json.Parser(); + parser.load_from_stream (stream); + rootnode = parser.get_root (); + } catch (Error e) { + throw new DataError.PARSE_DATA (@"unable to parse JSON response: $(e.message)"); + } + var rootarray = rootnode.get_array (); + + var tags = jarray_to_tags (rootarray); + return tags; + } catch(GLib.Error e) { + debug("cannot get_tags()"); + } + + return new ArrayList(); + } + + /** + */ + private Station jnode_to_station (Json.Node node) { + return Json.gobject_deserialize (typeof (Station), node) as Station; + } + + /** + */ + private ArrayList jarray_to_stations (Json.Array data) { + var stations = new ArrayList (); + + data.foreach_element ((array, index, element) => { + Station s = jnode_to_station (element); + stations.add (s); + }); + + return stations; + } + + /** + */ + private Tag jnode_to_tag (Json.Node node) { + return Json.gobject_deserialize (typeof (Tag), node) as Tag; + } + + /** + */ + private ArrayList jarray_to_tags (Json.Array data) { + var tags = new ArrayList (); + + data.foreach_element ((array, index, element) => { + Tag s = jnode_to_tag (element); + tags.add (s); + }); + + return tags; + } + + /** + * @brief Get all radio-browser.info API servers + * + * Gets server list from + * + * @since 1.5.4 + * @return ArrayList of strings containing the resolved hostnames + * @throw DataError if unable to resolve DNS records + */ + private ArrayList get_api_servers() throws DataError { + + var results = new ArrayList(); + + try + /* + DNS SRV record lookup + */ + { + var srv_targets = GLib.Resolver.get_default(). + lookup_service( SRV_SERVICE, SRV_PROTOCOL, SRV_DOMAIN, null ); + foreach (var target in srv_targets) { + results.add(target.get_hostname()); + } + } catch (GLib.Error e) { + @warning(@"Unable to resolve SRV records: $(e.message)"); + } + + if (results.is_empty) + /* + JSON API server lookup as SRV record lookup failed + */ + { + + try { + uint status_code; + var stream = HttpClient.GET(@"$ALL_API/json/servers", out status_code); + + debug (@"response from $(ALL_API)/json/servers: $(status_code)"); + + if (status_code == 200) { + + Json.Node root_node; + + try { + var parser = new Json.Parser(); + parser.load_from_stream (stream); + root_node = parser.get_root (); + } catch (Error e) { + throw new DataError.PARSE_DATA (@"unable to parse JSON response: $(e.message)"); + } + + if (root_node != null && root_node.get_node_type() == Json.NodeType.ARRAY) { + + root_node.get_array().foreach_element((array, index_, element_node) => { + var object = element_node.get_object(); + if (object != null) { + var name = object.get_string_member("name"); + if (name != null && !results.contains (name)) { + results.add(name); + } + } + }); + + } + } + } catch (Error e) { + warning("Failed to parse API ServersJSON: $(e.message)"); + } + } + + return results; + } + } +} \ No newline at end of file diff --git a/src/Services/RadioBrowserDirectory.vala b/src/Services/RadioBrowserDirectory.vala deleted file mode 100644 index aeae0c9..0000000 --- a/src/Services/RadioBrowserDirectory.vala +++ /dev/null @@ -1,308 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-or-later - * SPDX-FileCopyrightText: 2020-2022 Louis Brauer - */ - -using Gee; - -namespace Tuner.RadioBrowser { - -public struct SearchParams { - string text; - ArrayList tags; - ArrayList uuids; - string countrycode; - SortOrder order; - bool reverse; -} - -public errordomain DataError { - PARSE_DATA, - NO_CONNECTION -} - -public enum SortOrder { - NAME, - URL, - HOMEPAGE, - FAVICON, - TAGS, - COUNTRY, - STATE, - LANGUAGE, - VOTES, - CODEC, - BITRATE, - LASTCHECKOK, - LASTCHECKTIME, - CLICKTIMESTAMP, - CLICKCOUNT, - CLICKTREND, - RANDOM; - - public string to_string () { - switch (this) { - case NAME: - return "name"; - case URL: - return "url"; - case HOMEPAGE: - return "homepage"; - case FAVICON: - return "favicon"; - case TAGS: - return "tags"; - case COUNTRY: - return "country"; - case STATE: - return "state"; - case LANGUAGE: - return "language"; - case VOTES: - return "votes"; - case CODEC: - return "codec"; - case BITRATE: - return "bitrate"; - case LASTCHECKOK: - return "lastcheckok"; - case LASTCHECKTIME: - return "lastchecktime"; - case CLICKTIMESTAMP: - return "clicktimestamp"; - case CLICKCOUNT: - return "clickcount"; - case CLICKTREND: - return "clicktrend"; - case RANDOM: - return "random"; - default: - assert_not_reached (); - } - } -} - -// TODO: Fetch list of servers via DNS -private const string[] DEFAULT_BOOTSTRAP_SERVERS = { - "de1.api.radio-browser.info", -}; - -public class Station : Object { - public string stationuuid { get; set; } - public string name { get; set; } - public string url_resolved { get; set; } - public string country { get; set; } - public string countrycode { get; set; } - public string favicon { get; set; } - public uint clickcount { get; set; } - public string homepage { get; set; } - public string codec { get; set; } - public int bitrate { get; set; } -} - -public class Tag : Object { - public string name { get; set; } - public uint stationcount { get; set; } -} - -public bool EqualCompareString (string a, string b) { - return a == b; -} - -public int RandomSortFunc (string a, string b) { - return Random.int_range (-1, 1); -} - -public class Client : Object { - private string current_server; - private string USER_AGENT = @"$(Application.APP_ID)/$(Application.APP_VERSION)"; - private Soup.Session _session; - private ArrayList randomized_servers; - - public Client() throws DataError { - Object(); - _session = new Soup.Session (); - _session.user_agent = USER_AGENT; - _session.timeout = 3; - - - string[] servers; - string _servers = GLib.Environment.get_variable ("TUNER_API"); - if ( _servers != null ){ - servers = _servers.split(":"); - } else { - servers = DEFAULT_BOOTSTRAP_SERVERS; - } - - randomized_servers = new ArrayList.wrap (servers, EqualCompareString); - randomized_servers.sort (RandomSortFunc); - - current_server = @"https://$(randomized_servers[0])"; - debug (@"Chosen radio-browser.info server: $current_server"); - // TODO: Implement server rotation on error - } - - private Station jnode_to_station (Json.Node node) { - return Json.gobject_deserialize (typeof (Station), node) as Station; - } - - private ArrayList jarray_to_stations (Json.Array data) { - var stations = new ArrayList (); - - data.foreach_element ((array, index, element) => { - Station s = jnode_to_station (element); - stations.add (s); - }); - - return stations; - } - - private Tag jnode_to_tag (Json.Node node) { - return Json.gobject_deserialize (typeof (Tag), node) as Tag; - } - - private ArrayList jarray_to_tags (Json.Array data) { - var tags = new ArrayList (); - - data.foreach_element ((array, index, element) => { - Tag s = jnode_to_tag (element); - tags.add (s); - }); - - return tags; - } - - public void track (string stationuuid) { - debug (@"sending listening event for station $stationuuid"); - var resource = @"json/url/$stationuuid"; - var message = new Soup.Message ("GET", @"$current_server/$resource"); - try { - var resp = _session.send (message); - resp.close (); - } catch(GLib.Error e) { - debug ("failed to track()"); - } - debug (@"response: $(message.status_code)"); - } - - public void vote (string stationuuid) { - debug (@"sending vote event for station $stationuuid"); - var resource = @"json/vote/$stationuuid)"; - var message = new Soup.Message ("GET", @"$current_server/$resource"); - try { - var resp = _session.send (message); - resp.close (); - } catch(GLib.Error e) { - debug("failed to vote()"); - } - debug (@"response: $(message.status_code)"); - } - - public ArrayList get_stations (string resource) throws DataError { - debug (@"RB $resource"); - - var message = new Soup.Message ("GET", @"$current_server/$resource"); - Json.Node rootnode; - - try { - var response = _session.send (message); - warning (@"response from radio-browser.info: $(message.status_code)"); - - try { - var parser = new Json.Parser(); - parser.load_from_stream (response, null); - rootnode = parser.get_root(); - response.close (); - } catch (Error e) { - throw new DataError.PARSE_DATA (@"unable to parse JSON response: $(e.message)"); - } - var rootarray = rootnode.get_array (); - - var stations = jarray_to_stations (rootarray); - return stations; - } catch (GLib.Error e) { - warning (@"response from radio-browser.info: $(e.message)"); - } - - return new ArrayList(); - } - - public ArrayList search (SearchParams params, - uint rowcount, - uint offset = 0) throws DataError { - // by uuids - if (params.uuids != null) { - var stations = new ArrayList (); - foreach (var uuid in params.uuids) { - var station = this.by_uuid(uuid); - if (station != null) { - stations.add (station); - } - } - return stations; - } - - // by text or tags - var resource = @"json/stations/search?limit=$rowcount&order=$(params.order)&offset=$offset"; - if (params.text != null && params.text != "") { - resource += @"&name=$(params.text)"; - } - if (params.tags == null) { - warning ("param tags is null"); - } - if (params.tags.size > 0 ) { - string tag_list = params.tags[0]; - if (params.tags.size > 1) { - tag_list = string.joinv (",", params.tags.to_array()); - } - resource += @"&tagList=$tag_list&tagExact=true"; - } - if (params.countrycode.length > 0) { - resource += @"&countrycode=$(params.countrycode)"; - } - if (params.order != SortOrder.RANDOM) { - // random and reverse doesn't make sense - resource += @"&reverse=$(params.reverse)"; - } - return get_stations (resource); - } - - public Station? by_uuid (string uuid) throws DataError { - var resource = @"json/stations/byuuid/$uuid"; - var result = get_stations (resource); - if (result.size == 0) { - return null; - } - return result[0]; - } - - public ArrayList get_tags () throws DataError { - var resource = @"json/tags"; - var message = new Soup.Message ("GET", @"$current_server/$resource"); - Json.Node rootnode; - - try { - var ip = _session.send (message); - debug (@"response from radio-browser.info: $(message.status_code)"); - - - try { - var parser = new Json.Parser(); - parser.load_from_stream (ip, null); - rootnode = parser.get_root (); - } catch (Error e) { - throw new DataError.PARSE_DATA (@"unable to parse JSON response: $(e.message)"); - } - var rootarray = rootnode.get_array (); - - var tags = jarray_to_tags (rootarray); - return tags; - } catch(GLib.Error e) { - debug("cannot get_tags()"); - } - - return new ArrayList(); - } - -} -} diff --git a/src/Widgets/HeaderBar.vala b/src/Widgets/HeaderBar.vala index 3bd95d4..140e4ba 100644 --- a/src/Widgets/HeaderBar.vala +++ b/src/Widgets/HeaderBar.vala @@ -3,9 +3,25 @@ * SPDX-FileCopyrightText: 2020-2022 Louis Brauer */ +/** + * @class Tuner.HeaderBar + * @brief Custom header bar for the Tuner application. + * + * This class extends Gtk.HeaderBar to create a specialized header bar + * with play/pause controls, volume control, station information display, + * search functionality, and preferences menu. + * + * @extends Gtk.HeaderBar + */ public class Tuner.HeaderBar : Gtk.HeaderBar { + // Default icon name for stations without a custom favicon private const string DEFAULT_ICON_NAME = "internet-radio-symbolic"; + + /** + * @enum PlayState + * @brief Enumeration of possible play states for the play button. + */ public enum PlayState { PAUSE_ACTIVE, PAUSE_INACTIVE, @@ -13,11 +29,11 @@ public class Tuner.HeaderBar : Gtk.HeaderBar { PLAY_INACTIVE } + // Public properties public Gtk.Button play_button { get; set; } - - public Gtk.VolumeButton volume_button; + // Private member variables private Gtk.Button star_button; private bool _starred = false; private Model.Station _station; @@ -25,20 +41,34 @@ public class Tuner.HeaderBar : Gtk.HeaderBar { private RevealLabel _subtitle_label; private Gtk.Image _favicon_image; + // Signals public signal void star_clicked (bool starred); public signal void searched_for (string text); public signal void search_focused (); + // Search-related variables private int search_delay = 250; // search delay in milliseconds (ms) private uint delayed_changed_id; private string searchentry_text = ""; + /** + * @brief Reset the search timeout. + * + * This method removes any existing timeout and sets a new one for delayed search. + */ private void reset_timeout(){ if(delayed_changed_id > 0) Source.remove(delayed_changed_id); delayed_changed_id = Timeout.add(search_delay, timeout); } + /** + * @brief Timeout function for delayed search. + * + * This method is called when the search delay timeout expires. + * + * @return bool Returns false to stop the timeout. + */ private bool timeout(){ // perform search searched_for (searchentry_text); @@ -46,9 +76,17 @@ public class Tuner.HeaderBar : Gtk.HeaderBar { return false; } + /** + * @brief Construct block for initializing the header bar components. + * + * This method sets up all the UI elements of the header bar, including + * station info display, play button, preferences button, search entry, + * star button, and volume button. + */ construct { show_close_button = true; + // Create and configure station info display var station_info = new Gtk.Grid (); station_info.width_request = 200; station_info.column_spacing = 10; @@ -64,11 +102,14 @@ public class Tuner.HeaderBar : Gtk.HeaderBar { station_info.attach (_subtitle_label, 1, 1, 1, 1); custom_title = station_info; + + // Create and configure play button play_button = new Gtk.Button (); play_button.valign = Gtk.Align.CENTER; play_button.action_name = Window.ACTION_PREFIX + Window.ACTION_PAUSE; pack_start (play_button); + // Create and configure preferences button var prefs_button = new Gtk.MenuButton (); prefs_button.image = new Gtk.Image.from_icon_name ("open-menu", Gtk.IconSize.LARGE_TOOLBAR); prefs_button.valign = Gtk.Align.CENTER; @@ -77,6 +118,7 @@ public class Tuner.HeaderBar : Gtk.HeaderBar { prefs_button.popover = new Tuner.PreferencesPopover();; pack_end (prefs_button); + // Create and configure search entry var searchentry = new Gtk.SearchEntry (); searchentry.valign = Gtk.Align.CENTER; searchentry.placeholder_text = _("Station name"); @@ -90,6 +132,7 @@ public class Tuner.HeaderBar : Gtk.HeaderBar { }); pack_end (searchentry); + // Create and configure star button star_button = new Gtk.Button.from_icon_name ( "non-starred", Gtk.IconSize.LARGE_TOOLBAR @@ -100,119 +143,84 @@ public class Tuner.HeaderBar : Gtk.HeaderBar { star_button.clicked.connect (() => { star_clicked (starred); }); - pack_start (star_button); + // Create and configure volume button volume_button = new Gtk.VolumeButton (); volume_button.value = Application.instance.settings.get_double ("volume"); volume_button.value_changed.connect ((value) => { Application.instance.settings.set_double ("volume", value); }); pack_start (volume_button); - set_playstate (PlayState.PAUSE_INACTIVE); } + // Properties for title and subtitle public new string title { - get { - return _title_label.label; - } - set { - _title_label.label = value; - } + get { return _title_label.label; } + set { _title_label.label = value; } } public new string subtitle { - get { - return _subtitle_label.label; - } - set { - _subtitle_label.label = value; - } + get { return _subtitle_label.label; } + set { _subtitle_label.label = value; } } public Gtk.Image favicon { - get { - return _favicon_image; - } - set { - _favicon_image = value; - } + get { return _favicon_image; } + set { _favicon_image = value; } } + /** + * @brief Handle changes in the current station. + * + * This method updates the starred state when the current station changes. + */ public void handle_station_change () { starred = _station.starred; } + /** + * @brief Update the header bar with information from a new station. + * + * @param station The new station to display information for. + */ public void update_from_station (Model.Station station) { if (_station != null) { _station.notify.disconnect (handle_station_change); } + load_favicon (station); // Kick off first as its async and long running in comparison _station = station; _station.notify.connect ( (sender, property) => { handle_station_change (); }); title = station.title; subtitle = _("Playing"); - load_favicon (station.favicon_url); starred = station.starred; } + // Property for starred state private bool starred { - get { - return _starred; - } - + get { return _starred; } set { _starred = value; if (!_starred) { - star_button.image = new Gtk.Image.from_icon_name ("non-starred", Gtk.IconSize.LARGE_TOOLBAR); + star_button.image = new Gtk.Image.from_icon_name ("non-starred", Gtk.IconSize.LARGE_TOOLBAR); } else { - star_button.image = new Gtk.Image.from_icon_name ("starred", Gtk.IconSize.LARGE_TOOLBAR); + star_button.image = new Gtk.Image.from_icon_name ("starred", Gtk.IconSize.LARGE_TOOLBAR); } } } - private void load_favicon (string url) { - // Set default icon first, in case loading takes long or fails - favicon.set_from_icon_name (DEFAULT_ICON_NAME, Gtk.IconSize.DIALOG); - if (url.length == 0) { - return; - } - - var session = new Soup.Session (); - var message = new Soup.Message ("GET", url); - - session.send_async.begin (message, 0, null, (sess, res) => { - try { - GLib.InputStream resp = session.send_async.end (res); - - if (message.status_code != 200) { - warning (@"Unexpected status code: $(message.status_code), will not render $(url)"); - return; - } - - // var data_stream = new MemoryInputStream.from_data (mess.response_body.data); - Gdk.Pixbuf pxbuf; - - try { - pxbuf = new Gdk.Pixbuf.from_stream_at_scale (resp, 48, 48, true, null); - favicon.set_from_pixbuf (pxbuf); - favicon.set_size_request (48, 48); - } catch (Error e) { - warning ("Couldn't render favicon: %s (%s)", - url ?? "unknown url", - e.message); - } - - resp.close (); - } catch (GLib.Error e) { - warning("load_favicon failed: $(e.message)"); - } - }); - } + /** + * @brief Set the play state of the header bar. + * + * This method updates the play button icon and sensitivity based on the new play state. + * + * @param state The new play state to set. + */ public void set_playstate (PlayState state) { switch (state) { case PlayState.PLAY_ACTIVE: @@ -250,4 +258,39 @@ public class Tuner.HeaderBar : Gtk.HeaderBar { } } + /** + * @brief Load and display the favicon for a station. + * + * This method asynchronously loads the favicon anew for + * the given station and updates the favicon image. + * If the favicon is not available, it will load cached + * favicon or use the default icon. + * + * @param station The station whose favicon should be loaded. + */ + private void load_favicon(Model.Station station) + { + this.favicon.clear (); + + Favicon.load_async.begin (station, true, (favicon, res) => { + var pxbuf = Favicon.load_async.end (res); + if (pxbuf != null) { + this.favicon.set_from_pixbuf (pxbuf); + this.favicon.set_size_request (48, 48); + return; + } + }); + + // If favicon is not available, use cached favicon or default icon + Favicon.load_async.begin (station, false, (favicon, res) => { + var pxbuf = Favicon.load_async.end (res); + if (pxbuf != null) { + this.favicon.set_from_pixbuf (pxbuf); + } else { + // If favicon is not available, use default icon + this.favicon.set_from_icon_name (DEFAULT_ICON_NAME, Gtk.IconSize.DIALOG); + } + this.favicon.set_size_request (48, 48); + }); + } } diff --git a/src/Widgets/StationBox.vala b/src/Widgets/StationBox.vala index a21bed4..f21ba78 100644 --- a/src/Widgets/StationBox.vala +++ b/src/Widgets/StationBox.vala @@ -3,31 +3,54 @@ * SPDX-FileCopyrightText: 2020-2022 Louis Brauer */ +/** + * @class StationBox + * @brief A custom button widget representing a radio station. + * + * The StationBox class extends the WelcomeButton class to create a specialized + * button for displaying radio station information. It includes the station's + * title, location, codec, bitrate, and favicon. + * + * @extends Tuner.WelcomeButton + */ public class Tuner.StationBox : Tuner.WelcomeButton { + // Default icon name for stations without a custom favicon + private const string DEFAULT_ICON_NAME = "internet-radio"; + + // Public properties for the station and its context menu public Model.Station station { get; construct; } public StationContextMenu menu { get; private set; } + /** + * Constructor for the StationBox + * @param station The radio station to represent + */ public StationBox (Model.Station station) { Object ( description: make_description (station.location), title: make_title (station.title, station.starred), tag: make_tag (station.codec, station.bitrate), - icon: new Gtk.Image(), + favicon: new Gtk.Image.from_icon_name (DEFAULT_ICON_NAME, Gtk.IconSize.DIALOG), station: station ); } + /** + * Construct block for additional initialization + */ construct { + debug (@"StationBox construct $(station.title)"); + + load_favicon(); + get_style_context().add_class("station-button"); + always_show_image = true; this.station.notify["starred"].connect ( (sender, prop) => { this.title = make_title (this.station.title, this.station.starred); }); - // TODO Use a AsyncQueue with limited threads - new Thread("station-box", realize_favicon); - event.connect ((e) => { if (e.type == Gdk.EventType.BUTTON_PRESS && e.button.button == 3) { @@ -45,7 +68,6 @@ public class Tuner.StationBox : Tuner.WelcomeButton { } return false; }); - always_show_image = true; } private static string make_title (string title, bool starred) { @@ -70,93 +92,15 @@ public class Tuner.StationBox : Tuner.WelcomeButton { return location; } - private int realize_favicon () { - // TODO: REFACTOR in separate class - var favicon_cache_file = Path.build_filename (Application.instance.cache_dir, station.id); - if (FileUtils.test (favicon_cache_file, FileTest.EXISTS | FileTest.IS_REGULAR)) { - var file = File.new_for_path (favicon_cache_file); - try { - var favicon_stream = file.read (); - if (!set_favicon_from_stream (favicon_stream)) { - set_default_favicon (); - }; - favicon_stream.close (); - return 0; - } catch (Error e) { - warning (@"unable to read local favicon: %s %s", favicon_cache_file, e.message); + private void load_favicon() + { + Favicon.load_async.begin (station, false, (favicon, res) => { + var pxbuf = Favicon.load_async.end (res); + if (pxbuf != null) { + this.favicon.set_from_pixbuf (pxbuf); + this.favicon.set_size_request (48, 48); } - } else { - // debug (@"favicon cache file doesn't exist: %s", favicon_cache_file); - } - - // in Vala nullable strings are always empty - if (station.favicon_url != "") { - var session = new Soup.Session (); - var message = new Soup.Message ("GET", station.favicon_url); - - session.send_async.begin (message, 0, null, (sess, res) => { - try { - GLib.InputStream data_stream = session.send_async.end (res); - - //set_favicon_from_stream (data_stream); - - var file = File.new_for_path (favicon_cache_file); - try { - var stream = file.create_readwrite (FileCreateFlags.PRIVATE); - stream.output_stream.splice (data_stream, 0); - stream.close (); - } catch (Error e) { - // File already created by another stationbox - // TODO: possible race condition - // TODO: Create stationboxes as singletons? - } - - try { - var favicon_stream = file.read (); - if (!set_favicon_from_stream (favicon_stream)) { - set_default_favicon (); - }; - } catch (Error e) { - warning (@"Error while reading icon file stream: $(e.message)"); - } - } catch (GLib.Error e) { - critical (@"unable to load favicon: $(e.message)"); - return; - } - - if (message.status_code != 200) { - //debug (@"Unexpected status code: $(mess.status_code), will not render $(station.favicon_url)"); - set_default_favicon (); - return; - } - }); - - } else { - set_default_favicon (); - } - - Thread.exit (0); - return 0; - } - - private bool set_favicon_from_stream (InputStream stream) { - Gdk.Pixbuf pxbuf; - - try { - pxbuf = new Gdk.Pixbuf.from_stream_at_scale (stream, 48, 48, true, null); - this.icon.set_from_pixbuf (pxbuf); - this.icon.set_size_request (48, 48); - return true; - } catch (Error e) { - //debug ("Couldn't render favicon: %s (%s)", - // station.favicon_url ?? "unknown url", - // e.message); - return false; - } - } - - private void set_default_favicon () { - this.icon.set_from_icon_name ("internet-radio", Gtk.IconSize.DIALOG); + }); } } diff --git a/src/Widgets/WelcomeButton.vala b/src/Widgets/WelcomeButton.vala index 8450ceb..315b3c6 100644 --- a/src/Widgets/WelcomeButton.vala +++ b/src/Widgets/WelcomeButton.vala @@ -8,7 +8,7 @@ Gtk.Label button_title; Gtk.Label button_tag; Gtk.Label button_description; - Gtk.Image? _icon; + Gtk.Image? _favicon_image; Gtk.Grid button_grid; public string title { @@ -32,21 +32,21 @@ } } - public Gtk.Image? icon { - get { return _icon; } + public Gtk.Image? favicon { + get { return _favicon_image; } set { - if (_icon != null) { - _icon.destroy (); + if (_favicon_image != null) { + _favicon_image.destroy (); } - _icon = value; - if (_icon != null) { - _icon.set_pixel_size (48); - _icon.halign = Gtk.Align.CENTER; - _icon.valign = Gtk.Align.CENTER; - button_grid.attach (_icon, 0, 0, 1, 2); + _favicon_image = value; + if (_favicon_image != null) { + _favicon_image.set_pixel_size (48); + _favicon_image.halign = Gtk.Align.CENTER; + _favicon_image.valign = Gtk.Align.CENTER; + button_grid.attach (_favicon_image, 0, 0, 1, 2); } } - } + } /* public WelcomeButton (Gtk.Image? image, string title, string description) { diff --git a/src/Widgets/Window.vala b/src/Widgets/Window.vala index ba21b5d..40ac5d9 100644 --- a/src/Widgets/Window.vala +++ b/src/Widgets/Window.vala @@ -3,8 +3,12 @@ * SPDX-FileCopyrightText: 2020-2022 Louis Brauer */ + using Gee; +/** + Window +*/ public class Tuner.Window : Gtk.ApplicationWindow { public GLib.Settings settings { get; construct; } @@ -15,7 +19,7 @@ public class Tuner.Window : Gtk.ApplicationWindow { private HeaderBar headerbar; private Granite.Widgets.SourceList source_list; - public const string WindowName = "Tuner"; + public const string WINDOW_NAME = "Tuner"; public const string ACTION_PREFIX = "win."; public const string ACTION_PAUSE = "action_pause"; public const string ACTION_QUIT = "action_quit"; @@ -62,7 +66,7 @@ public class Tuner.Window : Gtk.ApplicationWindow { headerbar = new HeaderBar (); set_titlebar (headerbar); - set_title (WindowName); + set_title (WINDOW_NAME); player.state_changed.connect (handleplayer_state_changed); player.station_changed.connect (headerbar.update_from_station); @@ -109,8 +113,8 @@ public class Tuner.Window : Gtk.ApplicationWindow { var stack = new Gtk.Stack (); stack.transition_type = Gtk.StackTransitionType.CROSSFADE; - var data_file = Path.build_filename (Application.instance.data_dir, "favorites.json"); - var store = new Model.StationStore (data_file); + var favorites_file = Path.build_filename (Application.instance.data_dir, "favorites.json"); + var store = new Model.StationStore (favorites_file); _directory = new DirectoryController (store); var primary_box = new Gtk.Paned (Gtk.Orientation.HORIZONTAL); @@ -407,7 +411,7 @@ public class Tuner.Window : Gtk.ApplicationWindow { private static void adjust_theme() { var theme = Application.instance.settings.get_string("theme-mode"); - warning(@"current theme: $theme"); + info(@"current theme: $theme"); var gtk_settings = Gtk.Settings.get_default (); var granite_settings = Granite.Settings.get_default (); @@ -435,7 +439,7 @@ public class Tuner.Window : Gtk.ApplicationWindow { warning (@"storing last played station: $(station.id)"); settings.set_string("last-played-station", station.id); - set_title (WindowName+": "+station.title); + set_title (WINDOW_NAME+": "+station.title); } public void handle_favourites_changed () { diff --git a/src/meson.build b/src/meson.build index c5a5e5e..c1cf700 100644 --- a/src/meson.build +++ b/src/meson.build @@ -10,8 +10,10 @@ sources = files ( 'Services/DBusMediaPlayer.vala', 'Services/DBusInterface.vala', - 'Services/RadioBrowserDirectory.vala', + 'Services/RadioBrowser.vala', # 'Services/LocationDiscovery.vala', + 'Services/HttpClient.vala', + 'Services/Favicon.vala', 'Widgets/AbstractContentList.vala', 'Widgets/ContentBox.vala', diff --git a/tuner.mm b/tuner.mm new file mode 100644 index 0000000..7de7a43 --- /dev/null +++ b/tuner.mm @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +