diff --git a/Cargo.lock b/Cargo.lock index f32f074..62c9c51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1412,7 +1412,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" dependencies = [ "log", - "phf", + "phf 0.10.1", "phf_codegen", "string_cache", "string_cache_codegen", @@ -1723,7 +1723,17 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ - "phf_shared", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros", + "phf_shared 0.11.2", ] [[package]] @@ -1732,8 +1742,8 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.10.0", + "phf_shared 0.10.0", ] [[package]] @@ -1742,10 +1752,33 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ - "phf_shared", + "phf_shared 0.10.0", "rand", ] +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.55", +] + [[package]] name = "phf_shared" version = "0.10.0" @@ -1755,6 +1788,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.5" @@ -2248,6 +2290,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + [[package]] name = "socket2" version = "0.5.6" @@ -2273,7 +2326,7 @@ dependencies = [ "new_debug_unreachable", "once_cell", "parking_lot", - "phf_shared", + "phf_shared 0.10.0", "precomputed-hash", "serde", ] @@ -2284,8 +2337,8 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.10.0", + "phf_shared 0.10.0", "proc-macro2", "quote", ] @@ -2369,6 +2422,7 @@ name = "televido" version = "0.3.0" dependencies = [ "eyre", + "futures-util", "gettext-rs", "gsettings-macro", "gtk4", @@ -2378,9 +2432,11 @@ dependencies = [ "libadwaita", "mediathekviewweb", "once_cell", + "phf 0.11.2", "reqwest", "serde", "serde_json", + "smart-default", "time", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index be311f4..18ef1ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ publish = false [dependencies] adw = { package = "libadwaita", version = "0.6.0", features = ["v1_5"] } eyre = "0.6.12" +futures-util = "0.3.30" gettext-rs = { version = "0.7", features = ["gettext-system"] } gsettings-macro = "0.2.0" gtk = { version = "0.8.1", package = "gtk4", features = ["gnome_46", "blueprint"] } @@ -15,9 +16,11 @@ html2pango = "0.6.0" indexmap = { version = "2.2.6", features = ["serde"] } mediathekviewweb = "0.3.0" once_cell = "1.19.0" +phf = { version = "0.11.2", features = ["macros"] } reqwest = { version = "0.12.2", features = ["json"] } serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.115" +smart-default = "0.7.1" time = { version = "0.3.34", features = ["parsing", "serde"] } tokio = { version = "1.36.0", features = ["time", "rt-multi-thread", "macros"] } tracing = "0.1.40" diff --git a/data/de.k_bo.Televido.gschema.xml b/data/de.k_bo.Televido.gschema.xml index 98fe531..0caec55 100644 --- a/data/de.k_bo.Televido.gschema.xml +++ b/data/de.k_bo.Televido.gschema.xml @@ -50,5 +50,8 @@ "high" + + [] + \ No newline at end of file diff --git a/po/POTFILES.in b/po/POTFILES.in index 8dc7023..281ebab 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -2,6 +2,7 @@ data/de.k_bo.Televido.desktop.in data/de.k_bo.Televido.gschema.xml data/de.k_bo.Televido.metainfo.xml.in src/application.rs +src/channel_icons.rs src/config.rs src/help-overlay.blp src/launcher/application_proxy.rs @@ -14,18 +15,23 @@ src/live/channels.rs src/live/mod.rs src/live/view.blp src/live/view.rs -src/live/zapp.rs src/main.rs src/mediathek/card.blp src/mediathek/card.rs -src/mediathek/channels.rs src/mediathek/mod.rs src/mediathek/shows.rs src/mediathek/view.blp src/mediathek/view.rs -src/preferences.blp -src/preferences.rs +src/preferences/dialog.blp +src/preferences/dialog.rs +src/preferences/live/mod.rs +src/preferences/live/selector.blp +src/preferences/live/selector_row.blp +src/preferences/live/selector_row.rs +src/preferences/live/selector.rs +src/preferences/mod.rs src/settings.rs src/utils.rs src/window.blp -src/window.rs \ No newline at end of file +src/window.rs +src/zapp.rs \ No newline at end of file diff --git a/po/televido.pot b/po/televido.pot index 74b0305..7c5318b 100644 --- a/po/televido.pot +++ b/po/televido.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: televido\n" "Report-Msgid-Bugs-To: https://github.com/d-k-bo/televido/issues\n" -"POT-Creation-Date: 2024-04-04 20:08+0200\n" +"POT-Creation-Date: 2024-04-06 13:15+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -86,11 +86,15 @@ msgstr "" msgid "Initital release" msgstr "" -#: src/application.rs:134 +#: src/application.rs:98 src/live/view.rs:126 +msgid "Failed to load livestream channels" +msgstr "" + +#: src/application.rs:183 msgid "Failed to play video stream" msgstr "" -#: src/application.rs:177 +#: src/application.rs:226 msgid "Failed to launch video downloader" msgstr "" @@ -114,15 +118,15 @@ msgstr "" msgid "Quit application" msgstr "" -#: src/launcher/selector.blp:19 +#: src/launcher/selector.blp:18 msgid "Cancel" msgstr "" -#: src/launcher/selector.blp:25 +#: src/launcher/selector.blp:24 msgid "Confirm" msgstr "" -#: src/launcher/selector.blp:41 +#: src/launcher/selector.blp:40 msgid "Custom program" msgstr "" @@ -135,7 +139,7 @@ msgstr "" msgid "Failed to load external applications" msgstr "" -#: src/launcher/selector.rs:173 src/utils.rs:183 +#: src/launcher/selector.rs:173 src/utils.rs:101 msgid "See the terminal output for details." msgstr "" @@ -167,14 +171,10 @@ msgid "Play" msgstr "" #. translators: `{}` is replaced by the channel_id, e.g. `das_erste` -#: src/live/view.rs:61 +#: src/live/view.rs:63 msgid "Failed load shows for channel “{}”" msgstr "" -#: src/live/view.rs:105 -msgid "Failed to load livestream channels" -msgstr "" - #: src/mediathek/card.blp:106 msgid "Copy video URL" msgstr "" @@ -199,7 +199,7 @@ msgstr "" msgid "Low Quality" msgstr "" -#: src/mediathek/card.rs:178 +#: src/mediathek/card.rs:177 msgid "Failed to open website in browser" msgstr "" @@ -279,34 +279,58 @@ msgstr "" msgid "Failed to query the MediathekViewWeb API" msgstr "" -#: src/preferences.blp:11 +#: src/preferences/dialog.blp:9 msgid "External Programs" msgstr "" -#: src/preferences.blp:15 +#: src/preferences/dialog.blp:13 msgid "Video Player" msgstr "" -#: src/preferences.blp:19 src/preferences.blp:30 +#: src/preferences/dialog.blp:17 src/preferences/dialog.blp:28 msgid "Change" msgstr "" -#: src/preferences.blp:26 +#: src/preferences/dialog.blp:24 msgid "Video Downloader" msgstr "" +#: src/preferences/dialog.blp:36 +msgid "Live Channels" +msgstr "" + +#: src/preferences/dialog.blp:40 src/preferences/live/selector.blp:7 +msgid "Select and reorder channels" +msgstr "" + +#: src/preferences/live/selector.blp:15 +msgid "Visible channels" +msgstr "" + +#: src/preferences/live/selector.blp:35 +msgid "Hidden channels" +msgstr "" + +#: src/preferences/live/selector.blp:51 +msgid "Sort alphabetically" +msgstr "" + +#: src/preferences/live/selector.blp:56 +msgid "Sort using default order" +msgstr "" + #. translators: %b is Month name (short) #. %-e is the Day number #. %Y is the year (with century) #. %H is the hours (24h format) #. %M is the minutes -#: src/utils.rs:55 +#: src/utils.rs:60 msgid "%b %-e, %Y %H:%M" msgstr "" #. translators: %H is the hours (24h format) #. %M is the minutes -#: src/utils.rs:65 +#: src/utils.rs:70 msgid "%H:%M" msgstr "" diff --git a/src/application.rs b/src/application.rs index da488fd..d7e7ed2 100644 --- a/src/application.rs +++ b/src/application.rs @@ -1,25 +1,36 @@ // Copyright 2023 David Cabot // SPDX-License-Identifier: GPL-3.0-or-later -use std::{cell::OnceCell, sync::OnceLock}; +use std::{ + cell::OnceCell, + rc::Rc, + sync::{Arc, OnceLock}, +}; use adw::{gio, glib, prelude::*, subclass::prelude::*}; +use eyre::WrapErr; use gettextrs::gettext; +use smart_default::SmartDefault; use crate::{ config::{APP_ID, APP_NAME, AUTHOR, ISSUE_URL, PROJECT_URL, VERSION}, launcher::{ExternalProgram, ExternalProgramType, ProgramSelector}, preferences::TvPreferencesDialog, settings::TvSettings, - utils::{show_error, spawn_clone, tokio}, + utils::{show_error, spawn, spawn_clone, tokio, AsyncResource}, window::TvWindow, + zapp::Zapp, }; mod imp { use super::*; - #[derive(Debug, Default)] - pub struct TvApplication {} + #[derive(Debug, SmartDefault)] + pub struct TvApplication { + #[default(Arc::new(Zapp::new().expect("failed to initialize Zapp client")))] + pub(super) zapp: Arc, + pub(super) live_channels: AsyncResource>, + } #[glib::object_subclass] impl ObjectSubclass for TvApplication { @@ -52,27 +63,57 @@ glib::wrapper! { @implements gio::ActionGroup, gio::ActionMap; } +thread_local! { + static APPLICATION: OnceCell = const { OnceCell::new() }; +} + impl TvApplication { + pub fn new() -> Self { + let slf: Self = glib::Object::builder() + .property("application-id", APP_ID) + .property("flags", gio::ApplicationFlags::FLAGS_NONE) + .property("resource-base-path", "/de/k_bo/televido") + .build(); + + APPLICATION.with(|app| { + app.set(slf.clone()) + .expect("TvApplication may only be created once") + }); + + let live_channels = slf.live_channels(); + live_channels.set_load_fn({ + let zapp = slf.zapp(); + move || { + let zapp = zapp.clone(); + Box::pin(async move { + match tokio(async move { + zapp.channel_info_list() + .await + .wrap_err("Failed to load channel info list") + }) + .await + { + Ok(channels) => Rc::new(channels), + Err(e) => { + show_error(e.wrap_err(gettext("Failed to load livestream channels"))); + Default::default() + } + } + }) + } + }); + spawn(async move { live_channels.load() }); + + slf + } pub fn get() -> Self { - thread_local! { - static APPLICATION: OnceCell = const { OnceCell::new() }; - } APPLICATION.with(|app| { - app.get_or_init(|| { - glib::Object::builder() - .property("application-id", APP_ID) - .property("flags", gio::ApplicationFlags::FLAGS_NONE) - .property("resource-base-path", "/de/k_bo/televido") - .build() - }) - .clone() + app.get() + .expect("TvApplication::get() may only be called from the main thread") + .clone() }) } - pub fn new() -> Self { - Self::get() - } - pub async fn dbus() -> zbus::Connection { static DBUS: OnceLock = OnceLock::new(); match DBUS.get() { @@ -96,6 +137,14 @@ impl TvApplication { }) } + pub fn zapp(&self) -> Arc { + self.imp().zapp.clone() + } + + pub fn live_channels(&self) -> AsyncResource> { + self.imp().live_channels.clone() + } + pub async fn play(&self, uri: String) { let settings = TvSettings::get(); let player_name = settings.video_player_name(); diff --git a/src/channel_icons.rs b/src/channel_icons.rs new file mode 100644 index 0000000..e26e93c --- /dev/null +++ b/src/channel_icons.rs @@ -0,0 +1,143 @@ +// Copyright 2024 David Cabot +// SPDX-License-Identifier: GPL-3.0-or-later + +use adw::{gdk, gdk::gdk_pixbuf, glib, prelude::*}; +use eyre::WrapErr; +use phf::phf_map; +use tracing::error; + +use crate::application::TvApplication; + +pub fn load_channel_icon(channel_id: Option<&str>, image: >k::Image, size: i32) { + let Some(icon_name) = channel_id.and_then(|id| ICON_NAMES.get(id)) else { + image.set_icon_name(Some("image-missing-symbolic")); + return; + }; + + let application = TvApplication::get(); + let style_manager = application.style_manager(); + let scale_factor = application.window().surface().unwrap().scale_factor(); + let size = scale_factor * size; + + set_icon(&style_manager, image, icon_name, size); + + style_manager.connect_dark_notify(glib::clone!(@weak image => move |style_manager| { + set_icon(style_manager, &image, icon_name, size) + })); + + fn set_icon(style_manager: &adw::StyleManager, image: >k::Image, icon_name: &str, size: i32) { + match load_icon( + icon_name, + size, + ColorScheme::for_style_manager(style_manager), + ) { + Ok(texture) => image.set_from_paintable(Some(&texture)), + Err(e) => { + error!("{e:?}"); + image.set_icon_name(Some("image-missing-symbolic")); + } + } + } + + fn load_icon( + icon_name: &str, + size: i32, + color_scheme: ColorScheme, + ) -> eyre::Result { + let resource = + format!("/de/k_bo/televido/icons/scalable/channels/{color_scheme}/{icon_name}"); + + // load image manually with given size to avoid blurriness caused by scaling after rasterization + gdk_pixbuf::Pixbuf::from_resource_at_scale(&resource, size, size, true) + .map(|pixbuf| gdk::Texture::for_pixbuf(&pixbuf)) + .wrap_err_with(|| format!("failed to load channel logo from {resource}")) + } +} + +static ICON_NAMES: phf::Map<&'static str, &'static str> = phf_map! { + // live + "ard_alpha" => "ard-alpha.svg", + "arte" => "arte.svg", + "br_nord" => "br.svg", + "br_sued" => "br.svg", + "das_erste" => "das-erste.svg", + "deutsche_welle" => "deutsche-welle.svg", + "deutsche_welle_plus" => "deutsche-welle.svg", + "dreisat" => "3sat.svg", + "hr" => "hr.svg", + "kika" => "kika.svg", + "mdr_sachsen" => "mdr.svg", + "mdr_sachsen_anhalt" => "mdr.svg", + "mdr_thueringen" => "mdr.svg", + "ndr_hh" => "ndr.svg", + "ndr_mv" => "ndr.svg", + "ndr_nds" => "ndr.svg", + "ndr_sh" => "ndr.svg", + "one" => "one.svg", + "parlamentsfernsehen_1" => "parlamentsfernsehen.svg", + "parlamentsfernsehen_2" => "parlamentsfernsehen.svg", + "phoenix" => "phoenix.svg", + "rb" => "rb.svg", + "rbb_berlin" => "rbb.svg", + "rbb_brandenburg" => "rbb.svg", + "sr" => "sr.svg", + "swr_bw" => "swr.svg", + "swr_rp" => "swr.svg", + "tagesschau24" => "tagesschau24.svg", + "wdr" => "wdr.svg", + "zdf" => "zdf.svg", + "zdf_info" => "zdf-info.svg", + "zdf_neo" => "zdf-neo.svg", + // mediathek + "3Sat" => "3sat.svg", + "ARD" => "ard.svg", + "ARTE.DE" => "arte.svg", + "ARTE.EN" => "arte.svg", + "ARTE.ES" => "arte.svg", + "ARTE.FR" => "arte.svg", + "ARTE.IT" => "arte.svg", + "ARTE.PL" => "arte.svg", + "BR" => "br.svg", + "DW" => "deutsche-welle.svg", + "Funk.net" => "funk.svg", + "HR" => "hr.svg", + "KiKA" => "kika.svg", + "MDR" => "mdr.svg", + "NDR" => "ndr.svg", + "ORF" => "orf.svg", + "PHOENIX" => "phoenix.svg", + "Radio Bremen TV" => "rb.svg", + "RBB" => "rbb.svg", + "rbtv" => "rb.svg", + "SR" => "sr.svg", + "SRF" => "srf.svg", + "SWR" => "swr.svg", + "WDR" => "wdr.svg", + "ZDF" => "zdf.svg", + "ZDF-tivi" => "zdf.svg", +}; + +#[derive(Clone, Copy, Debug)] +enum ColorScheme { + Light, + Dark, +} + +impl ColorScheme { + fn for_style_manager(style_manager: &adw::StyleManager) -> Self { + if style_manager.is_dark() { + Self::Dark + } else { + Self::Light + } + } +} + +impl std::fmt::Display for ColorScheme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Light => "light", + Self::Dark => "dark", + }) + } +} diff --git a/src/launcher/selector.blp b/src/launcher/selector.blp index 330dbc9..4e1269f 100644 --- a/src/launcher/selector.blp +++ b/src/launcher/selector.blp @@ -4,7 +4,6 @@ using Gtk 4.0; using Adw 1; template $ProgramSelector: Adw.Dialog { - follows-content-size: true; can-close: false; Gtk.Box { diff --git a/src/live/card.rs b/src/live/card.rs index 43175e4..fcb681a 100644 --- a/src/live/card.rs +++ b/src/live/card.rs @@ -8,9 +8,12 @@ use std::{ use adw::{glib, gtk, prelude::*, subclass::prelude::*}; -use crate::utils::{load_channel_icon, spawn, tokio}; +use crate::{ + channel_icons::load_channel_icon, + utils::{spawn, tokio}, +}; -use super::channels::{Channel, ChannelObject}; +use super::channels::ChannelObject; mod imp { use super::*; @@ -41,13 +44,11 @@ mod imp { impl TvLiveCard { fn set_icon(&self) { - let icon_name = self - .obj() - .channel() - .and_then(|c| c.id().parse::().ok()) - .map(|c| c.icon_name()); - - load_channel_icon(&self.icon, icon_name); + load_channel_icon( + self.obj().channel().map(|c| c.id()).as_deref(), + &self.icon, + 64, + ) } } diff --git a/src/live/channels.rs b/src/live/channels.rs index b7e711b..2637239 100644 --- a/src/live/channels.rs +++ b/src/live/channels.rs @@ -5,7 +5,7 @@ use std::cell::{Cell, RefCell}; use adw::{glib, prelude::*, subclass::prelude::*}; -use crate::utils::{channel_mapping, format_timestamp_time}; +use crate::utils::format_timestamp_time; mod imp { use super::*; @@ -108,73 +108,3 @@ impl ChannelObject { .build() } } - -channel_mapping! { - #[derive(Clone, Copy, Debug, PartialEq)] - pub enum Channel { - #[channel(name = "ard_alpha", icon = "ard-alpha.svg")] - ArdAlpha, - #[channel(name = "arte", icon = "arte.svg")] - Arte, - #[channel(name = "br_nord", icon = "br.svg")] - BrNord, - #[channel(name = "br_sued", icon = "br.svg")] - BrSüd, - #[channel(name = "das_erste", icon = "das-erste.svg")] - DasErste, - #[channel(name = "deutsche_welle", icon = "deutsche-welle.svg")] - DeutscheWelle, - #[channel(name = "deutsche_welle_plus", icon = "deutsche-welle.svg")] - DeutscheWellePlus, - #[channel(name = "dreisat", icon = "3sat.svg")] - DreiSat, - #[channel(name = "hr", icon = "hr.svg")] - Hr, - #[channel(name = "kika", icon = "kika.svg")] - KiKa, - #[channel(name = "mdr_sachsen", icon = "mdr.svg")] - MdrSachsen, - #[channel(name = "mdr_sachsen_anhalt", icon = "mdr.svg")] - MdrSachsenAnhalt, - #[channel(name = "mdr_thueringen", icon = "mdr.svg")] - MdrThüringen, - #[channel(name = "ndr_hh", icon = "ndr.svg")] - NdrHH, - #[channel(name = "ndr_mv", icon = "ndr.svg")] - NdrMV, - #[channel(name = "ndr_nds", icon = "ndr.svg")] - NdrNds, - #[channel(name = "ndr_sh", icon = "ndr.svg")] - NdrSH, - #[channel(name = "one", icon = "one.svg")] - One, - #[channel(name = "parlamentsfernsehen_1", icon = "parlamentsfernsehen.svg")] - Parlamentsfernsehen1, - #[channel(name = "parlamentsfernsehen_2", icon = "parlamentsfernsehen.svg")] - Parlamentsfernsehen2, - #[channel(name = "phoenix", icon = "phoenix.svg")] - Phoenix, - #[channel(name = "rb", icon = "rb.svg")] - Rb, - #[channel(name = "rbb_berlin", icon = "rbb.svg")] - RbbBerlin, - #[channel(name = "rbb_brandenburg", icon = "rbb.svg")] - RbbBrandenburg, - #[channel(name = "sr", icon = "sr.svg")] - Sr, - #[channel(name = "swr_bw", icon = "swr.svg")] - SwrBW, - #[channel(name = "swr_rp", icon = "swr.svg")] - SwrRP, - #[channel(name = "tagesschau24", icon = "tagesschau24.svg")] - Tagesschau24, - #[channel(name = "wdr", icon = "wdr.svg")] - Wdr, - #[channel(name = "zdf", icon = "zdf.svg")] - Zdf, - #[channel(name = "zdf_info", icon = "zdf-info.svg")] - ZdfInfo, - #[channel(name = "zdf_neo", icon = "zdf-neo.svg")] - ZdfNeo, - } -} diff --git a/src/live/mod.rs b/src/live/mod.rs index f451d4b..b7f4e6e 100644 --- a/src/live/mod.rs +++ b/src/live/mod.rs @@ -4,6 +4,5 @@ mod card; mod channels; mod view; -mod zapp; pub use self::view::TvLiveView; diff --git a/src/live/view.blp b/src/live/view.blp index 56af22c..f3aa45a 100644 --- a/src/live/view.blp +++ b/src/live/view.blp @@ -30,6 +30,7 @@ template $TvLiveView: Adw.Bin { margin-bottom: 6; margin-start: 6; margin-end: 6; + valign: start; styles [ "boxed-list" diff --git a/src/live/view.rs b/src/live/view.rs index acca844..e289294 100644 --- a/src/live/view.rs +++ b/src/live/view.rs @@ -1,24 +1,24 @@ // Copyright 2023 David Cabot // SPDX-License-Identifier: GPL-3.0-or-later -use std::{cell::OnceCell, sync::Arc}; - use adw::{gio, glib, gtk, prelude::*, subclass::prelude::*}; use eyre::WrapErr; use gettextrs::gettext; +use smart_default::SmartDefault; -use crate::utils::{show_error, spawn, tokio}; - -use super::{ - card::TvLiveCard, - channels::ChannelObject, - zapp::{ChannelId, ChannelInfo, Show, ShowsResult, Zapp}, +use crate::{ + application::TvApplication, + settings::TvSettings, + utils::{show_error, spawn, tokio}, + zapp::{ChannelId, ChannelInfo, Show, ShowsResult}, }; +use super::{card::TvLiveCard, channels::ChannelObject}; + mod imp { use super::*; - #[derive(Debug, Default, gtk::CompositeTemplate)] + #[derive(Debug, SmartDefault, gtk::CompositeTemplate)] #[template(file = "src/live/view.blp")] pub struct TvLiveView { #[template_child] @@ -27,35 +27,37 @@ mod imp { spinner: TemplateChild, #[template_child] channels_list: TemplateChild, - - client: OnceCell>, - channels_model: OnceCell, } impl TvLiveView { - fn client(&self) -> Arc { - self.client - .get_or_init(|| Arc::new(Zapp::new().expect("failed to initialize HTTP client"))) - .clone() - } - fn channels_model(&self) -> gio::ListStore { - self.channels_model - .get_or_init(gio::ListStore::new::) - .clone() - } - async fn load(&self) { - let client = self.client(); - let load_channels = tokio(async move { - let list = client - .channel_info_list() - .await - .wrap_err("Failed to load channel info list")?; + async fn load_channels(&self) -> eyre::Result { + let client = TvApplication::get().zapp(); + let settings = TvSettings::get(); + let visible_channels = settings.live_channels(); + + let live_channels = TvApplication::get().live_channels().await; + + let live_channels: Vec<(ChannelId, ChannelInfo)> = if visible_channels.is_empty() { + live_channels + .iter() + .map(|(channel_id, channel_info)| (channel_id.clone(), channel_info.clone())) + .collect() + } else { + visible_channels + .into_iter() + .filter_map(|channel_id| { + let channel_info = live_channels.get(&channel_id)?; + Some((channel_id, channel_info.clone())) + }) + .collect() + }; + let load_channels = tokio(async move { let mut channels: Vec<(ChannelId, ChannelInfo, Option>)> = - Vec::with_capacity(list.len()); + Vec::with_capacity(live_channels.len()); - for (channel_id, channel_info) in list { - match client.shows(&channel_id).await.wrap_err_with(|| { + for (channel_id, channel_info) in live_channels.iter() { + match client.shows(channel_id).await.wrap_err_with(|| { eyre::Report::msg( // translators: `{}` is replaced by the channel_id, e.g. `das_erste` gettext("Failed load shows for channel “{}”") @@ -63,54 +65,67 @@ mod imp { ) })? { ShowsResult::Shows(shows) => { - channels.push((channel_id, channel_info, Some(shows))) + channels.push((channel_id.clone(), channel_info.clone(), Some(shows))) + } + ShowsResult::Error(_) => { + channels.push((channel_id.clone(), channel_info.clone(), None)) } - ShowsResult::Error(_) => channels.push((channel_id, channel_info, None)), } } Ok::<_, eyre::Report>(channels) }); - match load_channels.await { - Ok(channels) => { - for (channel_id, channel_info, shows) in channels { - let channel = ChannelObject::new( - channel_id.as_ref(), - &channel_info.name, - &channel_info.stream_url, + let channel_objects = load_channels + .await? + .into_iter() + .map(|(channel_id, channel_info, shows)| { + let channel = ChannelObject::new( + channel_id.as_ref(), + &channel_info.name, + &channel_info.stream_url, + ); + if let Some(Show { + title, + subtitle, + description, + channel: _, + start_time, + end_time, + }) = shows.as_ref().and_then(|shows| shows.first()) + { + channel.set_title(Some(title.as_str())); + channel.set_subtitle(subtitle.as_deref()); + channel.set_description( + description.as_deref().map(html2pango::markup).as_deref(), ); - if let Some(Show { - title, - subtitle, - description, - channel: _, - start_time, - end_time, - }) = shows.as_ref().and_then(|shows| shows.first()) - { - channel.set_title(Some(title.as_str())); - channel.set_subtitle(subtitle.as_deref()); - channel.set_description( - description.as_deref().map(html2pango::markup).as_deref(), - ); - channel.set_start_time(start_time.unix_timestamp()); - channel.set_end_time(end_time.unix_timestamp()); - } - self.channels_model().append(&channel) + channel.set_start_time(start_time.unix_timestamp()); + channel.set_end_time(end_time.unix_timestamp()); } + channel + }) + .collect::(); + + Ok(channel_objects) + } + pub(super) async fn reload(&self) { + self.spinner.set_spinning(true); + self.stack.set_visible_child_name("spinner"); + + match self.load_channels().await { + Ok(channels) => { + self.channels_list.bind_model(Some(&channels), |channel| { + glib::Object::builder::() + .property("channel", channel) + .build() + .upcast() + }); self.stack.set_visible_child_name("channels"); self.spinner.set_spinning(false); } Err(e) => show_error(e.wrap_err(gettext("Failed to load livestream channels"))), } } - pub(super) async fn reload(&self) { - self.spinner.set_spinning(true); - self.stack.set_visible_child_name("spinner"); - self.channels_model().remove_all(); - self.load().await; - } } #[glib::object_subclass] @@ -132,13 +147,7 @@ mod imp { fn constructed(&self) { self.parent_constructed(); - self.channels_list - .bind_model(Some(&self.channels_model()), |channel| { - glib::Object::builder::() - .property("channel", channel) - .build() - .upcast() - }); + let settings = TvSettings::get(); self.channels_list.connect_row_activated(|_, row| { let row = row @@ -148,7 +157,11 @@ mod imp { }); let slf = self.to_owned(); - spawn(async move { slf.load().await }); + spawn(async move { slf.reload().await }); + + settings.connect_live_channels_changed(glib::clone!(@weak self as slf => move |_| { + spawn(async move { slf.reload().await }); + })); } } impl WidgetImpl for TvLiveView {} diff --git a/src/main.rs b/src/main.rs index 3d52125..f149aa7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ #![allow(clippy::new_without_default)] mod application; +mod channel_icons; mod config; mod launcher; mod live; @@ -26,6 +27,7 @@ mod preferences; mod settings; mod utils; mod window; +mod zapp; use self::{ application::TvApplication, diff --git a/src/mediathek/card.rs b/src/mediathek/card.rs index f594730..0c9819a 100644 --- a/src/mediathek/card.rs +++ b/src/mediathek/card.rs @@ -7,11 +7,12 @@ use adw::{gio, glib, gtk, prelude::*, subclass::prelude::*}; use gettextrs::gettext; use crate::{ + channel_icons::load_channel_icon, settings::{TvSettings, VideoQuality}, - utils::{load_channel_icon, show_error, spawn}, + utils::{show_error, spawn}, }; -use super::{channels::Channel, shows::ShowObject}; +use super::shows::ShowObject; mod imp { use super::*; @@ -33,13 +34,11 @@ mod imp { } impl TvMediathekCard { fn set_icon(&self) { - let icon_name = self - .obj() - .show() - .and_then(|c| c.channel().parse::().ok()) - .map(|c| c.icon_name()); - - load_channel_icon(&self.icon, icon_name); + load_channel_icon( + self.obj().show().map(|c| c.channel()).as_deref(), + &self.icon, + 64, + ) } } diff --git a/src/mediathek/channels.rs b/src/mediathek/channels.rs deleted file mode 100644 index a26b97b..0000000 --- a/src/mediathek/channels.rs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2023 David Cabot -// SPDX-License-Identifier: GPL-3.0-or-later - -use crate::utils::channel_mapping; - -channel_mapping! { - #[derive(Clone, Copy, Debug, PartialEq)] - pub enum Channel { - #[channel(name = "3Sat", icon = "3sat.svg")] - DreiSat, - #[channel(name = "ARD", icon = "ard.svg")] - Ard, - #[channel(name = "ARTE.DE", icon = "arte.svg")] - ArteDe, - #[channel(name = "ARTE.EN", icon = "arte.svg")] - ArteEn, - #[channel(name = "ARTE.ES", icon = "arte.svg")] - ArteEs, - #[channel(name = "ARTE.FR", icon = "arte.svg")] - ArteFr, - #[channel(name = "ARTE.IT", icon = "arte.svg")] - ArteIt, - #[channel(name = "ARTE.PL", icon = "arte.svg")] - ArtePl, - #[channel(name = "BR", icon = "br.svg")] - Br, - #[channel(name = "DW", icon = "deutsche-welle.svg")] - Dw, - #[channel(name = "Funk.net", icon = "funk.svg")] - FunkNet, - #[channel(name = "HR", icon = "hr.svg")] - Hr, - #[channel(name = "KiKA", icon = "kika.svg")] - Kika, - #[channel(name = "MDR", icon = "mdr.svg")] - Mdr, - #[channel(name = "NDR", icon = "ndr.svg")] - Ndr, - #[channel(name = "ORF", icon = "orf.svg")] - Orf, - #[channel(name = "PHOENIX", icon = "phoenix.svg")] - Phoenix, - #[channel(name = "Radio Bremen TV", icon = "rb.svg")] - RadioBremenTv, - #[channel(name = "RBB", icon = "rbb.svg")] - Rbb, - #[channel(name = "rbtv", icon = "rb.svg")] - Rbtv, - #[channel(name = "SR", icon = "sr.svg")] - Sr, - #[channel(name = "SRF", icon = "srf.svg")] - Srf, - #[channel(name = "SWR", icon = "swr.svg")] - Swr, - #[channel(name = "WDR", icon = "wdr.svg")] - Wdr, - #[channel(name = "ZDF", icon = "zdf.svg")] - Zdf, - #[channel(name = "ZDF-tivi", icon = "zdf.svg")] - ZdfTivi, - } -} diff --git a/src/mediathek/mod.rs b/src/mediathek/mod.rs index 11b44f4..b8589a4 100644 --- a/src/mediathek/mod.rs +++ b/src/mediathek/mod.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later mod card; -mod channels; mod shows; mod view; diff --git a/src/preferences.blp b/src/preferences/dialog.blp similarity index 72% rename from src/preferences.blp rename to src/preferences/dialog.blp index 0625b65..af58f88 100644 --- a/src/preferences.blp +++ b/src/preferences/dialog.blp @@ -4,8 +4,6 @@ using Gtk 4.0; using Adw 1; template $TvPreferencesDialog: Adw.PreferencesDialog { - follows-content-size: true; - Adw.PreferencesPage { Adw.PreferencesGroup { title: _("External Programs"); @@ -33,5 +31,20 @@ template $TvPreferencesDialog: Adw.PreferencesDialog { } } } + + Adw.PreferencesGroup { + title: _("Live Channels"); + name: "live-channels"; + + Adw.ActionRow { + title: _("Select and reorder channels"); + activatable: true; + activated => $select_live_channels() swapped; + + Gtk.Image { + icon-name: "go-next-symbolic"; + } + } + } } } diff --git a/src/preferences.rs b/src/preferences/dialog.rs similarity index 94% rename from src/preferences.rs rename to src/preferences/dialog.rs index 8d1d47a..48db750 100644 --- a/src/preferences.rs +++ b/src/preferences/dialog.rs @@ -10,12 +10,13 @@ use crate::{ settings::TvSettings, }; -mod imp { +use super::live::TvLiveChannelSelector; +mod imp { use super::*; #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)] - #[template(file = "src/preferences.blp")] + #[template(file = "src/preferences/dialog.blp")] #[properties(wrapper_type = super::TvPreferencesDialog)] pub struct TvPreferencesDialog { #[template_child] @@ -57,6 +58,10 @@ mod imp { self.settings.set_video_downloader_id(&downloader.id); } } + #[template_callback] + async fn select_live_channels(&self, #[rest] _: &[glib::Value]) { + self.obj().push_subpage(&TvLiveChannelSelector::new()) + } } impl TvPreferencesDialog { diff --git a/src/preferences/live/mod.rs b/src/preferences/live/mod.rs new file mode 100644 index 0000000..2e0fa3d --- /dev/null +++ b/src/preferences/live/mod.rs @@ -0,0 +1,4 @@ +mod selector; +mod selector_row; + +pub use self::selector::TvLiveChannelSelector; diff --git a/src/preferences/live/selector.blp b/src/preferences/live/selector.blp new file mode 100644 index 0000000..9a61d35 --- /dev/null +++ b/src/preferences/live/selector.blp @@ -0,0 +1,60 @@ +// Copyright 2023 David Cabot +// SPDX-License-Identifier: GPL-3.0-or-later +using Gtk 4.0; +using Adw 1; + +template $TvLiveChannelSelector: Adw.NavigationPage { + title: _("Select and reorder channels"); + + Adw.ToolbarView { + [top] + Adw.HeaderBar {} + + Adw.PreferencesPage { + Adw.PreferencesGroup { + title: _("Visible channels"); + name: "visible-channels"; + + header-suffix: Gtk.MenuButton { + icon-name: "view-more-symbolic"; + menu-model: sort_menu; + + styles [ + "flat" + ] + }; + + Gtk.ListBox visible_channel_rows { + styles [ + "boxed-list" + ] + } + } + + Adw.PreferencesGroup { + title: _("Hidden channels"); + name: "hidden-channels"; + + Gtk.ListBox hidden_channel_rows { + styles [ + "boxed-list" + ] + } + } + } + } +} + +menu sort_menu { + section { + item { + label: _("Sort alphabetically"); + action: "live-channel-selector.sort-alphabetically"; + } + + item { + label: _("Sort using default order"); + action: "live-channel-selector.sort-default-order"; + } + } +} diff --git a/src/preferences/live/selector.rs b/src/preferences/live/selector.rs new file mode 100644 index 0000000..470b234 --- /dev/null +++ b/src/preferences/live/selector.rs @@ -0,0 +1,209 @@ +// Copyright 2023 David Cabot +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::cell::RefCell; + +use adw::{gio, glib, gtk, prelude::*, subclass::prelude::*}; +use indexmap::IndexMap; +use smart_default::SmartDefault; + +use crate::{ + application::TvApplication, + settings::TvSettings, + zapp::{ChannelId, ChannelInfo}, +}; + +use super::selector_row::TvLiveChannelSelectorRow; + +mod imp { + use crate::utils::{spawn, ListStoreExtManual}; + + use super::*; + + #[derive(Debug, SmartDefault, gtk::CompositeTemplate)] + #[template(file = "src/preferences/live/selector.blp")] + pub struct TvLiveChannelSelector { + #[template_child] + pub(super) visible_channel_rows: TemplateChild, + #[template_child] + pub(super) hidden_channel_rows: TemplateChild, + + #[default(gio::ListStore::new::())] + pub(super) visible_channels: gio::ListStore, + #[default(gio::ListStore::new::())] + pub(super) hidden_channels: gio::ListStore, + pub(super) channel_rows: RefCell>, + + pub(super) settings: TvSettings, + } + + impl TvLiveChannelSelector { + async fn load(&self) { + let live_channels = TvApplication::get().live_channels().await; + let mut visible_channels = self.settings.live_channels(); + + self.channel_rows + .borrow_mut() + .extend(live_channels.iter().map(|(id, ChannelInfo { name, .. })| { + let row = TvLiveChannelSelectorRow::new(id, name); + row.connect_visible_notify( + glib::clone!(@weak self as slf => move |row: &TvLiveChannelSelectorRow| { + slf.remove_row(row); + + if row.visible() { + slf.visible_channels.append(row); + } else { + let channel_rows = slf.channel_rows.borrow(); + slf.hidden_channels + .typed_insert_sorted::(row, |a, b| { + channel_rows + .get_index_of(&a.channel_id()) + .cmp(&channel_rows.get_index_of(&b.channel_id())) + }); + } + }), + ); + row.connect_received_drop( + glib::clone!(@weak self as slf => move |target_row, source_row| { + let target_visible = target_row.visible(); + let source_visible = source_row.visible(); + + if target_visible && source_visible { + let src_pos = slf.visible_channels.find(source_row).unwrap(); + let target_pos = slf.visible_channels.find(target_row).unwrap(); + + slf.visible_channels.remove(src_pos); + slf.visible_channels.insert(target_pos, source_row); + } else { + let target_rows = if target_row.visible() { + source_row.set_visible(true); + &slf.visible_channels + } else { + source_row.set_visible(false); + &slf.hidden_channels + }; + slf.remove_row(source_row); + + let target_pos = target_rows.find(target_row).unwrap(); + target_rows.insert(target_pos, source_row) + } + }), + ); + (id.clone(), row) + })); + + if visible_channels.is_empty() { + visible_channels.extend(live_channels.keys().cloned()); + } + + let channel_rows = self.channel_rows.borrow(); + + for id in &visible_channels { + if let Some(row) = channel_rows.get(id) { + row.set_visible(true); + } + } + + for (id, row) in channel_rows.iter() { + if !visible_channels.contains(id) { + row.set_visible(false); + } + } + } + fn remove_row(&self, row: &TvLiveChannelSelectorRow) { + if let Some(pos) = self.visible_channels.find(row) { + self.visible_channels.remove(pos) + } + if let Some(pos) = self.hidden_channels.find(row) { + self.hidden_channels.remove(pos) + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for TvLiveChannelSelector { + const NAME: &'static str = "TvLiveChannelSelector"; + type Type = super::TvLiveChannelSelector; + type ParentType = adw::NavigationPage; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + + klass.install_action( + "live-channel-selector.sort-alphabetically", + None, + |slf, _, _| { + let slf = slf.imp(); + slf.visible_channels + .typed_sort::(|a, b| { + a.channel_name() + .to_lowercase() + .cmp(&b.channel_name().to_lowercase()) + }) + }, + ); + klass.install_action( + "live-channel-selector.sort-default-order", + None, + |slf, _, _| { + let slf = slf.imp(); + let channel_rows = slf.channel_rows.borrow(); + + slf.visible_channels + .typed_sort::(|a, b| { + channel_rows + .get_index_of(&a.channel_id()) + .cmp(&channel_rows.get_index_of(&b.channel_id())) + }) + }, + ); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for TvLiveChannelSelector { + fn constructed(&self) { + self.parent_constructed(); + + self.visible_channel_rows + .bind_model(Some(&self.visible_channels), |row| { + row.clone().downcast().unwrap() + }); + self.hidden_channel_rows + .bind_model(Some(&self.hidden_channels), |row| { + row.clone().downcast().unwrap() + }); + + spawn(glib::clone!(@weak self as slf => async move { + slf.load().await + })); + + let settings = self.settings.clone(); + self.visible_channels + .connect_items_changed(move |visible_channels, _, _, _| { + let channels = visible_channels + .iter::() + .map(|res| res.unwrap()) + .map(|row| row.channel_id()) + .collect::>(); + settings.set_live_channels(channels); + }); + } + } + impl WidgetImpl for TvLiveChannelSelector {} + impl NavigationPageImpl for TvLiveChannelSelector {} +} + +glib::wrapper! { + pub struct TvLiveChannelSelector(ObjectSubclass) + @extends gtk::Widget, adw::NavigationPage; +} + +impl TvLiveChannelSelector { + pub fn new() -> Self { + glib::Object::new() + } +} diff --git a/src/preferences/live/selector_row.blp b/src/preferences/live/selector_row.blp new file mode 100644 index 0000000..02da6cd --- /dev/null +++ b/src/preferences/live/selector_row.blp @@ -0,0 +1,36 @@ +// Copyright 2023 David Cabot +// SPDX-License-Identifier: GPL-3.0-or-later +using Gtk 4.0; +using Adw 1; + +template $TvLiveChannelSelectorRow: Adw.ActionRow { + title: bind template.channel-name; + selectable: false; + + [prefix] + Gtk.Image icon {} + + [prefix] + Gtk.Image { + icon-name: "list-drag-handle-symbolic"; + } + + [suffix] + Gtk.Switch switch { + valign: center; + active: bind template.visible; + state: bind template.visible bidirectional; + } + + Gtk.DragSource drag_source { + actions: move; + prepare => $drag_prepare() swapped; + drag-begin => $drag_begin() swapped; + } + + Gtk.DropTarget drop_target { + actions: move; + formats: "TvLiveChannelSelectorRow"; + drop => $drop() swapped; + } +} diff --git a/src/preferences/live/selector_row.rs b/src/preferences/live/selector_row.rs new file mode 100644 index 0000000..5c454cd --- /dev/null +++ b/src/preferences/live/selector_row.rs @@ -0,0 +1,138 @@ +// Copyright 2023 David Cabot +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::{ + cell::{Cell, RefCell}, + sync::OnceLock, +}; + +use adw::{gdk, glib, glib::subclass::Signal, gtk, prelude::*, subclass::prelude::*}; + +use crate::{channel_icons::load_channel_icon, zapp::ChannelId}; + +mod imp { + use super::*; + + #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)] + #[template(file = "src/preferences/live/selector_row.blp")] + #[properties(wrapper_type = super::TvLiveChannelSelectorRow)] + pub struct TvLiveChannelSelectorRow { + #[template_child] + icon: TemplateChild, + #[template_child] + switch: TemplateChild, + #[template_child] + drag_source: TemplateChild, + #[template_child] + drop_target: TemplateChild, + + #[property(get, construct_only)] + channel_id: RefCell, + #[property(get, construct_only)] + channel_name: RefCell, + #[property(get, set)] + visible: Cell, + + drag_x: Cell, + drag_y: Cell, + } + + #[gtk::template_callbacks] + impl TvLiveChannelSelectorRow { + #[template_callback] + fn drag_prepare(&self, x: f64, y: f64) -> Option { + self.drag_x.set(x); + self.drag_y.set(y); + Some(gdk::ContentProvider::for_value(&self.obj().to_value())) + } + #[template_callback] + fn drag_begin(&self, drag: gdk::Drag) { + let drag_widget = gtk::ListBox::builder() + .width_request(self.obj().width()) + .height_request(self.obj().height()) + .build(); + + let drag_row: super::TvLiveChannelSelectorRow = glib::Object::builder() + .property("channel-id", &*self.channel_id.borrow()) + .property("channel-name", &*self.channel_name.borrow()) + .property("visible", true) + .build(); + drag_widget.append(&drag_row); + drag_widget.drag_highlight_row(&drag_row); + + let drag_icon: gtk::DragIcon = gtk::DragIcon::for_drag(&drag).downcast().unwrap(); + drag_icon.set_child(Some(&drag_widget)); + drag.set_hotspot(self.drag_x.get() as i32, self.drag_y.get() as i32) + } + #[template_callback] + fn drop(&self, value: glib::BoxedValue, #[rest] _: &[glib::Value]) -> bool { + match value.get::() { + Ok(row) => { + self.obj().emit_received_drop(&row); + true + } + _ => false, + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for TvLiveChannelSelectorRow { + const NAME: &'static str = "TvLiveChannelSelectorRow"; + type Type = super::TvLiveChannelSelectorRow; + type ParentType = adw::ActionRow; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks() + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for TvLiveChannelSelectorRow { + fn constructed(&self) { + self.parent_constructed(); + + load_channel_icon(Some(self.channel_id.borrow().as_ref()), &self.icon, 16); + } + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock<[Signal; 1]> = OnceLock::new(); + SIGNALS.get_or_init(|| { + [Signal::builder("received-drop") + .param_types([super::TvLiveChannelSelectorRow::static_type()]) + .build()] + }) + } + } + impl WidgetImpl for TvLiveChannelSelectorRow {} + impl ListBoxRowImpl for TvLiveChannelSelectorRow {} + impl PreferencesRowImpl for TvLiveChannelSelectorRow {} + impl ActionRowImpl for TvLiveChannelSelectorRow {} +} + +glib::wrapper! { + pub struct TvLiveChannelSelectorRow(ObjectSubclass) + @extends gtk::Widget, gtk::ListBoxRow, adw::PreferencesRow, adw::ActionRow; +} + +impl TvLiveChannelSelectorRow { + pub fn new(id: &ChannelId, name: &str) -> Self { + glib::Object::builder() + .property("channel-id", id) + .property("channel-name", name) + .build() + } + pub fn emit_received_drop(&self, row: &TvLiveChannelSelectorRow) { + self.emit_by_name::<()>("received-drop", &[&row]); + } + pub fn connect_received_drop(&self, f: impl Fn(&Self, &TvLiveChannelSelectorRow) + 'static) { + self.connect_local("received-drop", false, move |args| { + f(&args[0].get().unwrap(), &args[1].get().unwrap()); + None + }); + } +} diff --git a/src/preferences/mod.rs b/src/preferences/mod.rs new file mode 100644 index 0000000..36fa5d2 --- /dev/null +++ b/src/preferences/mod.rs @@ -0,0 +1,4 @@ +mod dialog; +mod live; + +pub use self::dialog::TvPreferencesDialog; diff --git a/src/settings.rs b/src/settings.rs index 8a4b2ad..271a3ab 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -3,12 +3,17 @@ use std::{cell::OnceCell, fmt::Display, str::FromStr}; -use adw::{gio, glib}; +use adw::{gio, glib, prelude::*}; use gsettings_macro::gen_settings; -use crate::config::BASE_APP_ID; +use crate::{config::BASE_APP_ID, zapp::ChannelId}; #[gen_settings(file = "data/de.k_bo.Televido.gschema.xml")] +#[gen_settings_define( + key_name = "live-channels", + arg_type = "Vec", + ret_type = "Vec" +)] pub struct TvSettings; impl TvSettings { @@ -30,6 +35,27 @@ impl Default for TvSettings { } } +impl StaticVariantType for ChannelId { + fn static_variant_type() -> std::borrow::Cow<'static, glib::VariantTy> { + String::static_variant_type() + } +} +impl FromVariant for ChannelId { + fn from_variant(variant: &glib::Variant) -> Option { + String::from_variant(variant).map(ChannelId::from) + } +} +impl ToVariant for ChannelId { + fn to_variant(&self) -> glib::Variant { + AsRef::::as_ref(self).to_variant() + } +} +impl From for glib::Variant { + fn from(id: ChannelId) -> Self { + id.to_variant() + } +} + #[derive(Clone, Copy, Debug, PartialEq)] pub enum VideoQuality { High, diff --git a/src/utils.rs b/src/utils.rs index bfc35f4..fc45c96 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,19 +1,24 @@ // Copyright 2023 David Cabot // SPDX-License-Identifier: GPL-3.0-or-later -use std::{future::Future, sync::OnceLock, time::Duration}; - -use adw::{ - glib, - gtk::{self, gdk, gdk_pixbuf}, - prelude::*, +use std::{ + cell::{OnceCell, RefCell}, + fmt::Debug, + future::Future, + pin::Pin, + rc::Rc, + sync::OnceLock, + task::{Poll, Waker}, + time::Duration, }; -use eyre::WrapErr; + +use adw::{gio, glib, gtk, prelude::*}; use gettextrs::gettext; -use tracing::error; use crate::{application::TvApplication, window::TvWindow}; +pub use self::async_resource::AsyncResource; + pub async fn tokio(fut: Fut) -> T where Fut: Future + Send + 'static, @@ -84,93 +89,6 @@ pub fn format_duration(duration: &Duration) -> String { } } -macro_rules! channel_mapping { - ( - $( #[ $attrs:meta ] )* - pub enum $Enum:ident { - $( - #[channel(name = $name:literal, icon = $icon:literal)] - $Variant:ident, - )+ - } - ) => { - $( #[ $attrs ] )* - pub enum $Enum { - $( $Variant, )+ - } - impl $Enum { - #[allow(dead_code)] - pub fn all() -> &'static [Self] { - static ALL: &[$Enum] = &[ $( $Enum::$Variant ),+ ]; - ALL - } - pub fn icon_name(&self) -> &'static str { - match self { - $( $Enum::$Variant => $icon, )+ - } - } - } - impl std::str::FromStr for $Enum { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - $( $name => Ok($Enum::$Variant), )+ - _ => Err(()) - } - } - } - impl std::fmt::Display for $Enum { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str( - match self { - $( $Enum::$Variant => $name, )+ - } - ) - } - } - }; -} -pub(crate) use channel_mapping; - -pub fn load_channel_icon(image: >k::Image, icon_name: Option<&'static str>) { - let Some(icon_name) = icon_name else { - image.set_icon_name(Some("image-missing-symbolic")); - return; - }; - - let style_manager = TvApplication::get().style_manager(); - - set_icon(&style_manager, image, icon_name); - - style_manager.connect_dark_notify(glib::clone!(@weak image => move |style_manager| { - set_icon(style_manager, &image, icon_name) - })); - - fn set_icon(style_manager: &adw::StyleManager, image: >k::Image, icon_name: &str) { - match load_icon(style_manager, icon_name) { - Ok(texture) => image.set_from_paintable(Some(&texture)), - Err(e) => { - error!("{e:?}"); - image.set_icon_name(Some("image-missing-symbolic")); - } - } - } - - fn load_icon(style_manager: &adw::StyleManager, icon_name: &str) -> eyre::Result { - let resource = if style_manager.is_dark() { - format!("/de/k_bo/televido/icons/scalable/channels/dark/{icon_name}",) - } else { - format!("/de/k_bo/televido/icons/scalable/channels/light/{icon_name}",) - }; - - // load image manually with given size to avoid blurriness caused by scaling after rasterization - gdk_pixbuf::Pixbuf::from_resource_at_scale(&resource, 64, 64, true) - .map(|pixbuf| gdk::Texture::for_pixbuf(&pixbuf)) - .wrap_err_with(|| format!("failed to load channel logo from {resource}")) - } -} - pub fn show_error(e: eyre::Report) { if let Some(window) = TvApplication::get() .active_window() @@ -187,3 +105,105 @@ pub fn show_error(e: eyre::Report) { } tracing::error!("{e:?}"); } + +pub trait ListStoreExtManual { + fn typed_insert_sorted>( + &self, + item: &T, + compare_func: impl FnMut(&T, &T) -> std::cmp::Ordering, + ) -> u32; + fn typed_sort>( + &self, + compare_func: impl FnMut(&T, &T) -> std::cmp::Ordering, + ); +} + +impl ListStoreExtManual for gio::ListStore { + fn typed_insert_sorted>( + &self, + item: &T, + mut compare_func: impl FnMut(&T, &T) -> std::cmp::Ordering, + ) -> u32 { + self.insert_sorted(item, |a, b| { + compare_func(a.downcast_ref().unwrap(), b.downcast_ref().unwrap()) + }) + } + fn typed_sort>( + &self, + mut compare_func: impl FnMut(&T, &T) -> std::cmp::Ordering, + ) { + self.sort(|a, b| compare_func(a.downcast_ref().unwrap(), b.downcast_ref().unwrap())) + } +} + +mod async_resource { + use super::*; + + #[derive(Clone, Default)] + pub struct AsyncResource { + inner: Rc>, + } + + impl std::fmt::Debug for AsyncResource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(std::any::type_name::()) + } + } + + #[derive(Default)] + struct AsyncResourceInner { + data: RefCell>, + load_fn: OnceCell>, + wakers: RefCell>, + } + + type LoadFn = Rc Pin + 'static>>>; + + impl AsyncResource { + #[track_caller] + pub fn set_load_fn( + &self, + load_fn: impl Fn() -> Pin + 'static>> + 'static, + ) { + if self.inner.load_fn.set(Rc::new(load_fn)).is_err() { + panic!("Resource load function has already been initialized") + } + } + } + + impl AsyncResource { + pub fn load(&self) { + match self.inner.load_fn.get().cloned() { + Some(load_fn) => { + let inner = self.inner.clone(); + spawn(async move { + *inner.data.borrow_mut() = None; + let data = load_fn().await; + *inner.data.borrow_mut() = Some(data); + for waker in inner.wakers.borrow_mut().drain(..) { + waker.wake(); + } + }); + } + None => panic!("Resource load function has not been initialized"), + } + } + } + + impl Future for AsyncResource { + type Output = T; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + match &*self.inner.data.borrow() { + Some(data) => Poll::Ready(data.clone()), + None => { + self.inner.wakers.borrow_mut().push(cx.waker().clone()); + Poll::Pending + } + } + } + } +} diff --git a/src/window.rs b/src/window.rs index 1c649a1..d23920a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -92,7 +92,7 @@ mod imp { glib::wrapper! { pub struct TvWindow(ObjectSubclass) @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow, - @implements gio::ActionGroup, gio::ActionMap; + @implements gio::ActionGroup, gio::ActionMap, gtk::Native; } impl TvWindow { diff --git a/src/live/zapp.rs b/src/zapp.rs similarity index 93% rename from src/live/zapp.rs rename to src/zapp.rs index f9e62e2..261c8ce 100644 --- a/src/live/zapp.rs +++ b/src/zapp.rs @@ -1,4 +1,4 @@ -// Copyright (c) 2023 d-k-bo +// Copyright (c) 2023-2024 d-k-bo // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -20,6 +20,7 @@ // // SPDX-License-Identifier: MIT +use adw::glib; use indexmap::IndexMap; use serde::Deserialize; use time::OffsetDateTime; @@ -66,7 +67,7 @@ impl Zapp { pub type ChannelInfoList = IndexMap; -#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize)] +#[derive(Clone, Debug, Default, Eq, Hash, PartialEq, Deserialize, glib::ValueDelegate)] pub struct ChannelId(String); impl AsRef for ChannelId { fn as_ref(&self) -> &str { @@ -78,6 +79,11 @@ impl std::fmt::Display for ChannelId { f.write_str(self.as_ref()) } } +impl From for ChannelId { + fn from(s: String) -> Self { + Self(s) + } +} #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")]