diff --git a/src/Application.vala b/src/Application.vala index ac7417e..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; } @@ -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/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/Services/Favicon.vala b/src/Services/Favicon.vala new file mode 100644 index 0000000..dbae786 --- /dev/null +++ b/src/Services/Favicon.vala @@ -0,0 +1,74 @@ +/* + * 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 { + + // private static Image INTERNET_RADIO = Image().set_from_icon_name ("internet-radio", Gtk.IconSize.DIALOG); + //private static Image INTERNET_RADIO_SYMBOLIC = new Image.from_icon_name ("internet-radio-symbolic", Gtk.IconSize.DIALOG); + /** + * @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. + * + * @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", 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..1c60cb5 --- /dev/null +++ b/src/Services/HttpClient.vala @@ -0,0 +1,117 @@ +/* + * 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 = 3; + } + return _session; + } + + /** + * @brief Perform a GET request to the specified URL + * + * This method sends a GET request to the specified URL using the singleton + * Soup.Session instance. It returns the response body as an InputStream and + * outputs the status code of the response. + * + * @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 + * @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; + var msg = new Soup.Message("GET", url_string); + try { + + if (Uri.is_valid(url_string, NONE)) + { + var inputStream = getSession().send(msg); + status_code = msg.status_code; + return inputStream; + } + } catch (Error e) { + warning ("GET - Couldn't render favicon: %s (%s)", + url_string ?? "unknown url", + e.message); + } + + return null; + } + + /** + * @brief Perform an asynchronous GET request to the specified URL + * + * This method sends an asynchronous GET request to the specified URL using the singleton + * Soup.Session instance. It returns the response body as an InputStream and + * outputs the status code of the response. + * + * @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; + var msg = new Soup.Message("GET", url_string); + try { + if (Uri.is_valid(url_string, NONE)) + { + var inputStream = yield getSession().send_async(msg, Priority.DEFAULT, null); + status_code = msg.status_code; + return inputStream; + } + } catch (Error e) { + warning ("GETasync - Couldn't render favicon: %s (%s)", + url_string ?? "unknown url", + e.message); + } + + return null; + } +} \ No newline at end of file diff --git a/src/Services/RadioBrowser.vala b/src/Services/RadioBrowser.vala new file mode 100644 index 0000000..05ca141 --- /dev/null +++ b/src/Services/RadioBrowser.vala @@ -0,0 +1,413 @@ +/* + * 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 + */ +namespace Tuner.RadioBrowser { + + /** + * @class Station + * @brief Station data subset returned from radio-browser API + */ + 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; } + } + + /** + * @struct SearchParams + * @brief Parameters for searching radio stations + */ + public struct SearchParams { + string text; + ArrayList tags; + ArrayList uuids; + string countrycode; + SortOrder 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 (); + } + } + } + + + // TODO: Fetch list of servers via DNS query of SRV record for _api._tcp.radio-browser.info + private const string[] DEFAULT_STATION_SERVERS = { + "de1.api.radio-browser.info", + }; + + + + /** + * @class Tag + * @brief Represents a tag associated with radio stations + */ + public class Tag : Object { + public string name { get; set; } + 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; + } + + /** + * @brief Random sort function for strings + * @param a First string to compare + * @param b Second string to compare + * @return Random integer between -1 and 1 + */ + public int RandomSortFunc (string a, string b) { + return Random.int_range (-1, 1); + } + + /** + * @class Client + * @brief RadioBrowser API Client + */ + public class Client : Object { + private string current_server; + private ArrayList randomized_servers; + + + ~Client() { + debug ("RadioBrowser Client - Destruct"); + } + + + /** + * @brief Constructor for RadioBrowser Client + * @throw DataError if unable to initialize the client + */ + public Client() throws DataError { + Object(); + + string[] servers; + string _servers = GLib.Environment.get_variable ("TUNER_API"); + if ( _servers != null ){ + servers = _servers.split(":"); + } else { + servers = DEFAULT_STATION_SERVERS; + } + + randomized_servers = new ArrayList.wrap (servers, EqualCompareString); + randomized_servers.sort (RandomSortFunc); + + current_server = @"https://$(randomized_servers[0])"; + debug (@"RadioBrowser Client - Chosen radio-browser.info server: $current_server"); + // TODO: Implement server rotation on error + } + + + /** + * @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; + //var resource = @"json/url/$stationuuid"; + //var message = new Soup.Message ("GET", @"$current_server/$resource"); + try { + //var resp = _session.send (message); + HttpClient.GET (@"$current_server/json/url/$stationuuid", out status_code); + // resp.close (); + } catch(GLib.Error e) { + debug ("failed to track()"); + } + 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; + + try { + HttpClient.GET(@"$current_server/json/vote/$stationuuid", out status_code); + + } catch(GLib.Error e) { + debug("failed to vote()"); + } + 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; + } + + } +} \ 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..a2d2fe0 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,51 +143,49 @@ 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); @@ -155,64 +196,31 @@ public class Tuner.HeaderBar : Gtk.HeaderBar { }); title = station.title; subtitle = _("Playing"); - load_favicon (station.favicon_url); + load_favicon (station); 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,23 @@ public class Tuner.HeaderBar : Gtk.HeaderBar { } } -} + /** + * @brief Load and display the favicon for a station. + * + * This method asynchronously loads the favicon for the given station and updates the favicon image. + * + * @param station The station whose favicon should be loaded. + */ + private void load_favicon(Model.Station station) + { + 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 { + this.favicon.set_from_icon_name (DEFAULT_ICON_NAME, Gtk.IconSize.DIALOG); + } + this.favicon.set_size_request (48, 48); + }); + } +} \ No newline at end of file diff --git a/src/Widgets/StationBox.vala b/src/Widgets/StationBox.vala index a21bed4..56e0a36 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 { + warning (@"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 6c17664..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); @@ -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',