From f1e27eb7f3491ee4b80bd0cb6e7684d14d3bffb5 Mon Sep 17 00:00:00 2001 From: d-k-bo <47948262+d-k-bo@users.noreply.github.com> Date: Sun, 29 Oct 2023 18:21:31 +0100 Subject: [PATCH] Support custom video players --- .../icons/scalable/actions/error-symbolic.svg | 2 + .../scalable/actions/test-pass-symbolic.svg | 2 + po/de.po | 45 +++-- po/televido.pot | 41 ++-- src/application.rs | 41 ++-- src/launcher/mod.rs | 113 ++++++----- src/launcher/selector.blp | 15 ++ src/launcher/selector.rs | 175 +++++++++++++----- src/preferences.rs | 25 ++- 9 files changed, 317 insertions(+), 142 deletions(-) create mode 100644 data/resources/icons/scalable/actions/error-symbolic.svg create mode 100644 data/resources/icons/scalable/actions/test-pass-symbolic.svg diff --git a/data/resources/icons/scalable/actions/error-symbolic.svg b/data/resources/icons/scalable/actions/error-symbolic.svg new file mode 100644 index 0000000..acc96ed --- /dev/null +++ b/data/resources/icons/scalable/actions/error-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/resources/icons/scalable/actions/test-pass-symbolic.svg b/data/resources/icons/scalable/actions/test-pass-symbolic.svg new file mode 100644 index 0000000..005be01 --- /dev/null +++ b/data/resources/icons/scalable/actions/test-pass-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/po/de.po b/po/de.po index fe04dff..4a6f60f 100644 --- a/po/de.po +++ b/po/de.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: Televido main\n" "Report-Msgid-Bugs-To: https://github.com/d-k-bo/televido/issues\n" -"POT-Creation-Date: 2023-10-26 21:32+0200\n" +"POT-Creation-Date: 2023-10-29 18:20+0100\n" "PO-Revision-Date: 2023-10-20 20:55+0200\n" "Last-Translator: David Cabot \n" "Language-Team: \n" @@ -72,7 +72,7 @@ msgstr "" msgid "Initital release" msgstr "Erstveröffentlichung" -#: src/application.rs:116 +#: src/application.rs:129 msgid "Failed to play video stream" msgstr "Videostream konnte nicht abgespielt werden" @@ -104,38 +104,53 @@ msgstr "Abbrechen" msgid "Confirm" msgstr "Bestätigen" -#: src/launcher/selector.rs:76 +#: src/launcher/selector.blp:38 +msgid "Custom program" +msgstr "Benutzerdefiniertes Programm" + +#. translators: `{}` is replaced by the application ID, e.g. `org.example.Application` +#: src/launcher/selector.rs:115 +msgid "Could not find application “{}”" +msgstr "Anwendung „{}“ konnte nicht gefunden werden" + +#: src/launcher/selector.rs:169 msgid "Failed to load external applications" msgstr "Externe Anwendungen konnten nicht geladen werden." -#: src/launcher/selector.rs:80 src/utils.rs:183 +#: src/launcher/selector.rs:173 src/utils.rs:183 msgid "See the terminal output for details." msgstr "In den Programm-Logs finden Sie weitere Details." -#. translators: `{}` is replaced by given ID, a valid one would be e.g. `org.gnome.Totem` -#: src/launcher/selector.rs:146 -msgid "Invalid program ID: “{}”" -msgstr "Ungültige Programm-ID: „{}“" - -#: src/launcher/selector.rs:196 +#: src/launcher/selector.rs:280 msgid "Select video player" msgstr "Videoplayer auswählen" -#: src/launcher/selector.rs:198 -msgid "Select one of the following external programs to stream content" +#: src/launcher/selector.rs:281 +msgid "Select one of the following external programs to stream content." msgstr "" "Wählen Sie eines der folgenden externen Programme zum Streamen von Inhalten" -#: src/launcher/selector.rs:202 +#: src/launcher/selector.rs:284 msgid "Select video downloader" msgstr "Video-Downloader auswählen" -#: src/launcher/selector.rs:204 -msgid "Select one of the following external programs to download content" +#: src/launcher/selector.rs:285 +msgid "Select one of the following external programs to download content." msgstr "" "Wählen Sie eines der folgenden externen Programme zum Herunterladen von " "Inhalten" +#: src/launcher/selector.rs:292 +msgid "" +"You can also specify a custom application ID (e.g. org.example.Application) " +"of a different program that supports DBus activation, is able to open a " +"https:// URI and is accessible from the context of this application." +msgstr "" +"Sie können auch eine benutzerdefinierte Anwendungs-ID (z.B. org.example." +"Application) angeben, um ein anderes Programm zu nutzen, das DBus-" +"Aktivierung unterstützt, https://-URIs öffnen kann und aus dem Kontext " +"dieser Anwendung erreicht werden kann." + #: src/live/card.blp:46 src/mediathek/card.blp:86 msgid "Play" msgstr "Abspielen" diff --git a/po/televido.pot b/po/televido.pot index 2c7ee51..68f92c2 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: 2023-10-26 21:32+0200\n" +"POT-Creation-Date: 2023-10-29 18:20+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -57,7 +57,7 @@ msgstr "" msgid "Initital release" msgstr "" -#: src/application.rs:116 +#: src/application.rs:129 msgid "Failed to play video stream" msgstr "" @@ -89,33 +89,44 @@ msgstr "" msgid "Confirm" msgstr "" -#: src/launcher/selector.rs:76 -msgid "Failed to load external applications" +#: src/launcher/selector.blp:38 +msgid "Custom program" msgstr "" -#: src/launcher/selector.rs:80 src/utils.rs:183 -msgid "See the terminal output for details." +#. translators: `{}` is replaced by the application ID, e.g. `org.example.Application` +#: src/launcher/selector.rs:115 +msgid "Could not find application “{}”" msgstr "" -#. translators: `{}` is replaced by given ID, a valid one would be e.g. `org.gnome.Totem` -#: src/launcher/selector.rs:146 -msgid "Invalid program ID: “{}”" +#: src/launcher/selector.rs:169 +msgid "Failed to load external applications" msgstr "" -#: src/launcher/selector.rs:196 +#: src/launcher/selector.rs:173 src/utils.rs:183 +msgid "See the terminal output for details." +msgstr "" + +#: src/launcher/selector.rs:280 msgid "Select video player" msgstr "" -#: src/launcher/selector.rs:198 -msgid "Select one of the following external programs to stream content" +#: src/launcher/selector.rs:281 +msgid "Select one of the following external programs to stream content." msgstr "" -#: src/launcher/selector.rs:202 +#: src/launcher/selector.rs:284 msgid "Select video downloader" msgstr "" -#: src/launcher/selector.rs:204 -msgid "Select one of the following external programs to download content" +#: src/launcher/selector.rs:285 +msgid "Select one of the following external programs to download content." +msgstr "" + +#: src/launcher/selector.rs:292 +msgid "" +"You can also specify a custom application ID (e.g. org.example.Application) " +"of a different program that supports DBus activation, is able to open a " +"https:// URI and is accessible from the context of this application." msgstr "" #: src/live/card.blp:46 src/mediathek/card.blp:86 diff --git a/src/application.rs b/src/application.rs index 937a639..d411d54 100644 --- a/src/application.rs +++ b/src/application.rs @@ -8,7 +8,7 @@ use gettextrs::gettext; use crate::{ config::{APP_ID, APP_NAME, AUTHOR, ISSUE_URL, PROJECT_URL, VERSION}, - launcher::{ExternalProgramType, ProgramSelector}, + launcher::{ExternalProgram, ExternalProgramType, ProgramSelector}, preferences::TvPreferencesWindow, settings::TvSettings, utils::{show_error, spawn_clone, tokio}, @@ -93,22 +93,35 @@ impl TvApplication { pub async fn play(&self, uri: String) { let settings = TvSettings::get(); + let player_name = settings.video_player_name(); let player_id = settings.video_player_id(); - let player = if player_id.is_empty() { - let Some(program) = ProgramSelector::select_program(ExternalProgramType::Player).await - else { - return; - }; - - settings.set_video_player_name(program.name); - settings.set_video_player_id(program.id); - program + let player = if player_id.is_empty() { + None } else { - let Some(program) = ExternalProgramType::Player.find(&player_id) else { - return; - }; - program + match ExternalProgram::find(player_name, player_id.clone()).await { + Ok(player) => player, + Err(e) => { + show_error(e); + None + } + } + }; + + let player = match player { + Some(player) => player, + None => { + match ProgramSelector::select_program(ExternalProgramType::Player, player_id).await + { + Some(player) => { + settings.set_video_player_name(&player.name); + settings.set_video_player_id(&player.id); + + player + } + None => return, + } + } }; match player.play(uri).await { diff --git a/src/launcher/mod.rs b/src/launcher/mod.rs index 5136108..5d764f0 100644 --- a/src/launcher/mod.rs +++ b/src/launcher/mod.rs @@ -1,6 +1,8 @@ // Copyright 2023 David Cabot // SPDX-License-Identifier: GPL-3.0-or-later +use std::borrow::Cow; + use crate::{application::TvApplication, utils::tokio}; use self::application_proxy::ApplicationProxy; @@ -10,47 +12,48 @@ mod application_proxy; mod selector; pub static PLAYERS: &[ExternalProgram] = &[ - ExternalProgram { - name: "Videos", - id: "org.gnome.Totem", - }, - ExternalProgram { - name: "Celluloid", - id: "io.github.celluloid_player.Celluloid", - }, - ExternalProgram { - name: "Clapper", - id: "com.github.rafostar.Clapper", - }, + ExternalProgram::new("Videos", "org.gnome.Totem"), + ExternalProgram::new("Celluloid", "io.github.celluloid_player.Celluloid"), + ExternalProgram::new("Clapper", "com.github.rafostar.Clapper"), + ExternalProgram::new("Daikhan", "io.gitlab.daikhan.stable"), // not dbus-activatable - // ExternalProgram { name: "µPlayer", id: "org.sigxcpu.Livi"}, - // ExternalProgram { name: "Glide", id: "net.baseart.Glide"}, - // ExternalProgram { name: "Daikhan", id: "io.gitlab.daikhan.stable"}, + // ExternalProgram::new("µPlayer", "org.sigxcpu.Livi"), + // ExternalProgram::new("Glide", "net.baseart.Glide"), // doesn't implement org.freedesktop.Application - // ExternalProgram { name: "VLC", id: "org.videolan.VLC"}, - // ExternalProgram { name: "mpv", id: "io.mpv.Mpv"}, - // ExternalProgram { name: "Haruna Media µPlayer", id: "org.kde.haruna"}, + // ExternalProgram::new("VLC", "org.videolan.VLC"), + // ExternalProgram::new("mpv", "io.mpv.Mpv"), + // ExternalProgram::new("Haruna Media µPlayer", "org.kde.haruna"), ]; -pub static DOWNLOADERS: &[ExternalProgram] = &[ExternalProgram { - name: "Parabolic", - id: "org.nickvision.tubeconverter", -}]; +pub static DOWNLOADERS: &[ExternalProgram] = &[ExternalProgram::new( + "Parabolic", + "org.nickvision.tubeconverter", +)]; -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct ExternalProgram { - pub name: &'static str, - pub id: &'static str, + pub name: Cow<'static, str>, + pub id: Cow<'static, str>, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ExternalProgramType { + Player, + Downloader, } + impl ExternalProgram { pub async fn play(self, uri: impl Into) -> eyre::Result<()> { let conn = TvApplication::dbus().await; let uri = uri.into(); tokio(async move { - let proxy = - ApplicationProxy::new(&conn, self.id, format!("/{}", self.id.replace('.', "/"))) - .await?; + let proxy = ApplicationProxy::new( + &conn, + self.id.clone(), + format!("/{}", self.id.replace('.', "/")), + ) + .await?; proxy.open(&[&uri], Default::default()).await?; @@ -60,27 +63,46 @@ impl ExternalProgram { } } -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum ExternalProgramType { - Player, - Downloader, -} - -impl ExternalProgramType { - pub fn all(self) -> &'static [ExternalProgram] { - match self { - ExternalProgramType::Player => PLAYERS, - ExternalProgramType::Downloader => DOWNLOADERS, +impl ExternalProgram { + const fn new(name: &'static str, id: &'static str) -> Self { + ExternalProgram { + name: Cow::Borrowed(name), + id: Cow::Borrowed(id), } } - pub fn find(self, id: &str) -> Option { - self.all().iter().find(|program| program.id == id).copied() + pub async fn find( + name: impl Into>, + id: impl Into>, + ) -> eyre::Result> { + let conn = TvApplication::dbus().await; + let name = name.into(); + let id = id.into(); + + tokio(async move { + let dbus_proxy = zbus::fdo::DBusProxy::new(&conn).await?; + + if dbus_proxy + .list_activatable_names() + .await? + .into_iter() + .any(|bus_name| bus_name == &*id) + { + Ok(Some(ExternalProgram { name, id })) + } else { + Ok(None) + } + }) + .await } - pub async fn list(self) -> eyre::Result> { + + pub async fn find_known(program_type: ExternalProgramType) -> eyre::Result> { let conn = TvApplication::dbus().await; tokio(async move { - let all = self.all(); + let known_programs = match program_type { + ExternalProgramType::Player => PLAYERS, + ExternalProgramType::Downloader => DOWNLOADERS, + }; let dbus_proxy = zbus::fdo::DBusProxy::new(&conn).await?; let programs = dbus_proxy @@ -88,9 +110,10 @@ impl ExternalProgramType { .await? .into_iter() .filter_map(|bus_name| { - all.iter() + known_programs + .iter() .find(|program| program.id == bus_name.as_str()) - .copied() + .cloned() }) .collect(); diff --git a/src/launcher/selector.blp b/src/launcher/selector.blp index 81688ae..47aaae5 100644 --- a/src/launcher/selector.blp +++ b/src/launcher/selector.blp @@ -33,6 +33,21 @@ template $ProgramSelector : Gtk.Window { Adw.PreferencesPage { Adw.PreferencesGroup program_list { description: bind template.description; + + Adw.EntryRow custom_program_entry { + title: _("Custom program"); + show-apply-button: true; + apply => $apply_custom_program() swapped; + + Gtk.EventControllerFocus { + enter => $start_custom_program_input() swapped; + } + + [suffix] + Gtk.Image custom_program_validation_icon { + visible: false; + } + } } } } diff --git a/src/launcher/selector.rs b/src/launcher/selector.rs index b0927c5..8461b0b 100644 --- a/src/launcher/selector.rs +++ b/src/launcher/selector.rs @@ -2,7 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later use std::{ - cell::{OnceCell, RefCell}, + borrow::Cow, + cell::{Cell, OnceCell, RefCell}, future::Future, rc::Rc, task::Waker, @@ -12,7 +13,10 @@ use adw::{glib, gtk, prelude::*, subclass::prelude::*}; use gettextrs::gettext; -use crate::{application::TvApplication, utils::show_error}; +use crate::{ + application::TvApplication, + utils::{show_error, spawn_clone}, +}; use super::{ExternalProgram, ExternalProgramType}; @@ -39,38 +43,127 @@ mod imp { confirm_button: TemplateChild, #[template_child] program_list: TemplateChild, + #[template_child] + custom_program_entry: TemplateChild, + #[template_child] + custom_program_validation_icon: TemplateChild, all_programs: RefCell>, pub(super) program_type: OnceCell, pub(super) program: RefCell>, pub(super) future_data: Rc)>>, + loaded: Cell, + } + + #[gtk::template_callbacks] + impl ProgramSelector { + #[template_callback] + async fn start_custom_program_input(&self, #[rest] _: &[glib::Value]) { + self.obj().set_selected_program(""); + } + #[template_callback] + async fn apply_custom_program(&self, entry: adw::EntryRow) { + self.obj().set_selected_program(&*entry.text()); + } } impl ProgramSelector { + async fn set_program(&self, id: Cow<'static, str>) { + if id.is_empty() { + *self.program.borrow_mut() = None; + self.confirm_button.set_sensitive(false); + self.custom_program_entry.set_css_classes(&[]); + self.custom_program_validation_icon.set_visible(false); + return; + } + + if let Some(program) = self + .all_programs + .borrow() + .iter() + .find(|program| program.id == id) + { + *self.program.borrow_mut() = Some(program.clone()); + self.confirm_button.set_sensitive(true); + self.custom_program_entry.set_text(""); + self.custom_program_entry.set_css_classes(&[]); + self.custom_program_validation_icon.set_visible(false); + return; + } + + if self.custom_program_entry.text().as_str() != id { + self.custom_program_entry.set_text(&id); + } + + match ExternalProgram::find("", id.clone()).await { + Ok(Some(program)) => { + *self.program.borrow_mut() = Some(program); + self.confirm_button.set_sensitive(true); + self.custom_program_entry.set_css_classes(&["success"]); + self.custom_program_validation_icon + .set_icon_name(Some("test-pass-symbolic")); + self.custom_program_validation_icon.set_tooltip_text(None); + self.custom_program_validation_icon.set_visible(true); + } + Ok(None) => { + *self.program.borrow_mut() = None; + self.confirm_button.set_sensitive(false); + self.custom_program_entry.set_css_classes(&["error"]); + self.custom_program_validation_icon + .set_icon_name(Some("error-symbolic")); + self.custom_program_validation_icon.set_tooltip_text(Some( + // translators: `{}` is replaced by the application ID, e.g. `org.example.Application` + &gettext("Could not find application “{}”").replace("{}", &id), + )); + self.custom_program_validation_icon.set_visible(true); + } + Err(e) => show_error(e), + } + } pub(super) async fn load(&self) { - match self - .program_type - .get() - .expect("program_type was not initialized") - .list() - .await + match ExternalProgram::find_known( + *self + .program_type + .get() + .expect("program_type was not initialized"), + ) + .await { Ok(programs) => { + self.program_list.remove(&*self.custom_program_entry); for program in &programs { let btn = gtk::CheckButton::builder() .action_name("program-selector.select-program") .action_target(&program.id.to_variant()) .build(); + + btn.connect_activate({ + let id = program.id.clone(); + + glib::clone!(@weak self as slf => move |_| { + spawn_clone!(slf, id => async { + slf.set_program(id).await + }) + }) + }); + let row = adw::ActionRow::builder() - .title(program.name) - .subtitle(program.id) + .title(&*program.name) + .subtitle(&*program.id) .activatable_widget(&btn) .build(); row.add_prefix(&btn); + self.program_list.add(&row); } + self.program_list.add(&*self.custom_program_entry); + self.obj().set_visible(true); *self.all_programs.borrow_mut() = programs; + self.loaded.set(true); + + let selected_program = self.selected_program.borrow().clone(); + self.set_program(selected_program.into()).await; } Err(e) => { let msg = gettext("Failed to load external applications"); @@ -94,6 +187,7 @@ mod imp { fn class_init(klass: &mut Self::Class) { klass.bind_template(); + klass.bind_template_callbacks(); klass.install_property_action("program-selector.select-program", "selected-program"); klass.install_action("program-selector.cancel", None, |slf, _, _| { @@ -129,26 +223,12 @@ mod imp { self.parent_constructed(); self.obj().connect_selected_program_notify(|slf| { - let selected_program = slf.selected_program(); - let slf = slf.imp(); - match slf - .all_programs - .borrow() - .iter() - .find(|program| program.id == selected_program) - { - Some(program) => { - *slf.program.borrow_mut() = Some(*program); - slf.confirm_button.set_sensitive(true); - } - None => show_error(eyre::Report::msg( - // translators: `{}` is replaced by given ID, a valid one would be e.g. `org.gnome.Totem` - gettext("Invalid program ID: “{}”").replace("{}", &selected_program), - )), + if slf.imp().loaded.get() { + spawn_clone!(slf => slf.imp().set_program(slf.selected_program().into())) } }); - self.obj().connect_close_request(move |slf| { + self.obj().connect_close_request(|slf| { let (selection, waker) = &mut *slf.imp().future_data.borrow_mut(); if *selection == ProgramSelection::Pending { *selection = ProgramSelection::Canceled; @@ -181,7 +261,10 @@ glib::wrapper! { } impl ProgramSelector { - pub async fn select_program(program_type: ExternalProgramType) -> Option { + pub async fn select_program( + program_type: ExternalProgramType, + initial_id: String, + ) -> Option { let application = TvApplication::get(); let parent = application.active_window()?; @@ -189,22 +272,26 @@ impl ProgramSelector { .property("modal", true) .property("application", application) .property("transient-for", parent) + .property("selected-program", initial_id) .build(); - match program_type { - ExternalProgramType::Player => { - slf.set_title(Some(&gettext("Select video player"))); - slf.set_description(gettext( - "Select one of the following external programs to stream content", - )); - } - ExternalProgramType::Downloader => { - slf.set_title(Some(&gettext("Select video downloader"))); - slf.set_description(gettext( - "Select one of the following external programs to download content", - )); - } - } + let (title, description) = match program_type { + ExternalProgramType::Player => ( + gettext("Select video player"), + gettext("Select one of the following external programs to stream content."), + ), + ExternalProgramType::Downloader => ( + gettext("Select video downloader"), + gettext("Select one of the following external programs to download content."), + ), + }; + slf.set_title(Some(&title)); + slf.set_description(format!( + "{description}\n{}", + gettext( + "You can also specify a custom application ID (e.g. org.example.Application) of a different program that supports DBus activation, is able to open a https:// URI and is accessible from the context of this application.", + ) + )); slf.imp() .program_type @@ -226,12 +313,12 @@ impl Future for ProgramSelectFuture { ) -> std::task::Poll { let (selection, waker) = &mut *self.0.borrow_mut(); - match *selection { + match selection { ProgramSelection::Pending => { *waker = Some(cx.waker().clone()); std::task::Poll::Pending } - ProgramSelection::Selected(program) => std::task::Poll::Ready(Some(program)), + ProgramSelection::Selected(program) => std::task::Poll::Ready(Some(program.clone())), ProgramSelection::Canceled => std::task::Poll::Ready(None), } } diff --git a/src/preferences.rs b/src/preferences.rs index 921bada..d1d9cda 100644 --- a/src/preferences.rs +++ b/src/preferences.rs @@ -31,22 +31,29 @@ mod imp { impl TvPreferencesWindow { #[template_callback] async fn select_video_player(&self, #[rest] _: &[glib::Value]) { - if let Some(program) = - ProgramSelector::select_program(ExternalProgramType::Player).await + if let Some(player) = ProgramSelector::select_program( + ExternalProgramType::Player, + self.settings.video_player_id(), + ) + .await { - self.settings.set_video_player_name(program.name); - self.settings.set_video_player_id(program.id); + self.settings.set_video_player_name(&player.name); + self.settings.set_video_player_id(&player.id); } } } impl TvPreferencesWindow { fn update_video_player_display_name(&self) { - *self.video_player_display_name.borrow_mut() = format!( - "{} ({})", - self.settings.video_player_name(), - self.settings.video_player_id() - ); + let name = self.settings.video_player_name(); + let id = self.settings.video_player_id(); + + *self.video_player_display_name.borrow_mut() = if name.is_empty() { + id + } else { + format!("{name} ({id})",) + }; + self.obj().notify_video_player_display_name(); } }