From 7f9d56ae0cda541c45a26fd7cea5da4a0121340c Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 5 Dec 2024 12:20:37 -0700 Subject: [PATCH] Add file menu, improve naming of subtitles --- Cargo.lock | 36 +++++++-- Cargo.toml | 4 +- i18n/en/cosmic_player.ftl | 9 +++ src/key_bind.rs | 45 +---------- src/main.rs | 155 ++++++++++++++++++++++++++++++++------ src/menu.rs | 33 ++++++++ 6 files changed, 210 insertions(+), 72 deletions(-) create mode 100644 src/menu.rs diff --git a/Cargo.lock b/Cargo.lock index 545986e..2b8c556 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1071,6 +1071,7 @@ name = "cosmic-player" version = "0.1.0" dependencies = [ "env_logger", + "gstreamer-tag", "i18n-embed", "i18n-embed-fl", "iced_video_player", @@ -1096,7 +1097,7 @@ dependencies = [ "rayon", "rustc-hash", "rustybuzz 0.14.1", - "self_cell 1.0.4", + "self_cell 1.1.0", "smol_str", "swash", "sys-locale", @@ -2301,6 +2302,31 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gstreamer-tag" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "089398382ac684c23c676a2c17c3da611e58ac022e9d6b6ed225eab5b5a97c24" +dependencies = [ + "glib", + "gstreamer", + "gstreamer-tag-sys", + "libc", +] + +[[package]] +name = "gstreamer-tag-sys" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c554d84f8e29aa2bae07b1ce00a79d2ea0a8e38b55c5cacc4411221955c0dbe1" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + [[package]] name = "gstreamer-video" version = "0.23.3" @@ -2670,7 +2696,7 @@ dependencies = [ [[package]] name = "iced_video_player" version = "0.6.0" -source = "git+https://github.com/jackpot51/iced_video_player.git?branch=prev-cosmic#cb70dc44be8d04b323d749322d06a2dd8a659e68" +source = "git+https://github.com/jackpot51/iced_video_player.git?branch=prev-cosmic#c6adbcd70c518ad6ae418f4e6f42ce68df586001" dependencies = [ "glib", "gstreamer", @@ -4560,14 +4586,14 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" dependencies = [ - "self_cell 1.0.4", + "self_cell 1.1.0", ] [[package]] name = "self_cell" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d369a96f978623eb3dc28807c4852d6cc617fed53da5d3c400feff1ef34a714a" +checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe" [[package]] name = "serde" diff --git a/Cargo.toml b/Cargo.toml index 57b57bb..b37c070 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +gstreamer-tag = "0.23" lazy_static = "1" serde = { version = "1", features = ["serde_derive"] } tokio = "1" @@ -32,7 +33,8 @@ version = "0.2.1" features = ["serde"] [features] -default = ["wgpu"] +default = ["xdg-portal", "wgpu"] +xdg-portal = ["libcosmic/xdg-portal"] wgpu = ["iced_video_player/wgpu", "libcosmic/wgpu"] [profile.release-with-debug] diff --git a/i18n/en/cosmic_player.ftl b/i18n/en/cosmic_player.ftl index 30938f9..11aee3d 100644 --- a/i18n/en/cosmic_player.ftl +++ b/i18n/en/cosmic_player.ftl @@ -9,3 +9,12 @@ theme = Theme match-desktop = Match desktop dark = Dark light = Light + +# Menu + +## File +file = File +open-media = Open media... +open-recent-media = Open recent media +close-file = Close file +quit = Quit \ No newline at end of file diff --git a/src/key_bind.rs b/src/key_bind.rs index 4d4ae22..daa42e9 100644 --- a/src/key_bind.rs +++ b/src/key_bind.rs @@ -1,48 +1,9 @@ -use cosmic::{ - iced::keyboard::{Key, Modifiers}, - iced_core::keyboard::key::Named, -}; -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fmt}; +use cosmic::{iced::keyboard::Key, iced_core::keyboard::key::Named}; +use std::collections::HashMap; use crate::Action; -#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] -pub enum Modifier { - Super, - Ctrl, - Alt, - Shift, -} - -#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] -pub struct KeyBind { - pub modifiers: Vec, - pub key: Key, -} - -impl KeyBind { - pub fn matches(&self, modifiers: Modifiers, key: &Key) -> bool { - key == &self.key - && modifiers.logo() == self.modifiers.contains(&Modifier::Super) - && modifiers.control() == self.modifiers.contains(&Modifier::Ctrl) - && modifiers.alt() == self.modifiers.contains(&Modifier::Alt) - && modifiers.shift() == self.modifiers.contains(&Modifier::Shift) - } -} - -impl fmt::Display for KeyBind { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - for modifier in self.modifiers.iter() { - write!(f, "{:?} + ", modifier)?; - } - match &self.key { - Key::Character(c) => write!(f, "{}", c.to_uppercase()), - Key::Named(named) => write!(f, "{:?}", named), - other => write!(f, "{:?}", other), - } - } -} +pub use cosmic::widget::menu::key_bind::{KeyBind, Modifier}; //TODO: load from config pub fn key_binds() -> HashMap { diff --git a/src/main.rs b/src/main.rs index a1dd986..5a2133c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,17 +13,18 @@ use cosmic::{ window, Alignment, Color, Length, Limits, }, theme, - widget::{self, Slider}, + widget::{self, menu::action::MenuAction, Slider}, Application, ApplicationExt, Element, }; use iced_video_player::{ gst::{self, prelude::*}, - gst_pbutils, Video, VideoPlayer, + gst_app, gst_pbutils, Video, VideoPlayer, }; use std::{ any::TypeId, collections::HashMap, - fs, + ffi::{CStr, CString}, + fs, process, time::{Duration, Instant}, }; @@ -35,6 +36,7 @@ use crate::{ mod config; mod key_bind; mod localize; +mod menu; static CONTROLS_TIMEOUT: Duration = Duration::new(2, 0); @@ -42,6 +44,20 @@ const GST_PLAY_FLAG_VIDEO: i32 = 1 << 0; const GST_PLAY_FLAG_AUDIO: i32 = 1 << 1; const GST_PLAY_FLAG_TEXT: i32 = 1 << 2; +fn language_name(code: &str) -> Option { + let code_c = CString::new(code).ok()?; + let name_c = unsafe { + //TODO: export this in gstreamer_tag + let name_ptr = gstreamer_tag::ffi::gst_tag_get_language_name(code_c.as_ptr()); + if name_ptr.is_null() { + return None; + } + CStr::from_ptr(name_ptr) + }; + let name = name_c.to_str().ok()?; + Some(name.to_string()) +} + /// Runs application with these settings #[rustfmt::skip] pub fn main() -> Result<(), Box> { @@ -102,19 +118,27 @@ pub fn main() -> Result<(), Box> { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Action { + FileClose, + FileOpen, Fullscreen, PlayPause, SeekBackward, SeekForward, + WindowClose, } -impl Action { - pub fn message(&self) -> Message { +impl MenuAction for Action { + type Message = Message; + + fn message(&self) -> Message { match self { + Self::FileClose => Message::FileClose, + Self::FileOpen => Message::FileOpen, Self::Fullscreen => Message::Fullscreen, Self::PlayPause => Message::PlayPause, Self::SeekBackward => Message::SeekRelative(-10.0), Self::SeekForward => Message::SeekRelative(10.0), + Self::WindowClose => Message::WindowClose, } } } @@ -130,6 +154,9 @@ pub struct Flags { #[derive(Clone, Debug)] pub enum Message { Config(Config), + FileClose, + FileLoad(url::Url), + FileOpen, Fullscreen, Key(Modifiers, Key), AudioCode(usize), @@ -144,6 +171,7 @@ pub enum Message { Reload, ShowControls, SystemThemeModeChange(cosmic_theme::ThemeMode), + WindowClose, } /// The [`App`] stores application-specific state. @@ -166,7 +194,14 @@ pub struct App { impl App { fn close(&mut self) { - self.video_opt = None; + //TODO: drop does not work well + if let Some(mut video) = self.video_opt.take() { + log::info!("pausing video"); + video.set_paused(true); + log::info!("dropping video"); + drop(video); + log::info!("dropped video"); + } self.position = 0.0; self.duration = 0.0; self.dragging = false; @@ -184,13 +219,44 @@ impl App { None => return Command::none(), }; - let video = match Video::new(&url) { - Ok(ok) => ok, - Err(err) => { - log::warn!("failed to open {:?}: {err}", url); - return Command::none(); + log::info!("Loading {}", url); + + //TODO: this code came from iced_video_player::Video::new and has been modified to stop the pipeline on error + //TODO: remove unwraps and enable playback of files with only audio. + let video = { + gst::init().unwrap(); + + let pipeline = format!( + "playbin uri=\"{}\" video-sink=\"videoscale ! videoconvert ! appsink name=iced_video drop=true caps=video/x-raw,format=NV12,pixel-aspect-ratio=1/1\"", + url.as_str() + ); + let pipeline = gst::parse::launch(pipeline.as_ref()) + .unwrap() + .downcast::() + .map_err(|_| iced_video_player::Error::Cast) + .unwrap(); + + let video_sink: gst::Element = pipeline.property("video-sink"); + let pad = video_sink.pads().first().cloned().unwrap(); + let pad = pad.dynamic_cast::().unwrap(); + let bin = pad + .parent_element() + .unwrap() + .downcast::() + .unwrap(); + let video_sink = bin.by_name("iced_video").unwrap(); + let video_sink = video_sink.downcast::().unwrap(); + + match Video::from_gst_pipeline(pipeline.clone(), video_sink, None) { + Ok(ok) => ok, + Err(err) => { + log::warn!("failed to open {}: {err}", url); + pipeline.set_state(gst::State::Null).unwrap(); + return Command::none(); + } } }; + self.duration = video.duration().as_secs_f64(); let pipeline = video.pipeline(); self.video_opt = Some(video); @@ -200,13 +266,15 @@ impl App { for i in 0..n_audio { let tags: gst::TagList = pipeline.emit_by_name("get-audio-tags", &[&i]); log::info!("audio stream {i}: {tags:?}"); - self.audio_codes.push( - if let Some(language_code) = tags.get::() { - language_code.get().to_string() + self.audio_codes + .push(if let Some(title) = tags.get::() { + title.get().to_string() + } else if let Some(language_code) = tags.get::() { + let language_code = language_code.get(); + language_name(language_code).unwrap_or_else(|| language_code.to_string()) } else { format!("Audio #{i}") - }, - ); + }); } self.current_audio = pipeline.property::("current-audio"); @@ -215,13 +283,15 @@ impl App { for i in 0..n_text { let tags: gst::TagList = pipeline.emit_by_name("get-text-tags", &[&i]); log::info!("text stream {i}: {tags:?}"); - self.text_codes.push( - if let Some(language_code) = tags.get::() { - language_code.get().to_string() + self.text_codes + .push(if let Some(title) = tags.get::() { + title.get().to_string() + } else if let Some(language_code) = tags.get::() { + let language_code = language_code.get(); + language_name(language_code).unwrap_or_else(|| language_code.to_string()) } else { format!("Subtitle #{i}") - }, - ); + }); } self.current_text = pipeline.property::("current-text"); @@ -337,6 +407,33 @@ impl Application for App { return self.update_config(); } } + Message::FileClose => { + self.close(); + } + Message::FileLoad(url) => { + self.flags.url_opt = Some(url); + return self.load(); + } + Message::FileOpen => { + //TODO: embed cosmic-files dialog (after libcosmic rebase works) + #[cfg(feature = "xdg-portal")] + return Command::perform( + async move { + let dialog = cosmic::dialog::file_chooser::open::Dialog::new() + .title(fl!("open-media")); + match dialog.open_file().await { + Ok(response) => { + message::app(Message::FileLoad(response.url().to_owned())) + } + Err(err) => { + log::warn!("failed to open file: {}", err); + message::none() + } + } + }, + |x| x, + ); + } Message::Fullscreen => { self.fullscreen = !self.fullscreen; self.core.window.show_headerbar = !self.fullscreen; @@ -463,14 +560,18 @@ impl Application for App { Message::SystemThemeModeChange(_theme_mode) => { return self.update_config(); } + Message::WindowClose => { + process::exit(0); + } } Command::none() } fn header_start(&self) -> Vec> { - let mut row = widget::row::with_capacity(4) + let mut row = widget::row::with_capacity(5) .align_items(Alignment::Center) .spacing(8); + row = row.push(menu::menu_bar(&self.flags.config, &self.key_binds)); if !self.audio_codes.is_empty() { //TODO: allow mute/unmute/change volume row = row.push(widget::icon::from_name("audio-volume-high-symbolic").size(16)); @@ -494,6 +595,12 @@ impl Application for App { /// Creates a view after each update. fn view(&self) -> Element { + let cosmic_theme::Spacing { + space_xxs, + space_xs, + .. + } = theme::active().cosmic().spacing; + let format_time = |time_float: f64| -> String { let time = time_float.floor() as i64; let seconds = time % 60; @@ -527,8 +634,7 @@ impl Application for App { widget::container( widget::row::with_capacity(5) .align_items(Alignment::Center) - .spacing(8) - .padding([0, 8]) + .spacing(space_xxs) .push( widget::button::icon( if self.video_opt.as_ref().map_or(true, |video| video.paused()) { @@ -558,6 +664,7 @@ impl Application for App { .on_press(Message::Fullscreen), ), ) + .padding([space_xxs, space_xs]) .style(theme::Container::WindowBackground), ); } diff --git a/src/menu.rs b/src/menu.rs new file mode 100644 index 0000000..db79bca --- /dev/null +++ b/src/menu.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::widget::menu::key_bind::KeyBind; +use cosmic::widget::menu::{items as menu_items, root as menu_root, Item as MenuItem}; +use cosmic::{ + widget::menu::{ItemHeight, ItemWidth, MenuBar, Tree as MenuTree}, + Element, +}; +use std::collections::HashMap; + +use crate::{fl, Action, Config, Message}; + +pub fn menu_bar<'a>(config: &Config, key_binds: &HashMap) -> Element<'a, Message> { + let mut recent_items = Vec::new(); + + MenuBar::new(vec![MenuTree::with_children( + menu_root(fl!("file")), + menu_items( + key_binds, + vec![ + MenuItem::Button(fl!("open-media"), Action::FileOpen), + MenuItem::Folder(fl!("open-recent-media"), recent_items), + MenuItem::Button(fl!("close-file"), Action::FileClose), + MenuItem::Divider, + MenuItem::Button(fl!("quit"), Action::WindowClose), + ], + ), + )]) + .item_height(ItemHeight::Dynamic(40)) + .item_width(ItemWidth::Uniform(240)) + .spacing(4.0) + .into() +}