Split into ffmpeg and gstreamer implementations
This commit is contained in:
parent
52b9439ca4
commit
17ce907f28
11 changed files with 1761 additions and 1245 deletions
|
|
@ -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