Split into ffmpeg and gstreamer implementations
This commit is contained in:
parent
52b9439ca4
commit
17ce907f28
11 changed files with 1761 additions and 1245 deletions
1808
Cargo.lock
generated
1808
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
12
Cargo.toml
12
Cargo.toml
|
|
@ -5,11 +5,12 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
cpal = "0.15"
|
||||
ffmpeg-next = "6"
|
||||
ffmpeg-next = { version = "6", optional = true }
|
||||
lazy_static = "1"
|
||||
paste = "1"
|
||||
serde = { version = "1", features = ["serde_derive"] }
|
||||
tokio = "1"
|
||||
url = "2"
|
||||
# Internationalization
|
||||
i18n-embed = { version = "0.13", features = ["fluent-system", "desktop-requester"] }
|
||||
i18n-embed-fl = "0.6"
|
||||
|
|
@ -19,6 +20,11 @@ env_logger = "0.10"
|
|||
log = "0.4"
|
||||
lexopt = "0.3"
|
||||
|
||||
[dependencies.iced_video_player]
|
||||
git = "https://github.com/jackpot51/iced_video_player.git"
|
||||
branch = "cosmic"
|
||||
optional = true
|
||||
|
||||
[dependencies.libcosmic]
|
||||
git = "https://github.com/pop-os/libcosmic.git"
|
||||
default-features = false
|
||||
|
|
@ -30,7 +36,9 @@ version = "0.2.1"
|
|||
features = ["serde"]
|
||||
|
||||
[features]
|
||||
default = ["wgpu"]
|
||||
default = ["gstreamer", "wgpu"]
|
||||
ffmpeg = ["dep:ffmpeg-next"]
|
||||
gstreamer = ["dep:iced_video_player", "wgpu"]
|
||||
wgpu = ["libcosmic/wgpu"]
|
||||
|
||||
[profile.release-with-debug]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ use lexopt::prelude::*;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::{path::PathBuf, process};
|
||||
|
||||
use crate::hardware::DeviceType;
|
||||
#[cfg(feature = "ffmpeg")]
|
||||
use crate::app::hardware::DeviceType;
|
||||
|
||||
pub const CONFIG_VERSION: u64 = 1;
|
||||
|
||||
|
|
@ -30,8 +31,10 @@ impl AppTheme {
|
|||
}
|
||||
|
||||
#[derive(Clone, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub app_theme: AppTheme,
|
||||
#[cfg(feature = "ffmpeg")]
|
||||
pub hw_decoder: DeviceType,
|
||||
}
|
||||
|
||||
|
|
@ -39,11 +42,13 @@ impl Default for Config {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
app_theme: AppTheme::System,
|
||||
#[cfg(feature = "ffmpeg")]
|
||||
hw_decoder: DeviceType::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffmpeg")]
|
||||
impl Config {
|
||||
pub fn with_args(&mut self, args: &mut Args) {
|
||||
if let Some(decoder) = args.decoder {
|
||||
|
|
@ -52,11 +57,13 @@ impl Config {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffmpeg")]
|
||||
pub struct Args {
|
||||
pub paths: Vec<PathBuf>,
|
||||
pub decoder: Option<DeviceType>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffmpeg")]
|
||||
impl Args {
|
||||
pub fn parse_args() -> Result<Self, lexopt::Error> {
|
||||
let mut paths = Vec::new();
|
||||
|
|
|
|||
439
src/ffmpeg/mod.rs
Normal file
439
src/ffmpeg/mod.rs
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
// Copyright 2023 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use cosmic::{
|
||||
app::{message, Command, Core, Settings},
|
||||
cosmic_config::{self, CosmicConfigEntry},
|
||||
cosmic_theme, executor,
|
||||
iced::{
|
||||
event::{self, Event},
|
||||
keyboard::{Event as KeyEvent, Key, Modifiers},
|
||||
subscription::{self, Subscription},
|
||||
window, Alignment, Length, Limits,
|
||||
},
|
||||
widget, Application, ApplicationExt, Element,
|
||||
};
|
||||
use std::{
|
||||
any::TypeId,
|
||||
collections::HashMap,
|
||||
env, process,
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{AppTheme, Args, Config, CONFIG_VERSION},
|
||||
fl,
|
||||
key_bind::{key_binds, KeyBind},
|
||||
localize,
|
||||
};
|
||||
|
||||
pub mod hardware;
|
||||
|
||||
mod player;
|
||||
use player::{PlayerMessage, VideoFrame, VideoQueue};
|
||||
|
||||
/// Runs application with these settings
|
||||
#[rustfmt::skip]
|
||||
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||
|
||||
localize::localize();
|
||||
|
||||
let mut args = match Args::parse_args() {
|
||||
Ok(args) => args,
|
||||
Err(e) => {
|
||||
log::error!("{e}");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let (config_handler, config) = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) {
|
||||
Ok(config_handler) => {
|
||||
let mut config = match Config::get_entry(&config_handler) {
|
||||
Ok(ok) => ok,
|
||||
Err((errs, config)) => {
|
||||
log::info!("errors loading config: {:?}", errs);
|
||||
config
|
||||
}
|
||||
};
|
||||
// Update config with command line args
|
||||
config.with_args(&mut args);
|
||||
(Some(config_handler), config)
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("failed to create config handler: {}", err);
|
||||
(None, Config::default())
|
||||
}
|
||||
};
|
||||
|
||||
//TODO: support using multiple paths
|
||||
let Args { mut paths, .. } = args;
|
||||
let path = paths.pop().unwrap();
|
||||
|
||||
// TODO: Update video player config when it's updated via the app
|
||||
let (player_tx, video_queue_lock) = player::run(path, config.clone());
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings = settings.theme(config.app_theme.theme());
|
||||
settings = settings.size_limits(Limits::NONE.min_width(360.0).min_height(180.0));
|
||||
|
||||
let flags = Flags {
|
||||
config_handler,
|
||||
config,
|
||||
player_tx,
|
||||
video_queue_lock,
|
||||
};
|
||||
cosmic::app::run::<App>(settings, flags)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Action {
|
||||
Todo,
|
||||
SeekBackward,
|
||||
SeekForward,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn message(&self) -> Message {
|
||||
match self {
|
||||
Self::Todo => Message::Todo,
|
||||
Self::SeekBackward => Message::Player(PlayerMessage::SeekRelative(-10.0)),
|
||||
Self::SeekForward => Message::Player(PlayerMessage::SeekRelative(10.0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Flags {
|
||||
config_handler: Option<cosmic_config::Config>,
|
||||
config: Config,
|
||||
player_tx: mpsc::Sender<PlayerMessage>,
|
||||
video_queue_lock: Arc<Mutex<VideoQueue>>,
|
||||
}
|
||||
|
||||
/// Messages that are used specifically by our [`App`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Message {
|
||||
Todo,
|
||||
AppTheme(AppTheme),
|
||||
Config(Config),
|
||||
Key(Modifiers, Key),
|
||||
Player(PlayerMessage),
|
||||
SystemThemeModeChange(cosmic_theme::ThemeMode),
|
||||
Tick(Instant),
|
||||
ToggleContextPage(ContextPage),
|
||||
WindowClose,
|
||||
WindowNew,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ContextPage {
|
||||
Settings,
|
||||
}
|
||||
|
||||
impl ContextPage {
|
||||
fn title(&self) -> String {
|
||||
match self {
|
||||
Self::Settings => fl!("settings"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`App`] stores application-specific state.
|
||||
pub struct App {
|
||||
core: Core,
|
||||
flags: Flags,
|
||||
app_themes: Vec<String>,
|
||||
context_page: ContextPage,
|
||||
key_binds: HashMap<KeyBind, Action>,
|
||||
handle_opt: Option<widget::image::Handle>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn update_config(&mut self) -> Command<Message> {
|
||||
cosmic::app::command::set_theme(self.flags.config.app_theme.theme())
|
||||
}
|
||||
|
||||
fn update_title(&mut self) -> Command<Message> {
|
||||
let title = "COSMIC Media Player";
|
||||
self.set_header_title(title.to_string());
|
||||
self.set_window_title(title.to_string())
|
||||
}
|
||||
|
||||
fn settings(&self) -> Element<Message> {
|
||||
let app_theme_selected = match self.flags.config.app_theme {
|
||||
AppTheme::Dark => 1,
|
||||
AppTheme::Light => 2,
|
||||
AppTheme::System => 0,
|
||||
};
|
||||
widget::settings::view_column(vec![widget::settings::view_section(fl!("appearance"))
|
||||
.add(
|
||||
widget::settings::item::builder(fl!("theme")).control(widget::dropdown(
|
||||
&self.app_themes,
|
||||
Some(app_theme_selected),
|
||||
move |index| {
|
||||
Message::AppTheme(match index {
|
||||
1 => AppTheme::Dark,
|
||||
2 => AppTheme::Light,
|
||||
_ => AppTheme::System,
|
||||
})
|
||||
},
|
||||
)),
|
||||
)
|
||||
.into()])
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement [`Application`] to integrate with COSMIC.
|
||||
impl Application for App {
|
||||
/// Default async executor to use with the app.
|
||||
type Executor = executor::Default;
|
||||
|
||||
/// Argument received
|
||||
type Flags = Flags;
|
||||
|
||||
/// Message type specific to our [`App`].
|
||||
type Message = Message;
|
||||
|
||||
/// The unique application ID to supply to the window manager.
|
||||
const APP_ID: &'static str = "com.system76.CosmicPlayer";
|
||||
|
||||
fn core(&self) -> &Core {
|
||||
&self.core
|
||||
}
|
||||
|
||||
fn core_mut(&mut self) -> &mut Core {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
/// Creates the application, and optionally emits command on initialize.
|
||||
fn init(core: Core, flags: Self::Flags) -> (Self, Command<Self::Message>) {
|
||||
let app_themes = vec![fl!("match-desktop"), fl!("dark"), fl!("light")];
|
||||
let mut app = App {
|
||||
core,
|
||||
flags,
|
||||
app_themes,
|
||||
context_page: ContextPage::Settings,
|
||||
key_binds: key_binds(),
|
||||
handle_opt: None,
|
||||
};
|
||||
|
||||
let command = app.update_title();
|
||||
(app, command)
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> Command<Message> {
|
||||
if self.core.window.show_context {
|
||||
// Close context drawer if open
|
||||
self.core.window.show_context = false;
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
|
||||
/// Handle application events here.
|
||||
fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
|
||||
// Helper for updating config values efficiently
|
||||
macro_rules! config_set {
|
||||
($name: ident, $value: expr) => {
|
||||
match &self.flags.config_handler {
|
||||
Some(config_handler) => {
|
||||
match paste::paste! { self.flags.config.[<set_ $name>](config_handler, $value) } {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"failed to save config {:?}: {}",
|
||||
stringify!($name),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.flags.config.$name = $value;
|
||||
log::warn!(
|
||||
"failed to save config {:?}: no config handler",
|
||||
stringify!($name)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match message {
|
||||
Message::Todo => {
|
||||
log::warn!("TODO");
|
||||
}
|
||||
Message::AppTheme(app_theme) => {
|
||||
config_set!(app_theme, app_theme);
|
||||
return self.update_config();
|
||||
}
|
||||
Message::Config(config) => {
|
||||
if config != self.flags.config {
|
||||
log::info!("update config");
|
||||
//TODO: update syntax theme by clearing tabs, only if needed
|
||||
self.flags.config = config;
|
||||
return self.update_config();
|
||||
}
|
||||
}
|
||||
Message::Key(modifiers, key) => {
|
||||
for (key_bind, action) in self.key_binds.iter() {
|
||||
if key_bind.matches(modifiers, &key) {
|
||||
return self.update(action.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Player(player_message) => {
|
||||
self.flags.player_tx.send(player_message).unwrap();
|
||||
}
|
||||
Message::SystemThemeModeChange(_theme_mode) => {
|
||||
return self.update_config();
|
||||
}
|
||||
Message::Tick(frame_time) => {
|
||||
let start = Instant::now();
|
||||
|
||||
let mut video_frame_opt: Option<VideoFrame> = None;
|
||||
let delayed_time = {
|
||||
let mut video_queue = self.flags.video_queue_lock.lock().unwrap();
|
||||
let delayed_time = frame_time - video_queue.delay;
|
||||
while let Some(video_frame) = video_queue.data.pop_front() {
|
||||
if video_frame.1.unwrap_or(delayed_time) <= delayed_time {
|
||||
if let Some(old_frame) = video_frame_opt {
|
||||
//TODO: log this outside of locking video_queue_lock?
|
||||
log::warn!("skipping video frame {:?}", old_frame.0.pts());
|
||||
}
|
||||
// Frame is ready to be shown
|
||||
video_frame_opt = Some(video_frame);
|
||||
} else {
|
||||
// Put frame back and exit loop
|
||||
video_queue.data.push_front(video_frame);
|
||||
break;
|
||||
}
|
||||
}
|
||||
delayed_time
|
||||
};
|
||||
|
||||
match video_frame_opt {
|
||||
Some(video_frame) => {
|
||||
let pts = video_frame.0.pts();
|
||||
let present_time_opt = video_frame.1;
|
||||
self.handle_opt = Some(video_frame.into_handle());
|
||||
|
||||
let duration = start.elapsed();
|
||||
log::debug!(
|
||||
"converted video frame at {:?} to handle in {:?}",
|
||||
pts,
|
||||
duration
|
||||
);
|
||||
|
||||
if let Some(present_time) = present_time_opt {
|
||||
if present_time > delayed_time {
|
||||
let ahead = present_time - delayed_time;
|
||||
if ahead > Duration::from_millis(1) {
|
||||
log::debug!("video ahead {:?}", ahead);
|
||||
}
|
||||
} else {
|
||||
let behind = delayed_time - present_time;
|
||||
if behind > Duration::from_millis(1) {
|
||||
log::debug!("video behind {:?}", behind);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Message::ToggleContextPage(context_page) => {
|
||||
//TODO: ensure context menus are closed
|
||||
if self.context_page == context_page {
|
||||
self.core.window.show_context = !self.core.window.show_context;
|
||||
} else {
|
||||
self.context_page = context_page;
|
||||
self.core.window.show_context = true;
|
||||
}
|
||||
self.set_context_title(context_page.title());
|
||||
}
|
||||
Message::WindowClose => {
|
||||
return window::close(window::Id::MAIN);
|
||||
}
|
||||
Message::WindowNew => match env::current_exe() {
|
||||
Ok(exe) => match process::Command::new(&exe).spawn() {
|
||||
Ok(_child) => {}
|
||||
Err(err) => {
|
||||
log::error!("failed to execute {:?}: {}", exe, err);
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
log::error!("failed to get current executable path: {}", err);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Command::none()
|
||||
}
|
||||
|
||||
fn context_drawer(&self) -> Option<Element<Message>> {
|
||||
if !self.core.window.show_context {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(match self.context_page {
|
||||
ContextPage::Settings => self.settings(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a view after each update.
|
||||
fn view(&self) -> Element<Self::Message> {
|
||||
let content: Element<_> = match &self.handle_opt {
|
||||
Some(handle) => widget::image(handle.clone())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into(),
|
||||
None => widget::text("Loading").into(),
|
||||
};
|
||||
|
||||
// Uncomment to debug layout:
|
||||
//content.explain(cosmic::iced::Color::WHITE)
|
||||
content
|
||||
}
|
||||
|
||||
fn subscription(&self) -> Subscription<Self::Message> {
|
||||
struct ConfigSubscription;
|
||||
struct ThemeSubscription;
|
||||
|
||||
Subscription::batch([
|
||||
window::frames().map(|(_window_id, instant)| Message::Tick(instant)),
|
||||
event::listen_with(|event, _status| match event {
|
||||
Event::Keyboard(KeyEvent::KeyPressed { key, modifiers, .. }) => {
|
||||
Some(Message::Key(modifiers, key))
|
||||
}
|
||||
_ => None,
|
||||
}),
|
||||
cosmic_config::config_subscription(
|
||||
TypeId::of::<ConfigSubscription>(),
|
||||
Self::APP_ID.into(),
|
||||
CONFIG_VERSION,
|
||||
)
|
||||
.map(|update| {
|
||||
if !update.errors.is_empty() {
|
||||
log::debug!("errors loading config: {:?}", update.errors);
|
||||
}
|
||||
Message::SystemThemeModeChange(update.config)
|
||||
}),
|
||||
cosmic_config::config_subscription::<_, cosmic_theme::ThemeMode>(
|
||||
TypeId::of::<ThemeSubscription>(),
|
||||
cosmic_theme::THEME_MODE_ID.into(),
|
||||
cosmic_theme::ThemeMode::version(),
|
||||
)
|
||||
.map(|update| {
|
||||
if !update.errors.is_empty() {
|
||||
log::debug!("errors loading theme mode: {:?}", update.errors);
|
||||
}
|
||||
Message::SystemThemeModeChange(update.config)
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
300
src/gstreamer/mod.rs
Normal file
300
src/gstreamer/mod.rs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
// Copyright 2023 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use cosmic::{
|
||||
app::{Command, Core, Settings},
|
||||
cosmic_config::{self, CosmicConfigEntry},
|
||||
cosmic_theme, executor,
|
||||
iced::{
|
||||
event::{self, Event},
|
||||
keyboard::{Event as KeyEvent, Key, Modifiers},
|
||||
subscription::{self, Subscription},
|
||||
Length, Limits, Size,
|
||||
},
|
||||
widget::{self, Column, Row, Slider},
|
||||
Application, ApplicationExt, Element,
|
||||
};
|
||||
use iced_video_player::{Video, VideoPlayer};
|
||||
use std::{any::TypeId, collections::HashMap, time::Duration};
|
||||
|
||||
use crate::{
|
||||
config::{Config, CONFIG_VERSION},
|
||||
key_bind::{key_binds, KeyBind},
|
||||
localize,
|
||||
};
|
||||
|
||||
/// Runs application with these settings
|
||||
#[rustfmt::skip]
|
||||
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||
|
||||
localize::localize();
|
||||
|
||||
let (config_handler, config) = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) {
|
||||
Ok(config_handler) => {
|
||||
let config = match Config::get_entry(&config_handler) {
|
||||
Ok(ok) => ok,
|
||||
Err((errs, config)) => {
|
||||
log::info!("errors loading config: {:?}", errs);
|
||||
config
|
||||
}
|
||||
};
|
||||
(Some(config_handler), config)
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("failed to create config handler: {}", err);
|
||||
(None, Config::default())
|
||||
}
|
||||
};
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings = settings.theme(config.app_theme.theme());
|
||||
settings = settings.size_limits(Limits::NONE.min_width(360.0).min_height(180.0));
|
||||
|
||||
let url = url::Url::from_file_path(
|
||||
std::env::args().nth(1).unwrap()
|
||||
)
|
||||
.unwrap();
|
||||
let flags = Flags {
|
||||
config_handler,
|
||||
config,
|
||||
url,
|
||||
};
|
||||
cosmic::app::run::<App>(settings, flags)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Action {
|
||||
SeekBackward,
|
||||
SeekForward,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn message(&self) -> Message {
|
||||
match self {
|
||||
Self::SeekBackward => Message::SeekRelative(-10.0),
|
||||
Self::SeekForward => Message::SeekRelative(10.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Flags {
|
||||
config_handler: Option<cosmic_config::Config>,
|
||||
config: Config,
|
||||
url: url::Url,
|
||||
}
|
||||
|
||||
/// Messages that are used specifically by our [`App`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Message {
|
||||
Config(Config),
|
||||
Key(Modifiers, Key),
|
||||
TogglePause,
|
||||
ToggleLoop,
|
||||
Seek(f64),
|
||||
SeekRelative(f64),
|
||||
SeekRelease,
|
||||
EndOfStream,
|
||||
NewFrame,
|
||||
SystemThemeModeChange(cosmic_theme::ThemeMode),
|
||||
}
|
||||
|
||||
/// The [`App`] stores application-specific state.
|
||||
pub struct App {
|
||||
core: Core,
|
||||
flags: Flags,
|
||||
key_binds: HashMap<KeyBind, Action>,
|
||||
video: Video,
|
||||
position: f64,
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn update_config(&mut self) -> Command<Message> {
|
||||
cosmic::app::command::set_theme(self.flags.config.app_theme.theme())
|
||||
}
|
||||
|
||||
fn update_title(&mut self) -> Command<Message> {
|
||||
let title = "COSMIC Media Player";
|
||||
self.set_header_title(title.to_string());
|
||||
self.set_window_title(title.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement [`cosmic::Application`] to integrate with COSMIC.
|
||||
impl Application for App {
|
||||
/// Default async executor to use with the app.
|
||||
type Executor = executor::Default;
|
||||
|
||||
/// Argument received [`cosmic::Application::new`].
|
||||
type Flags = Flags;
|
||||
|
||||
/// Message type specific to our [`App`].
|
||||
type Message = Message;
|
||||
|
||||
/// The unique application ID to supply to the window manager.
|
||||
const APP_ID: &'static str = "com.system76.CosmicPlayer";
|
||||
|
||||
fn core(&self) -> &Core {
|
||||
&self.core
|
||||
}
|
||||
|
||||
fn core_mut(&mut self) -> &mut Core {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
/// Creates the application, and optionally emits command on initialize.
|
||||
fn init(core: Core, flags: Self::Flags) -> (Self, Command<Self::Message>) {
|
||||
let video = Video::new(&flags.url).unwrap();
|
||||
let mut app = App {
|
||||
core,
|
||||
flags,
|
||||
key_binds: key_binds(),
|
||||
video,
|
||||
position: 0.0,
|
||||
dragging: false,
|
||||
};
|
||||
let command = app.update_title();
|
||||
(app, command)
|
||||
}
|
||||
|
||||
/// Handle application events here.
|
||||
fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
|
||||
match message {
|
||||
Message::Config(config) => {
|
||||
if config != self.flags.config {
|
||||
log::info!("update config");
|
||||
self.flags.config = config;
|
||||
return self.update_config();
|
||||
}
|
||||
}
|
||||
Message::Key(modifiers, key) => {
|
||||
for (key_bind, action) in self.key_binds.iter() {
|
||||
if key_bind.matches(modifiers, &key) {
|
||||
return self.update(action.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::TogglePause => {
|
||||
self.video.set_paused(!self.video.paused());
|
||||
}
|
||||
Message::ToggleLoop => {
|
||||
self.video.set_looping(!self.video.looping());
|
||||
}
|
||||
Message::Seek(secs) => {
|
||||
self.dragging = true;
|
||||
self.position = secs;
|
||||
self.video
|
||||
.seek(Duration::from_secs_f64(self.position), false)
|
||||
.expect("seek");
|
||||
self.video.set_paused(false);
|
||||
}
|
||||
Message::SeekRelative(secs) => {
|
||||
self.video
|
||||
.seek(Duration::from_secs_f64(self.position + secs), true)
|
||||
.expect("seek");
|
||||
}
|
||||
Message::SeekRelease => {
|
||||
self.dragging = false;
|
||||
self.video
|
||||
.seek(Duration::from_secs_f64(self.position), true)
|
||||
.expect("seek");
|
||||
self.video.set_paused(false);
|
||||
}
|
||||
Message::EndOfStream => {
|
||||
println!("end of stream");
|
||||
}
|
||||
Message::NewFrame => {
|
||||
if self.dragging {
|
||||
self.video.set_paused(true);
|
||||
} else {
|
||||
self.position = self.video.position().as_secs_f64();
|
||||
}
|
||||
}
|
||||
Message::SystemThemeModeChange(_theme_mode) => {
|
||||
return self.update_config();
|
||||
}
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
|
||||
/// Creates a view after each update.
|
||||
fn view(&self) -> Element<Self::Message> {
|
||||
Column::new()
|
||||
.push(widget::vertical_space(Length::Fill))
|
||||
.push(
|
||||
VideoPlayer::new(&self.video)
|
||||
.on_end_of_stream(Message::EndOfStream)
|
||||
.on_new_frame(Message::NewFrame)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(widget::vertical_space(Length::Fill))
|
||||
.push(
|
||||
Row::new()
|
||||
.height(Length::Fixed(16.0))
|
||||
.spacing(8)
|
||||
.push(
|
||||
widget::button::icon(if self.video.paused() {
|
||||
widget::icon::from_name("media-playback-start-symbolic").size(16)
|
||||
} else {
|
||||
widget::icon::from_name("media-playback-pause-symbolic").size(16)
|
||||
})
|
||||
.on_press(Message::TogglePause),
|
||||
)
|
||||
.push(widget::text(format!(
|
||||
"{:#?}s / {:#?}s",
|
||||
self.position as u64,
|
||||
self.video.duration().as_secs()
|
||||
)))
|
||||
.push(
|
||||
Slider::new(
|
||||
0.0..=self.video.duration().as_secs_f64(),
|
||||
self.position,
|
||||
Message::Seek,
|
||||
)
|
||||
.step(0.1)
|
||||
.on_release(Message::SeekRelease),
|
||||
),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn subscription(&self) -> Subscription<Self::Message> {
|
||||
struct ConfigSubscription;
|
||||
struct ThemeSubscription;
|
||||
|
||||
Subscription::batch([
|
||||
event::listen_with(|event, _status| match event {
|
||||
Event::Keyboard(KeyEvent::KeyPressed { key, modifiers, .. }) => {
|
||||
Some(Message::Key(modifiers, key))
|
||||
}
|
||||
_ => None,
|
||||
}),
|
||||
cosmic_config::config_subscription(
|
||||
TypeId::of::<ConfigSubscription>(),
|
||||
Self::APP_ID.into(),
|
||||
CONFIG_VERSION,
|
||||
)
|
||||
.map(|update| {
|
||||
if !update.errors.is_empty() {
|
||||
log::debug!("errors loading config: {:?}", update.errors);
|
||||
}
|
||||
Message::SystemThemeModeChange(update.config)
|
||||
}),
|
||||
cosmic_config::config_subscription::<_, cosmic_theme::ThemeMode>(
|
||||
TypeId::of::<ThemeSubscription>(),
|
||||
cosmic_theme::THEME_MODE_ID.into(),
|
||||
cosmic_theme::ThemeMode::version(),
|
||||
)
|
||||
.map(|update| {
|
||||
if !update.errors.is_empty() {
|
||||
log::debug!("errors loading theme mode: {:?}", update.errors);
|
||||
}
|
||||
Message::SystemThemeModeChange(update.config)
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ use cosmic::{
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::HashMap, fmt};
|
||||
|
||||
use crate::Action;
|
||||
use crate::app::Action;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
pub enum Modifier {
|
||||
|
|
|
|||
436
src/main.rs
436
src/main.rs
|
|
@ -1,440 +1,18 @@
|
|||
// Copyright 2023 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use cosmic::{
|
||||
app::{message, Command, Core, Settings},
|
||||
cosmic_config::{self, CosmicConfigEntry},
|
||||
cosmic_theme, executor,
|
||||
iced::{
|
||||
event::{self, Event},
|
||||
keyboard::{Event as KeyEvent, Key, Modifiers},
|
||||
subscription::{self, Subscription},
|
||||
window, Alignment, Length, Limits,
|
||||
},
|
||||
widget, Application, ApplicationExt, Element,
|
||||
};
|
||||
use std::{
|
||||
any::TypeId,
|
||||
collections::HashMap,
|
||||
env, process,
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use config::{AppTheme, Args, Config, CONFIG_VERSION};
|
||||
mod config;
|
||||
|
||||
use key_bind::{key_binds, KeyBind};
|
||||
mod key_bind;
|
||||
|
||||
mod localize;
|
||||
|
||||
use player::{PlayerMessage, VideoFrame, VideoQueue};
|
||||
mod player;
|
||||
#[cfg(feature = "ffmpeg")]
|
||||
#[path = "ffmpeg/mod.rs"]
|
||||
mod app;
|
||||
|
||||
mod hardware;
|
||||
#[cfg(feature = "gstreamer")]
|
||||
#[path = "gstreamer/mod.rs"]
|
||||
mod app;
|
||||
|
||||
/// Runs application with these settings
|
||||
#[rustfmt::skip]
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||
|
||||
localize::localize();
|
||||
|
||||
let mut args = match Args::parse_args() {
|
||||
Ok(args) => args,
|
||||
Err(e) => {
|
||||
log::error!("{e}");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let (config_handler, config) = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) {
|
||||
Ok(config_handler) => {
|
||||
let mut config = match Config::get_entry(&config_handler) {
|
||||
Ok(ok) => ok,
|
||||
Err((errs, config)) => {
|
||||
log::info!("errors loading config: {:?}", errs);
|
||||
config
|
||||
}
|
||||
};
|
||||
// Update config with command line args
|
||||
config.with_args(&mut args);
|
||||
(Some(config_handler), config)
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("failed to create config handler: {}", err);
|
||||
(None, Config::default())
|
||||
}
|
||||
};
|
||||
|
||||
//TODO: support using multiple paths
|
||||
let Args { mut paths, .. } = args;
|
||||
let path = paths.pop().unwrap();
|
||||
|
||||
// TODO: Update video player config when it's updated via the app
|
||||
let (player_tx, video_queue_lock) = player::run(path, config.clone());
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings = settings.theme(config.app_theme.theme());
|
||||
settings = settings.size_limits(Limits::NONE.min_width(360.0).min_height(180.0));
|
||||
|
||||
let flags = Flags {
|
||||
config_handler,
|
||||
config,
|
||||
player_tx,
|
||||
video_queue_lock,
|
||||
};
|
||||
cosmic::app::run::<App>(settings, flags)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Action {
|
||||
Todo,
|
||||
SeekBackward,
|
||||
SeekForward,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn message(&self) -> Message {
|
||||
match self {
|
||||
Self::Todo => Message::Todo,
|
||||
Self::SeekBackward => Message::Player(PlayerMessage::SeekRelative(-10.0)),
|
||||
Self::SeekForward => Message::Player(PlayerMessage::SeekRelative(10.0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Flags {
|
||||
config_handler: Option<cosmic_config::Config>,
|
||||
config: Config,
|
||||
player_tx: mpsc::Sender<PlayerMessage>,
|
||||
video_queue_lock: Arc<Mutex<VideoQueue>>,
|
||||
}
|
||||
|
||||
/// Messages that are used specifically by our [`App`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Message {
|
||||
Todo,
|
||||
AppTheme(AppTheme),
|
||||
Config(Config),
|
||||
Key(Modifiers, Key),
|
||||
Player(PlayerMessage),
|
||||
SystemThemeModeChange(cosmic_theme::ThemeMode),
|
||||
Tick(Instant),
|
||||
ToggleContextPage(ContextPage),
|
||||
WindowClose,
|
||||
WindowNew,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ContextPage {
|
||||
Settings,
|
||||
}
|
||||
|
||||
impl ContextPage {
|
||||
fn title(&self) -> String {
|
||||
match self {
|
||||
Self::Settings => fl!("settings"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`App`] stores application-specific state.
|
||||
pub struct App {
|
||||
core: Core,
|
||||
flags: Flags,
|
||||
app_themes: Vec<String>,
|
||||
context_page: ContextPage,
|
||||
key_binds: HashMap<KeyBind, Action>,
|
||||
handle_opt: Option<widget::image::Handle>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn update_config(&mut self) -> Command<Message> {
|
||||
cosmic::app::command::set_theme(self.flags.config.app_theme.theme())
|
||||
}
|
||||
|
||||
fn update_title(&mut self) -> Command<Message> {
|
||||
let title = "COSMIC Media Player";
|
||||
self.set_header_title(title.to_string());
|
||||
self.set_window_title(title.to_string())
|
||||
}
|
||||
|
||||
fn settings(&self) -> Element<Message> {
|
||||
let app_theme_selected = match self.flags.config.app_theme {
|
||||
AppTheme::Dark => 1,
|
||||
AppTheme::Light => 2,
|
||||
AppTheme::System => 0,
|
||||
};
|
||||
widget::settings::view_column(vec![widget::settings::view_section(fl!("appearance"))
|
||||
.add(
|
||||
widget::settings::item::builder(fl!("theme")).control(widget::dropdown(
|
||||
&self.app_themes,
|
||||
Some(app_theme_selected),
|
||||
move |index| {
|
||||
Message::AppTheme(match index {
|
||||
1 => AppTheme::Dark,
|
||||
2 => AppTheme::Light,
|
||||
_ => AppTheme::System,
|
||||
})
|
||||
},
|
||||
)),
|
||||
)
|
||||
.into()])
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement [`Application`] to integrate with COSMIC.
|
||||
impl Application for App {
|
||||
/// Default async executor to use with the app.
|
||||
type Executor = executor::Default;
|
||||
|
||||
/// Argument received
|
||||
type Flags = Flags;
|
||||
|
||||
/// Message type specific to our [`App`].
|
||||
type Message = Message;
|
||||
|
||||
/// The unique application ID to supply to the window manager.
|
||||
const APP_ID: &'static str = "com.system76.CosmicPlayer";
|
||||
|
||||
fn core(&self) -> &Core {
|
||||
&self.core
|
||||
}
|
||||
|
||||
fn core_mut(&mut self) -> &mut Core {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
/// Creates the application, and optionally emits command on initialize.
|
||||
fn init(core: Core, flags: Self::Flags) -> (Self, Command<Self::Message>) {
|
||||
let app_themes = vec![fl!("match-desktop"), fl!("dark"), fl!("light")];
|
||||
let mut app = App {
|
||||
core,
|
||||
flags,
|
||||
app_themes,
|
||||
context_page: ContextPage::Settings,
|
||||
key_binds: key_binds(),
|
||||
handle_opt: None,
|
||||
};
|
||||
|
||||
let command = app.update_title();
|
||||
(app, command)
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> Command<Message> {
|
||||
if self.core.window.show_context {
|
||||
// Close context drawer if open
|
||||
self.core.window.show_context = false;
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
|
||||
/// Handle application events here.
|
||||
fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
|
||||
// Helper for updating config values efficiently
|
||||
macro_rules! config_set {
|
||||
($name: ident, $value: expr) => {
|
||||
match &self.flags.config_handler {
|
||||
Some(config_handler) => {
|
||||
match paste::paste! { self.flags.config.[<set_ $name>](config_handler, $value) } {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"failed to save config {:?}: {}",
|
||||
stringify!($name),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.flags.config.$name = $value;
|
||||
log::warn!(
|
||||
"failed to save config {:?}: no config handler",
|
||||
stringify!($name)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match message {
|
||||
Message::Todo => {
|
||||
log::warn!("TODO");
|
||||
}
|
||||
Message::AppTheme(app_theme) => {
|
||||
config_set!(app_theme, app_theme);
|
||||
return self.update_config();
|
||||
}
|
||||
Message::Config(config) => {
|
||||
if config != self.flags.config {
|
||||
log::info!("update config");
|
||||
//TODO: update syntax theme by clearing tabs, only if needed
|
||||
self.flags.config = config;
|
||||
return self.update_config();
|
||||
}
|
||||
}
|
||||
Message::Key(modifiers, key) => {
|
||||
for (key_bind, action) in self.key_binds.iter() {
|
||||
if key_bind.matches(modifiers, &key) {
|
||||
return self.update(action.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Player(player_message) => {
|
||||
self.flags.player_tx.send(player_message).unwrap();
|
||||
}
|
||||
Message::SystemThemeModeChange(_theme_mode) => {
|
||||
return self.update_config();
|
||||
}
|
||||
Message::Tick(frame_time) => {
|
||||
let start = Instant::now();
|
||||
|
||||
let mut video_frame_opt: Option<VideoFrame> = None;
|
||||
let delayed_time = {
|
||||
let mut video_queue = self.flags.video_queue_lock.lock().unwrap();
|
||||
let delayed_time = frame_time - video_queue.delay;
|
||||
while let Some(video_frame) = video_queue.data.pop_front() {
|
||||
if video_frame.1.unwrap_or(delayed_time) <= delayed_time {
|
||||
if let Some(old_frame) = video_frame_opt {
|
||||
//TODO: log this outside of locking video_queue_lock?
|
||||
log::warn!("skipping video frame {:?}", old_frame.0.pts());
|
||||
}
|
||||
// Frame is ready to be shown
|
||||
video_frame_opt = Some(video_frame);
|
||||
} else {
|
||||
// Put frame back and exit loop
|
||||
video_queue.data.push_front(video_frame);
|
||||
break;
|
||||
}
|
||||
}
|
||||
delayed_time
|
||||
};
|
||||
|
||||
match video_frame_opt {
|
||||
Some(video_frame) => {
|
||||
let pts = video_frame.0.pts();
|
||||
let present_time_opt = video_frame.1;
|
||||
self.handle_opt = Some(video_frame.into_handle());
|
||||
|
||||
let duration = start.elapsed();
|
||||
log::debug!(
|
||||
"converted video frame at {:?} to handle in {:?}",
|
||||
pts,
|
||||
duration
|
||||
);
|
||||
|
||||
if let Some(present_time) = present_time_opt {
|
||||
if present_time > delayed_time {
|
||||
let ahead = present_time - delayed_time;
|
||||
if ahead > Duration::from_millis(1) {
|
||||
log::debug!("video ahead {:?}", ahead);
|
||||
}
|
||||
} else {
|
||||
let behind = delayed_time - present_time;
|
||||
if behind > Duration::from_millis(1) {
|
||||
log::debug!("video behind {:?}", behind);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Message::ToggleContextPage(context_page) => {
|
||||
//TODO: ensure context menus are closed
|
||||
if self.context_page == context_page {
|
||||
self.core.window.show_context = !self.core.window.show_context;
|
||||
} else {
|
||||
self.context_page = context_page;
|
||||
self.core.window.show_context = true;
|
||||
}
|
||||
self.set_context_title(context_page.title());
|
||||
}
|
||||
Message::WindowClose => {
|
||||
return window::close(window::Id::MAIN);
|
||||
}
|
||||
Message::WindowNew => match env::current_exe() {
|
||||
Ok(exe) => match process::Command::new(&exe).spawn() {
|
||||
Ok(_child) => {}
|
||||
Err(err) => {
|
||||
log::error!("failed to execute {:?}: {}", exe, err);
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
log::error!("failed to get current executable path: {}", err);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Command::none()
|
||||
}
|
||||
|
||||
fn context_drawer(&self) -> Option<Element<Message>> {
|
||||
if !self.core.window.show_context {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(match self.context_page {
|
||||
ContextPage::Settings => self.settings(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a view after each update.
|
||||
fn view(&self) -> Element<Self::Message> {
|
||||
let content: Element<_> = match &self.handle_opt {
|
||||
Some(handle) => widget::image(handle.clone())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into(),
|
||||
None => widget::text("Loading").into(),
|
||||
};
|
||||
|
||||
// Uncomment to debug layout:
|
||||
//content.explain(cosmic::iced::Color::WHITE)
|
||||
content
|
||||
}
|
||||
|
||||
fn subscription(&self) -> Subscription<Self::Message> {
|
||||
struct ConfigSubscription;
|
||||
struct ThemeSubscription;
|
||||
|
||||
Subscription::batch([
|
||||
window::frames().map(|(_window_id, instant)| Message::Tick(instant)),
|
||||
event::listen_with(|event, _status| match event {
|
||||
Event::Keyboard(KeyEvent::KeyPressed { key, modifiers, .. }) => {
|
||||
Some(Message::Key(modifiers, key))
|
||||
}
|
||||
_ => None,
|
||||
}),
|
||||
cosmic_config::config_subscription(
|
||||
TypeId::of::<ConfigSubscription>(),
|
||||
Self::APP_ID.into(),
|
||||
CONFIG_VERSION,
|
||||
)
|
||||
.map(|update| {
|
||||
if !update.errors.is_empty() {
|
||||
log::debug!("errors loading config: {:?}", update.errors);
|
||||
}
|
||||
Message::SystemThemeModeChange(update.config)
|
||||
}),
|
||||
cosmic_config::config_subscription::<_, cosmic_theme::ThemeMode>(
|
||||
TypeId::of::<ThemeSubscription>(),
|
||||
cosmic_theme::THEME_MODE_ID.into(),
|
||||
cosmic_theme::ThemeMode::version(),
|
||||
)
|
||||
.map(|update| {
|
||||
if !update.errors.is_empty() {
|
||||
log::debug!("errors loading theme mode: {:?}", update.errors);
|
||||
}
|
||||
Message::SystemThemeModeChange(update.config)
|
||||
}),
|
||||
])
|
||||
}
|
||||
app::main()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue