Remove ffmpeg implementation
This commit is contained in:
parent
d61426957d
commit
f10350c7ec
11 changed files with 605 additions and 2304 deletions
|
|
@ -4,12 +4,7 @@ use cosmic::{
|
|||
cosmic_config::{self, cosmic_config_derive::CosmicConfigEntry, CosmicConfigEntry},
|
||||
theme,
|
||||
};
|
||||
use lexopt::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{path::PathBuf, process};
|
||||
|
||||
#[cfg(feature = "ffmpeg")]
|
||||
use crate::app::hardware::DeviceType;
|
||||
|
||||
pub const CONFIG_VERSION: u64 = 1;
|
||||
|
||||
|
|
@ -34,68 +29,12 @@ impl AppTheme {
|
|||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub app_theme: AppTheme,
|
||||
#[cfg(feature = "ffmpeg")]
|
||||
pub hw_decoder: DeviceType,
|
||||
}
|
||||
|
||||
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 {
|
||||
self.hw_decoder = decoder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
let mut decoder = None;
|
||||
|
||||
let mut parser = lexopt::Parser::from_env();
|
||||
while let Some(arg) = parser.next()? {
|
||||
match arg {
|
||||
Long("list-hwdec") => {
|
||||
println!("Supported hardware decoders:");
|
||||
for hwdec in DeviceType::supported_devices() {
|
||||
println!("\t* [{}] {hwdec}", hwdec.short_name());
|
||||
}
|
||||
process::exit(0);
|
||||
}
|
||||
Long("hwdec") => {
|
||||
decoder = Some(parser.value()?.parse()?);
|
||||
}
|
||||
Value(path) => {
|
||||
let path = path.parse()?;
|
||||
paths.push(path);
|
||||
}
|
||||
_ => return Err(arg.unexpected()),
|
||||
}
|
||||
}
|
||||
|
||||
if paths.is_empty() {
|
||||
return Err(lexopt::Error::MissingValue {
|
||||
option: Some("missing video path".into()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self { paths, decoder })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use ffmpeg_next::ffi::AVHWDeviceType;
|
||||
use serde::{
|
||||
de::{value::Error as DeError, Error as DeErrorTrait, Unexpected},
|
||||
Deserialize, Serialize,
|
||||
};
|
||||
|
||||
use super::iter::SupportedDeviceIter;
|
||||
|
||||
/// Delegate type for [`ffmpeg_next::ffi::AVHWDeviceType`] for configs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DeviceType {
|
||||
None,
|
||||
/// Compute Unified Device Architecture
|
||||
/// Nvidia only.
|
||||
/// https://developer.nvidia.com/video-codec-sdk
|
||||
Cuda,
|
||||
/// Direct3D 11 Video API
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/medfound/direct3d-11-video-apis
|
||||
D3d11va,
|
||||
/// Direct3D 12 Video API
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/medfound/direct3d-12-video-overview
|
||||
D3d12va,
|
||||
/// DirectX Video Acceleration 2.0
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/medfound/about-dxva-2-0
|
||||
Dxva2,
|
||||
/// Direct Rendering Manager
|
||||
/// https://dri.freedesktop.org/wiki/DRM/
|
||||
Drm,
|
||||
/// MediaCodec
|
||||
/// Android only
|
||||
/// https://developer.android.com/reference/android/media/MediaCodec
|
||||
MediaCodec,
|
||||
/// OpenCL
|
||||
/// Only used in filters
|
||||
/// https://www.khronos.org/opencl/
|
||||
OpenCl,
|
||||
/// Intel Quick Sync Video
|
||||
/// https://www.intel.com/content/www/us/en/developer/tools/vpl/overview.html
|
||||
Qsv,
|
||||
/// Video Acceleration API
|
||||
/// https://www.intel.com/content/www/us/en/developer/articles/technical/linuxmedia-vaapi.html
|
||||
Vaapi,
|
||||
/// Video Decode and Presentation API for Unix
|
||||
/// https://www.freedesktop.org/wiki/Software/VDPAU/
|
||||
Vdpau,
|
||||
/// Video Toolbox
|
||||
/// https://developer.apple.com/documentation/videotoolbox
|
||||
VideoToolbox,
|
||||
/// Vulkan
|
||||
Vulkan,
|
||||
}
|
||||
|
||||
impl DeviceType {
|
||||
/// Hardware device names for user facing interfaces (logging, configs).
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None",
|
||||
Self::Cuda => "CUDA",
|
||||
Self::Dxva2 => "DirectX Video Acceleration 2.0",
|
||||
Self::D3d11va => "DirectX 11 Video Acceleration",
|
||||
Self::D3d12va => "DirectX 12 Video Acceleration",
|
||||
Self::Drm => "Direct Rendering Manager (DRM)",
|
||||
Self::MediaCodec => "MediaCodec",
|
||||
Self::OpenCl => "OpenCL",
|
||||
Self::Qsv => "Intel Quick Video Sync",
|
||||
Self::Vaapi => "VA-API",
|
||||
Self::Vdpau => "VDPAU",
|
||||
Self::VideoToolbox => "VideoToolbox",
|
||||
Self::Vulkan => "Vulkan",
|
||||
}
|
||||
}
|
||||
|
||||
/// Short name for CLI arguments
|
||||
pub const fn short_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Cuda => "cuda",
|
||||
Self::Dxva2 => "dxva2",
|
||||
Self::D3d11va => "d3d11va",
|
||||
Self::D3d12va => "d3d12va",
|
||||
Self::Drm => "drm",
|
||||
Self::MediaCodec => "mediacodec",
|
||||
Self::OpenCl => "opencl",
|
||||
Self::Qsv => "qsv",
|
||||
Self::Vaapi => "vaapi",
|
||||
Self::Vdpau => "vdpau",
|
||||
Self::VideoToolbox => "videotoolbox",
|
||||
Self::Vulkan => "vulkan",
|
||||
}
|
||||
}
|
||||
|
||||
/// System's supported hardware decoders
|
||||
pub fn supported_devices() -> SupportedDeviceIter {
|
||||
SupportedDeviceIter::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DeviceType {
|
||||
type Err = DeError;
|
||||
|
||||
// av_hwdevice_find_type_by_name returns None for invalid device type names, but this type
|
||||
// is used for deserializing configs (etc.) so the error is preserved.
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"none" => Ok(Self::None),
|
||||
"cuda" => Ok(Self::Cuda),
|
||||
"dxva2" => Ok(Self::Dxva2),
|
||||
"d3d11va" => Ok(Self::D3d11va),
|
||||
"d3d12va" => Ok(Self::D3d12va),
|
||||
"drm" => Ok(Self::Drm),
|
||||
"mediacodec" => Ok(Self::MediaCodec),
|
||||
"opencl" => Ok(Self::OpenCl),
|
||||
"qsv" => Ok(Self::Qsv),
|
||||
"vaapi" => Ok(Self::Vaapi),
|
||||
"vdpau" => Ok(Self::Vdpau),
|
||||
"videotoolbox" => Ok(Self::VideoToolbox),
|
||||
"vulkan" => Ok(Self::Vulkan),
|
||||
_ => Err(DeError::invalid_value(
|
||||
Unexpected::Str(s),
|
||||
&"valid hardware decoder",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DeviceType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AVHWDeviceType> for DeviceType {
|
||||
fn from(value: AVHWDeviceType) -> Self {
|
||||
match value {
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_NONE => Self::None,
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA => Self::Cuda,
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_DXVA2 => Self::Dxva2,
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA => Self::D3d11va,
|
||||
// This variant exists in ffmpeg's C lib but not in Rust's crate yet.
|
||||
// AVHWDeviceType::AV_HWDEVICE_TYPE_D3D12VA => Self::D3d12va
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_DRM => Self::Drm,
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_MEDIACODEC => Self::MediaCodec,
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_OPENCL => Self::OpenCl,
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_QSV => Self::Qsv,
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI => Self::Vaapi,
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_VDPAU => Self::Vdpau,
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX => Self::VideoToolbox,
|
||||
AVHWDeviceType::AV_HWDEVICE_TYPE_VULKAN => Self::Vulkan,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeviceType> for AVHWDeviceType {
|
||||
fn from(value: DeviceType) -> Self {
|
||||
match value {
|
||||
DeviceType::None => Self::AV_HWDEVICE_TYPE_NONE,
|
||||
DeviceType::Cuda => Self::AV_HWDEVICE_TYPE_CUDA,
|
||||
DeviceType::D3d11va => Self::AV_HWDEVICE_TYPE_D3D11VA,
|
||||
// NOTE: Next FFmpeg release
|
||||
DeviceType::D3d12va => Self::AV_HWDEVICE_TYPE_NONE,
|
||||
DeviceType::Dxva2 => Self::AV_HWDEVICE_TYPE_DXVA2,
|
||||
DeviceType::Drm => Self::AV_HWDEVICE_TYPE_DRM,
|
||||
DeviceType::MediaCodec => Self::AV_HWDEVICE_TYPE_MEDIACODEC,
|
||||
DeviceType::OpenCl => Self::AV_HWDEVICE_TYPE_OPENCL,
|
||||
DeviceType::Qsv => Self::AV_HWDEVICE_TYPE_QSV,
|
||||
DeviceType::Vaapi => Self::AV_HWDEVICE_TYPE_VAAPI,
|
||||
DeviceType::Vdpau => Self::AV_HWDEVICE_TYPE_VDPAU,
|
||||
DeviceType::VideoToolbox => Self::AV_HWDEVICE_TYPE_VIDEOTOOLBOX,
|
||||
DeviceType::Vulkan => Self::AV_HWDEVICE_TYPE_VULKAN,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DeviceType {
|
||||
fn default() -> Self {
|
||||
Self::Vaapi
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::iter::FusedIterator;
|
||||
|
||||
use ffmpeg_next::ffi::{av_hwdevice_iterate_types, AVHWDeviceType};
|
||||
|
||||
use super::device_type::DeviceType;
|
||||
|
||||
/// Iterator over system's supported hardware decoders.
|
||||
pub struct SupportedDeviceIter {
|
||||
current: AVHWDeviceType,
|
||||
}
|
||||
|
||||
impl Default for SupportedDeviceIter {
|
||||
fn default() -> Self {
|
||||
// SAFETY: FFmpeg's documentation states that the iterator is delimited by AV_HWDEVICE_TYPE_NONE.
|
||||
let current = unsafe { av_hwdevice_iterate_types(AVHWDeviceType::AV_HWDEVICE_TYPE_NONE) };
|
||||
Self { current }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for SupportedDeviceIter {
|
||||
type Item = DeviceType;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// None is a sentinel value that indicates the iterator is exhausted
|
||||
if self.current == AVHWDeviceType::AV_HWDEVICE_TYPE_NONE {
|
||||
None
|
||||
} else {
|
||||
let prev = self.current;
|
||||
// SAFETY: The docs and examples state that the iterator yields the next value
|
||||
// when the previous is passed in.
|
||||
self.current = unsafe { av_hwdevice_iterate_types(prev) };
|
||||
|
||||
Some(prev.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FusedIterator for SupportedDeviceIter {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::hint::black_box;
|
||||
|
||||
use super::*;
|
||||
|
||||
// The iterator's yielded values aren't important since hardware decoders vary by system
|
||||
// This is just a sanity check to ensure the iterator works
|
||||
#[test]
|
||||
fn supported_device_iter_doesnt_seg_fault() {
|
||||
for decoder in DeviceType::supported_devices() {
|
||||
black_box(decoder);
|
||||
}
|
||||
|
||||
let _decoders: Vec<_> = black_box(DeviceType::supported_devices().collect());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
pub mod device_type;
|
||||
pub mod iter;
|
||||
|
||||
pub use device_type::DeviceType;
|
||||
pub use iter::SupportedDeviceIter;
|
||||
|
|
@ -1,439 +0,0 @@
|
|||
// 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)
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -1,654 +0,0 @@
|
|||
extern crate ffmpeg_next as ffmpeg;
|
||||
|
||||
use cosmic::widget;
|
||||
use cpal::{
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
FromSample, SizedSample,
|
||||
};
|
||||
use ffmpeg::{
|
||||
codec, ffi,
|
||||
format::{input, Pixel},
|
||||
media::Type,
|
||||
software::{resampling, scaling},
|
||||
util::{
|
||||
channel_layout, error,
|
||||
format::sample,
|
||||
frame::{audio::Audio, video::Video},
|
||||
},
|
||||
Packet,
|
||||
};
|
||||
use std::{
|
||||
cmp,
|
||||
collections::VecDeque,
|
||||
error::Error,
|
||||
path::{Path, PathBuf},
|
||||
ptr, slice,
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
//TODO: calculate presentation time of end of queue
|
||||
pub struct AudioQueue {
|
||||
pub channels: usize,
|
||||
pub rate: f64,
|
||||
pub data: VecDeque<f32>,
|
||||
// Delay for data to hit speakers, used to sync with video
|
||||
pub delay: Duration,
|
||||
}
|
||||
|
||||
impl AudioQueue {
|
||||
pub fn new(channels: cpal::ChannelCount, rate: cpal::SampleRate) -> Self {
|
||||
Self {
|
||||
channels: channels as usize,
|
||||
rate: rate.0 as f64,
|
||||
data: VecDeque::new(),
|
||||
delay: Duration::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn duration(&self) -> Duration {
|
||||
self.duration_for_samples(self.data.len())
|
||||
}
|
||||
|
||||
pub fn duration_for_samples(&self, samples: usize) -> Duration {
|
||||
let frames = samples / self.channels;
|
||||
let seconds = (frames as f64) / self.rate;
|
||||
Duration::from_secs_f64(seconds)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PlayerMessage {
|
||||
SeekRelative(f64),
|
||||
}
|
||||
|
||||
pub struct VideoFrame(pub Video, pub Option<Instant>);
|
||||
|
||||
impl VideoFrame {
|
||||
pub fn into_handle(self) -> widget::image::Handle {
|
||||
let width = self.0.width();
|
||||
let height = self.0.height();
|
||||
widget::image::Handle::from_pixels(width, height, self)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for VideoFrame {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.0.data(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VideoQueue {
|
||||
pub data: VecDeque<VideoFrame>,
|
||||
// Delay to add to each frame to sync with audio
|
||||
pub delay: Duration,
|
||||
}
|
||||
|
||||
impl VideoQueue {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
data: VecDeque::new(),
|
||||
delay: Duration::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, frame: VideoFrame) {
|
||||
// Discard all frames that are newer than frame to fix seeking and duration calculation
|
||||
self.data
|
||||
.retain(|other| other.1.map_or(true, |x| x <= frame.1.unwrap_or(x)));
|
||||
self.data.push_back(frame);
|
||||
}
|
||||
|
||||
pub fn duration(&self) -> Duration {
|
||||
//TODO: can accurate duration actually be calculated since one frame would count as zero?
|
||||
let mut start_end_opt = None;
|
||||
for frame in self.data.iter() {
|
||||
if let Some(frame_time) = frame.1 {
|
||||
start_end_opt = Some(match start_end_opt {
|
||||
Some((start, end)) => (cmp::min(start, frame_time), cmp::max(end, frame_time)),
|
||||
None => (frame_time, frame_time),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some((start, end)) = start_end_opt {
|
||||
end.duration_since(start)
|
||||
} else {
|
||||
Duration::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cpal() -> (
|
||||
cpal::SupportedStreamConfig,
|
||||
Box<dyn StreamTrait>,
|
||||
Arc<Mutex<AudioQueue>>,
|
||||
) {
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.expect("failed to get default audio output device");
|
||||
let config = device
|
||||
.default_output_config()
|
||||
.expect("failed to get default audio output config");
|
||||
println!("{:?}: {:?}", device.name(), config);
|
||||
|
||||
let audio_queue_lock = Arc::new(Mutex::new(AudioQueue::new(
|
||||
config.channels(),
|
||||
config.sample_rate(),
|
||||
)));
|
||||
let stream = {
|
||||
let config = config.clone();
|
||||
let audio_queue_lock = audio_queue_lock.clone();
|
||||
match config.sample_format() {
|
||||
cpal::SampleFormat::I8 => cpal_stream::<i8>(device, config.into(), audio_queue_lock),
|
||||
cpal::SampleFormat::I16 => cpal_stream::<i16>(device, config.into(), audio_queue_lock),
|
||||
// cpal::SampleFormat::I24 => cpal_stream::<I24>(device, config.into(), audio_queue_lock),
|
||||
cpal::SampleFormat::I32 => cpal_stream::<i32>(device, config.into(), audio_queue_lock),
|
||||
// cpal::SampleFormat::I48 => cpal_stream::<I48>(device, config.into(), audio_queue_lock),
|
||||
cpal::SampleFormat::I64 => cpal_stream::<i64>(device, config.into(), audio_queue_lock),
|
||||
cpal::SampleFormat::U8 => cpal_stream::<u8>(device, config.into(), audio_queue_lock),
|
||||
cpal::SampleFormat::U16 => cpal_stream::<u16>(device, config.into(), audio_queue_lock),
|
||||
// cpal::SampleFormat::U24 => cpal_stream::<U24>(device, config.into(), audio_queue_lock),
|
||||
cpal::SampleFormat::U32 => cpal_stream::<u32>(device, config.into(), audio_queue_lock),
|
||||
// cpal::SampleFormat::U48 => cpal_stream::<U48>(device, config.into(), audio_queue_lock),
|
||||
cpal::SampleFormat::U64 => cpal_stream::<u64>(device, config.into(), audio_queue_lock),
|
||||
cpal::SampleFormat::F32 => cpal_stream::<f32>(device, config.into(), audio_queue_lock),
|
||||
cpal::SampleFormat::F64 => cpal_stream::<f64>(device, config.into(), audio_queue_lock),
|
||||
sample_format => panic!("unsupported sample format '{sample_format}'"),
|
||||
}
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
(config, stream, audio_queue_lock)
|
||||
}
|
||||
|
||||
fn cpal_stream<T>(
|
||||
device: cpal::Device,
|
||||
config: cpal::StreamConfig,
|
||||
audio_queue_lock: Arc<Mutex<AudioQueue>>,
|
||||
) -> Result<Box<dyn StreamTrait>, Box<dyn Error>>
|
||||
where
|
||||
T: SizedSample + FromSample<f32>,
|
||||
{
|
||||
let data_fn = {
|
||||
move |samples: &mut [T], info: &cpal::OutputCallbackInfo| {
|
||||
let timestamp = info.timestamp();
|
||||
let delay = timestamp.playback.duration_since(×tamp.callback);
|
||||
|
||||
let mut underrun = 0;
|
||||
{
|
||||
//TODO: buffer audio
|
||||
let mut audio_queue = audio_queue_lock.lock().unwrap();
|
||||
//TODO: also add samples time?
|
||||
audio_queue.delay = delay.unwrap_or_default();
|
||||
for sample in samples {
|
||||
let float = match audio_queue.data.pop_front() {
|
||||
Some(some) => some,
|
||||
None => {
|
||||
underrun += 1;
|
||||
0.0
|
||||
}
|
||||
};
|
||||
*sample = T::from_sample(float);
|
||||
}
|
||||
};
|
||||
if underrun > 0 {
|
||||
log::error!("audio underrun {}", underrun);
|
||||
}
|
||||
}
|
||||
};
|
||||
let err_fn = |err| eprintln!("an error occurred on stream: {}", err);
|
||||
let stream = device.build_output_stream(&config, data_fn, err_fn, None)?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
fn ffmpeg_thread<P: AsRef<Path>>(
|
||||
path: P,
|
||||
player_rx: mpsc::Receiver<PlayerMessage>,
|
||||
video_queue_lock: Arc<Mutex<VideoQueue>>,
|
||||
config: Config,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let (audio_config, cpal_stream, audio_queue_lock) = cpal();
|
||||
|
||||
let mut ictx = input(&path)?;
|
||||
|
||||
let video_stream = ictx
|
||||
.streams()
|
||||
.best(Type::Video)
|
||||
.ok_or(ffmpeg::Error::StreamNotFound)?;
|
||||
let video_stream_index = video_stream.index();
|
||||
let video_time_base = f64::from(video_stream.time_base());
|
||||
|
||||
let mut video_decoder = {
|
||||
let mut video_decoder_context =
|
||||
codec::context::Context::from_parameters(video_stream.parameters())?;
|
||||
|
||||
//TODO: safe wrappers
|
||||
let mut hw_device_ctx = ptr::null_mut();
|
||||
unsafe {
|
||||
//TODO: support other types
|
||||
let hw_device_kind = config.hw_decoder;
|
||||
if ffi::av_hwdevice_ctx_create(
|
||||
&mut hw_device_ctx,
|
||||
hw_device_kind.into(),
|
||||
ptr::null(),
|
||||
ptr::null_mut(),
|
||||
0,
|
||||
) == 0
|
||||
{
|
||||
log::info!("using {hw_device_kind} decoding");
|
||||
(&mut *video_decoder_context.as_mut_ptr()).hw_device_ctx =
|
||||
ffi::av_buffer_ref(hw_device_ctx);
|
||||
} else {
|
||||
//TODO: support other hardware devices
|
||||
log::warn!(
|
||||
"failed to use {hw_device_kind} decoding, falling back to software decoding"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
video_decoder_context.decoder().video()?
|
||||
};
|
||||
|
||||
let (cpu_frame_tx, cpu_frame_rx) = mpsc::channel::<(Video, Option<Instant>)>();
|
||||
{
|
||||
let video_format = video_decoder.format();
|
||||
let video_width = video_decoder.width();
|
||||
let video_height = video_decoder.height();
|
||||
let video_queue_lock = video_queue_lock.clone();
|
||||
thread::Builder::new()
|
||||
.name("video_scale".to_string())
|
||||
.spawn(move || {
|
||||
let mut video_scaler = scaling::context::Context::get(
|
||||
video_format,
|
||||
video_width,
|
||||
video_height,
|
||||
Pixel::RGBA,
|
||||
video_width,
|
||||
video_height,
|
||||
scaling::Flags::FAST_BILINEAR,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
loop {
|
||||
let mut recv_opt: Option<(Video, Option<Instant>)> = None;
|
||||
/*TODO: SKIP
|
||||
while let Ok(recv) = cpu_frame_rx.try_recv() {
|
||||
if let Some((old_frame, _)) = recv_opt {
|
||||
//TODO: only skip if behind (frames come in weird timing from codecs)
|
||||
log::warn!("skipping cpu video frame at {:?}", old_frame.pts());
|
||||
}
|
||||
recv_opt = Some(recv);
|
||||
}
|
||||
*/
|
||||
let (cpu_frame, sync_time_opt) = match recv_opt {
|
||||
Some(some) => some,
|
||||
None => cpu_frame_rx.recv().unwrap(),
|
||||
};
|
||||
let pts_opt = cpu_frame.pts();
|
||||
|
||||
// Start count after blocking recv
|
||||
let start = Instant::now();
|
||||
|
||||
video_scaler.cached(
|
||||
cpu_frame.format(),
|
||||
cpu_frame.width(),
|
||||
cpu_frame.height(),
|
||||
Pixel::RGBA,
|
||||
cpu_frame.width(),
|
||||
cpu_frame.height(),
|
||||
scaling::Flags::FAST_BILINEAR,
|
||||
);
|
||||
|
||||
let mut scaled_frame = Video::empty();
|
||||
video_scaler.run(&cpu_frame, &mut scaled_frame).unwrap();
|
||||
scaled_frame.set_pts(pts_opt);
|
||||
|
||||
let present_time_opt = if let Some(pts) = pts_opt {
|
||||
let expected_float = pts as f64 * video_time_base;
|
||||
let expected = Duration::from_secs_f64(expected_float);
|
||||
if let Some(sync_time) = sync_time_opt {
|
||||
Some(sync_time + expected)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let video_frame = VideoFrame(scaled_frame, present_time_opt);
|
||||
{
|
||||
let mut video_queue = video_queue_lock.lock().unwrap();
|
||||
video_queue.push(video_frame);
|
||||
}
|
||||
|
||||
let duration = start.elapsed();
|
||||
log::debug!("scaled video frame at {:?} in {:?}", pts_opt, duration,);
|
||||
}
|
||||
})?
|
||||
};
|
||||
|
||||
// Sync channel to prevent allocation issues and falling behind
|
||||
let (gpu_frame_tx, gpu_frame_rx) = mpsc::sync_channel::<(Video, Option<Instant>)>(2);
|
||||
thread::Builder::new()
|
||||
.name("video_map_gpu_cpu".to_string())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let mut recv_opt: Option<(Video, Option<Instant>)> = None;
|
||||
/*TODO: SKIP
|
||||
while let Ok(recv) = gpu_frame_rx.try_recv() {
|
||||
if let Some((old_frame, _)) = recv_opt {
|
||||
//TODO: only skip if behind (frames come in weird timing from codecs)
|
||||
log::warn!("skipping gpu video frame at {:?}", old_frame.pts());
|
||||
}
|
||||
recv_opt = Some(recv);
|
||||
}
|
||||
*/
|
||||
let (gpu_frame, sync_time_opt) = match recv_opt {
|
||||
Some(some) => some,
|
||||
None => gpu_frame_rx.recv().unwrap(),
|
||||
};
|
||||
let pts = gpu_frame.pts();
|
||||
|
||||
// Start timer after blocking recv
|
||||
let start = Instant::now();
|
||||
|
||||
let mut cpu_frame = Video::empty();
|
||||
unsafe {
|
||||
if (&*gpu_frame.as_ptr()).hw_frames_ctx.is_null() {
|
||||
cpu_frame = gpu_frame;
|
||||
} else {
|
||||
if ffi::av_hwframe_transfer_data(
|
||||
cpu_frame.as_mut_ptr(),
|
||||
gpu_frame.as_ptr(),
|
||||
0,
|
||||
) < 0
|
||||
{
|
||||
panic!("av_hwframe_transfer_data failed");
|
||||
}
|
||||
/*TODO: MAP OR TRANSFER?
|
||||
if ffi::av_hwframe_map(
|
||||
cpu_frame.as_mut_ptr(),
|
||||
gpu_frame.as_ptr(),
|
||||
ffi::AV_HWFRAME_MAP_READ as i32,
|
||||
) < 0
|
||||
{
|
||||
panic!("av_hwframe_map failed");
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
cpu_frame.set_pts(pts);
|
||||
cpu_frame_tx.send((cpu_frame, sync_time_opt)).unwrap();
|
||||
|
||||
let duration = start.elapsed();
|
||||
log::debug!("map gpu video frame to cpu at {:?} in {:?}", pts, duration);
|
||||
}
|
||||
})?;
|
||||
|
||||
// Sync channel to prevent getting too far behind
|
||||
let (video_packet_tx, video_packet_rx) = mpsc::sync_channel::<(Packet, Option<Instant>)>(2);
|
||||
thread::Builder::new()
|
||||
.name("video_decode".to_string())
|
||||
.spawn(move || {
|
||||
let mut eof = false;
|
||||
while !eof {
|
||||
let mut sync_time_opt = None;
|
||||
|
||||
{
|
||||
let packet_res = video_packet_rx.recv();
|
||||
|
||||
// Start timer after blocking recv
|
||||
let start = Instant::now();
|
||||
|
||||
let mut packet_pts = None;
|
||||
match packet_res {
|
||||
Ok((packet, time_opt)) => {
|
||||
packet_pts = packet.pts();
|
||||
sync_time_opt = time_opt;
|
||||
video_decoder.send_packet(&packet).unwrap();
|
||||
}
|
||||
Err(_err) => {
|
||||
video_decoder.send_eof().unwrap();
|
||||
eof = true;
|
||||
}
|
||||
}
|
||||
|
||||
let duration = start.elapsed();
|
||||
log::debug!("sent packet at {:?} in {:?}", packet_pts, duration);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let mut pts = None;
|
||||
let mut video_frames = 0;
|
||||
loop {
|
||||
let mut gpu_frame = Video::empty();
|
||||
if video_decoder.receive_frame(&mut gpu_frame).is_ok() {
|
||||
pts = gpu_frame.pts();
|
||||
gpu_frame_tx.send((gpu_frame, sync_time_opt)).unwrap();
|
||||
video_frames += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if video_frames > 0 {
|
||||
let duration = start.elapsed();
|
||||
log::debug!(
|
||||
"received {} video frames at {:?} in {:?}",
|
||||
video_frames,
|
||||
pts,
|
||||
duration
|
||||
);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
let audio_stream = ictx
|
||||
.streams()
|
||||
.best(Type::Audio)
|
||||
.ok_or(ffmpeg::Error::StreamNotFound)?;
|
||||
let audio_stream_index = audio_stream.index();
|
||||
let audio_time_base = f64::from(audio_stream.time_base());
|
||||
|
||||
let audio_context_decoder =
|
||||
codec::context::Context::from_parameters(audio_stream.parameters())?;
|
||||
let mut audio_decoder = audio_context_decoder.decoder().audio()?;
|
||||
|
||||
let mut audio_resampler = resampling::Context::get(
|
||||
audio_decoder.format(),
|
||||
audio_decoder.channel_layout(),
|
||||
audio_decoder.rate(),
|
||||
//TODO: support other formats?
|
||||
sample::Sample::F32(sample::Type::Packed),
|
||||
match audio_config.channels() {
|
||||
1 => channel_layout::ChannelLayout::MONO,
|
||||
2 => channel_layout::ChannelLayout::STEREO,
|
||||
//TODO: more channel configs
|
||||
unsupported => {
|
||||
panic!("unsupported audio channels {:?}", unsupported);
|
||||
}
|
||||
},
|
||||
audio_config.sample_rate().0,
|
||||
)?;
|
||||
|
||||
let min_sleep = Duration::from_millis(1);
|
||||
let min_skip = Duration::from_millis(1);
|
||||
let mut receive_and_process_decoded_audio_frames = |decoder: &mut ffmpeg::decoder::Audio,
|
||||
sync_time_opt: &mut Option<Instant>|
|
||||
-> Result<(), ffmpeg::Error> {
|
||||
let mut decoded = Audio::empty();
|
||||
let mut resampled = Audio::empty();
|
||||
let mut pts_opt = None;
|
||||
while decoder.receive_frame(&mut decoded).is_ok() {
|
||||
pts_opt = decoded.pts();
|
||||
|
||||
audio_resampler.run(&decoded, &mut resampled)?;
|
||||
{
|
||||
// plane method doesn't work with packed samples, so do it manually
|
||||
let plane = unsafe {
|
||||
slice::from_raw_parts(
|
||||
(*resampled.as_ptr()).data[0] as *const f32,
|
||||
resampled.samples() * resampled.channels() as usize,
|
||||
)
|
||||
};
|
||||
{
|
||||
let mut audio_queue = audio_queue_lock.lock().unwrap();
|
||||
audio_queue.data.extend(plane);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pts) = pts_opt {
|
||||
let expected_float = pts as f64 * audio_time_base;
|
||||
let expected = Duration::from_secs_f64(expected_float);
|
||||
if let Some(sync_time) = &sync_time_opt {
|
||||
// Sync with audio
|
||||
let actual = sync_time.elapsed();
|
||||
if expected > actual {
|
||||
let sleep = expected - actual;
|
||||
if sleep > min_sleep {
|
||||
// We leave min_sleep of buffer room
|
||||
log::debug!("audio ahead {:?}", sleep);
|
||||
}
|
||||
} else {
|
||||
let skip = actual - expected;
|
||||
if skip > min_skip {
|
||||
//TODO: handle frame skipping
|
||||
log::debug!("audio behind {:?}", skip);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Set up sync
|
||||
*sync_time_opt = Some(Instant::now() - expected);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
//TODO: dynamically choose this
|
||||
let buffer_duration = Duration::from_millis(250);
|
||||
|
||||
// Start CPAL stream
|
||||
cpal_stream.play()?;
|
||||
|
||||
let mut sync_time_opt = None;
|
||||
let mut seconds_opt = None;
|
||||
loop {
|
||||
let mut packet = Packet::empty();
|
||||
match packet.read(&mut ictx) {
|
||||
Ok(()) => {
|
||||
if packet.stream() == video_stream_index {
|
||||
video_packet_tx.send((packet, sync_time_opt)).unwrap();
|
||||
} else if packet.stream() == audio_stream_index {
|
||||
audio_decoder.send_packet(&packet)?;
|
||||
receive_and_process_decoded_audio_frames(
|
||||
&mut audio_decoder,
|
||||
&mut sync_time_opt,
|
||||
)?;
|
||||
if let Some(pts) = packet.pts() {
|
||||
seconds_opt = Some(pts as f64 * audio_time_base);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error::Error::Eof) => break,
|
||||
Err(_err) => {}
|
||||
}
|
||||
|
||||
let (audio_queue_duration, audio_queue_delay) = {
|
||||
let audio_queue = audio_queue_lock.lock().unwrap();
|
||||
(audio_queue.duration(), audio_queue.delay)
|
||||
};
|
||||
|
||||
let (video_queue_duration, video_queue_delay) = {
|
||||
let mut video_queue = video_queue_lock.lock().unwrap();
|
||||
let video_queue_duration = video_queue.duration();
|
||||
if video_queue_duration < buffer_duration {
|
||||
// If we do not have enough video queued, delay the video output
|
||||
video_queue.delay = buffer_duration - video_queue_duration;
|
||||
} else {
|
||||
video_queue.delay = Duration::default();
|
||||
}
|
||||
// Add audio queue delay to sync with audio
|
||||
video_queue.delay += audio_queue_delay;
|
||||
(video_queue_duration, video_queue.delay)
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"video: {:?}, {:?} audio: {:?}, {:?}",
|
||||
video_queue_duration,
|
||||
video_queue_delay,
|
||||
audio_queue_duration,
|
||||
audio_queue_delay
|
||||
);
|
||||
|
||||
let min_queue_duration = cmp::min(video_queue_duration, audio_queue_duration);
|
||||
if min_queue_duration > buffer_duration {
|
||||
// If we have enough queued, we can sleep
|
||||
let sleep = min_queue_duration - buffer_duration;
|
||||
log::debug!("sleep {:?}", sleep);
|
||||
thread::sleep(sleep);
|
||||
}
|
||||
|
||||
while let Ok(message) = player_rx.try_recv() {
|
||||
match message {
|
||||
PlayerMessage::SeekRelative(seek_seconds) => {
|
||||
if let Some(seconds) = seconds_opt {
|
||||
//TODO: use time base instead of hardcoded values
|
||||
let timestamp = ((seconds + seek_seconds) * 1000000.0) as i64;
|
||||
if seek_seconds.is_sign_negative() {
|
||||
println!(
|
||||
"backwards {} from {} = {}",
|
||||
seek_seconds, seconds, timestamp
|
||||
);
|
||||
ictx.seek(timestamp, ..timestamp)?;
|
||||
} else {
|
||||
println!("forwards {} from {} = {}", seek_seconds, seconds, timestamp);
|
||||
ictx.seek(timestamp, timestamp..)?;
|
||||
}
|
||||
|
||||
// Clear audio sync time
|
||||
sync_time_opt = None;
|
||||
// Clear audio and video queues
|
||||
{
|
||||
let mut audio_queue = audio_queue_lock.lock().unwrap();
|
||||
audio_queue.data.clear();
|
||||
}
|
||||
{
|
||||
//TODO: clear pending data stuck in channels
|
||||
let mut video_queue = video_queue_lock.lock().unwrap();
|
||||
video_queue.data.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audio_decoder.send_eof()?;
|
||||
receive_and_process_decoded_audio_frames(&mut audio_decoder, &mut sync_time_opt)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(path: PathBuf, config: Config) -> (mpsc::Sender<PlayerMessage>, Arc<Mutex<VideoQueue>>) {
|
||||
ffmpeg::init().unwrap();
|
||||
|
||||
let (player_tx, player_rx) = mpsc::channel();
|
||||
let video_queue_lock = Arc::new(Mutex::new(VideoQueue::new()));
|
||||
{
|
||||
let video_queue_lock = video_queue_lock.clone();
|
||||
thread::Builder::new()
|
||||
.name("ffmpeg".to_string())
|
||||
.spawn(move || {
|
||||
ffmpeg_thread(path, player_rx, video_queue_lock, config).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
(player_tx, video_queue_lock)
|
||||
}
|
||||
|
|
@ -1,585 +0,0 @@
|
|||
// 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, font,
|
||||
iced::{
|
||||
event::{self, Event},
|
||||
keyboard::{Event as KeyEvent, Key, Modifiers},
|
||||
mouse::Event as MouseEvent,
|
||||
subscription::Subscription,
|
||||
window, Alignment, Color, Length, Limits,
|
||||
},
|
||||
theme,
|
||||
widget::{self, Slider},
|
||||
Application, ApplicationExt, Element,
|
||||
};
|
||||
use iced_video_player::{
|
||||
gst::{self, prelude::*},
|
||||
gst_pbutils, Video, VideoPlayer,
|
||||
};
|
||||
use std::{
|
||||
any::TypeId,
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{Config, CONFIG_VERSION},
|
||||
key_bind::{key_binds, KeyBind},
|
||||
localize,
|
||||
};
|
||||
|
||||
static CONTROLS_TIMEOUT: Duration = Duration::new(2, 0);
|
||||
|
||||
const GST_PLAY_FLAG_VIDEO: i32 = 1 << 0;
|
||||
const GST_PLAY_FLAG_AUDIO: i32 = 1 << 1;
|
||||
const GST_PLAY_FLAG_TEXT: i32 = 1 << 2;
|
||||
|
||||
/// 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 {
|
||||
Fullscreen,
|
||||
PlayPause,
|
||||
SeekBackward,
|
||||
SeekForward,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn message(&self) -> Message {
|
||||
match self {
|
||||
Self::Fullscreen => Message::Fullscreen,
|
||||
Self::PlayPause => Message::PlayPause,
|
||||
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),
|
||||
Fullscreen,
|
||||
Key(Modifiers, Key),
|
||||
AudioCode(usize),
|
||||
TextCode(usize),
|
||||
PlayPause,
|
||||
Seek(f64),
|
||||
SeekRelative(f64),
|
||||
SeekRelease,
|
||||
EndOfStream,
|
||||
MissingPlugin(gst::Message),
|
||||
NewFrame,
|
||||
Reload,
|
||||
ShowControls,
|
||||
SystemThemeModeChange(cosmic_theme::ThemeMode),
|
||||
}
|
||||
|
||||
/// The [`App`] stores application-specific state.
|
||||
pub struct App {
|
||||
core: Core,
|
||||
flags: Flags,
|
||||
controls: bool,
|
||||
controls_time: Instant,
|
||||
fullscreen: bool,
|
||||
key_binds: HashMap<KeyBind, Action>,
|
||||
video_opt: Option<Video>,
|
||||
position: f64,
|
||||
duration: f64,
|
||||
dragging: bool,
|
||||
audio_codes: Vec<String>,
|
||||
current_audio: i32,
|
||||
text_codes: Vec<String>,
|
||||
current_text: i32,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn close(&mut self) {
|
||||
self.video_opt = None;
|
||||
self.position = 0.0;
|
||||
self.duration = 0.0;
|
||||
self.dragging = false;
|
||||
self.audio_codes = Vec::new();
|
||||
self.current_audio = -1;
|
||||
self.text_codes = Vec::new();
|
||||
self.current_text = -1;
|
||||
}
|
||||
|
||||
fn load(&mut self) -> Command<Message> {
|
||||
self.close();
|
||||
|
||||
let video = match Video::new(&self.flags.url) {
|
||||
Ok(ok) => ok,
|
||||
Err(err) => {
|
||||
log::warn!("failed to open {:?}: {err}", self.flags.url);
|
||||
return Command::none();
|
||||
}
|
||||
};
|
||||
self.duration = video.duration().as_secs_f64();
|
||||
let pipeline = video.pipeline();
|
||||
self.video_opt = Some(video);
|
||||
|
||||
let n_audio = pipeline.property::<i32>("n-audio");
|
||||
self.audio_codes = Vec::with_capacity(n_audio as usize);
|
||||
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::<gst::tags::LanguageCode>() {
|
||||
language_code.get().to_string()
|
||||
} else {
|
||||
format!("Audio #{i}")
|
||||
},
|
||||
);
|
||||
}
|
||||
self.current_audio = pipeline.property::<i32>("current-audio");
|
||||
|
||||
let n_text = pipeline.property::<i32>("n-text");
|
||||
self.text_codes = Vec::with_capacity(n_text as usize);
|
||||
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::<gst::tags::LanguageCode>() {
|
||||
language_code.get().to_string()
|
||||
} else {
|
||||
format!("Subtitle #{i}")
|
||||
},
|
||||
);
|
||||
}
|
||||
self.current_text = pipeline.property::<i32>("current-text");
|
||||
|
||||
//TODO: Flags can be used to enable/disable subtitles
|
||||
let flags_value = pipeline.property_value("flags");
|
||||
println!("original flags {:?}", flags_value);
|
||||
match flags_value.transform::<i32>() {
|
||||
Ok(flags_transform) => match flags_transform.get::<i32>() {
|
||||
Ok(mut flags) => {
|
||||
flags |= GST_PLAY_FLAG_VIDEO | GST_PLAY_FLAG_AUDIO | GST_PLAY_FLAG_TEXT;
|
||||
match gst::glib::Value::from(flags).transform_with_type(flags_value.type_()) {
|
||||
Ok(value) => pipeline.set_property("flags", value),
|
||||
Err(err) => {
|
||||
log::warn!("failed to transform int to flags: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("failed to get flags as int: {err}");
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
log::warn!("failed to transform flags to int: {err}");
|
||||
}
|
||||
}
|
||||
println!("updated flags {:?}", pipeline.property_value("flags"));
|
||||
|
||||
self.update_title()
|
||||
}
|
||||
|
||||
fn update_controls(&mut self, in_use: bool) {
|
||||
if in_use {
|
||||
self.controls = true;
|
||||
self.controls_time = Instant::now();
|
||||
} else if self.controls && self.controls_time.elapsed() > CONTROLS_TIMEOUT {
|
||||
self.controls = false;
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
//TODO: filename?
|
||||
let title = "COSMIC Media Player";
|
||||
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(mut core: Core, flags: Self::Flags) -> (Self, Command<Self::Message>) {
|
||||
core.window.content_container = false;
|
||||
|
||||
let mut app = App {
|
||||
core,
|
||||
flags,
|
||||
controls: true,
|
||||
controls_time: Instant::now(),
|
||||
fullscreen: false,
|
||||
key_binds: key_binds(),
|
||||
video_opt: None,
|
||||
position: 0.0,
|
||||
duration: 0.0,
|
||||
dragging: false,
|
||||
audio_codes: Vec::new(),
|
||||
current_audio: -1,
|
||||
text_codes: Vec::new(),
|
||||
current_text: -1,
|
||||
};
|
||||
|
||||
let command = app.load();
|
||||
(app, command)
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> Command<Self::Message> {
|
||||
if self.fullscreen {
|
||||
return self.update(Message::Fullscreen);
|
||||
} else {
|
||||
Command::none()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::Fullscreen => {
|
||||
self.fullscreen = !self.fullscreen;
|
||||
self.core.window.show_headerbar = !self.fullscreen;
|
||||
return window::change_mode(
|
||||
window::Id::MAIN,
|
||||
if self.fullscreen {
|
||||
window::Mode::Fullscreen
|
||||
} else {
|
||||
window::Mode::Windowed
|
||||
},
|
||||
);
|
||||
}
|
||||
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::AudioCode(code) => {
|
||||
if let Ok(code) = i32::try_from(code) {
|
||||
if let Some(video) = &self.video_opt {
|
||||
let pipeline = video.pipeline();
|
||||
pipeline.set_property("current-audio", code);
|
||||
self.current_audio = pipeline.property("current-audio");
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::TextCode(code) => {
|
||||
if let Ok(code) = i32::try_from(code) {
|
||||
if let Some(video) = &self.video_opt {
|
||||
let pipeline = video.pipeline();
|
||||
pipeline.set_property("current-text", code);
|
||||
self.current_text = pipeline.property("current-text");
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::PlayPause => {
|
||||
if let Some(video) = &mut self.video_opt {
|
||||
video.set_paused(!video.paused());
|
||||
self.update_controls(true);
|
||||
}
|
||||
}
|
||||
Message::Seek(secs) => {
|
||||
if let Some(video) = &mut self.video_opt {
|
||||
self.dragging = true;
|
||||
self.position = secs;
|
||||
video.set_paused(true);
|
||||
let duration = Duration::try_from_secs_f64(self.position).unwrap_or_default();
|
||||
video.seek(duration, true).expect("seek");
|
||||
self.update_controls(true);
|
||||
}
|
||||
}
|
||||
Message::SeekRelative(secs) => {
|
||||
if let Some(video) = &mut self.video_opt {
|
||||
self.position = video.position().as_secs_f64();
|
||||
let duration =
|
||||
Duration::try_from_secs_f64(self.position + secs).unwrap_or_default();
|
||||
video.seek(duration, true).expect("seek");
|
||||
}
|
||||
}
|
||||
Message::SeekRelease => {
|
||||
if let Some(video) = &mut self.video_opt {
|
||||
self.dragging = false;
|
||||
let duration = Duration::try_from_secs_f64(self.position).unwrap_or_default();
|
||||
video.seek(duration, true).expect("seek");
|
||||
video.set_paused(false);
|
||||
self.update_controls(true);
|
||||
}
|
||||
}
|
||||
Message::EndOfStream => {
|
||||
println!("end of stream");
|
||||
}
|
||||
Message::MissingPlugin(element) => {
|
||||
if let Some(video) = &mut self.video_opt {
|
||||
video.set_paused(true);
|
||||
}
|
||||
return Command::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
match gst_pbutils::MissingPluginMessage::parse(&element) {
|
||||
Ok(missing_plugin) => {
|
||||
let mut install_ctx = gst_pbutils::InstallPluginsContext::new();
|
||||
install_ctx
|
||||
.set_desktop_id(&format!("{}.desktop", Self::APP_ID));
|
||||
let install_detail = missing_plugin.installer_detail();
|
||||
println!("installing plugins: {}", install_detail);
|
||||
let status = gst_pbutils::missing_plugins::install_plugins_sync(
|
||||
&[&install_detail],
|
||||
Some(&install_ctx),
|
||||
);
|
||||
log::info!("plugin install status: {}", status);
|
||||
log::info!(
|
||||
"gstreamer registry update: {:?}",
|
||||
gst::Registry::update()
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("failed to parse missing plugin message: {err}");
|
||||
}
|
||||
}
|
||||
message::app(Message::Reload)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
},
|
||||
|x| x,
|
||||
);
|
||||
}
|
||||
Message::NewFrame => {
|
||||
if let Some(video) = &self.video_opt {
|
||||
if !self.dragging {
|
||||
self.position = video.position().as_secs_f64();
|
||||
self.update_controls(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Reload => {
|
||||
return self.load();
|
||||
}
|
||||
Message::ShowControls => {
|
||||
self.update_controls(true);
|
||||
}
|
||||
Message::SystemThemeModeChange(_theme_mode) => {
|
||||
return self.update_config();
|
||||
}
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
|
||||
fn header_start(&self) -> Vec<Element<Self::Message>> {
|
||||
let mut row = widget::row::with_capacity(4)
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(8);
|
||||
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));
|
||||
row = row.push(widget::dropdown(
|
||||
&self.audio_codes,
|
||||
usize::try_from(self.current_audio).ok(),
|
||||
Message::AudioCode,
|
||||
));
|
||||
}
|
||||
if !self.text_codes.is_empty() {
|
||||
//TODO: allow toggling subtitles
|
||||
row = row.push(widget::icon::from_name("media-view-subtitles-symbolic").size(16));
|
||||
row = row.push(widget::dropdown(
|
||||
&self.text_codes,
|
||||
usize::try_from(self.current_text).ok(),
|
||||
Message::TextCode,
|
||||
));
|
||||
}
|
||||
vec![row.into()]
|
||||
}
|
||||
|
||||
/// Creates a view after each update.
|
||||
fn view(&self) -> Element<Self::Message> {
|
||||
let format_time = |time_float: f64| -> String {
|
||||
let time = time_float.floor() as i64;
|
||||
let seconds = time % 60;
|
||||
let minutes = (time / 60) % 60;
|
||||
let hours = (time / 60) / 60;
|
||||
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
||||
};
|
||||
|
||||
let Some(video) = &self.video_opt else {
|
||||
//TODO: open button if no video?
|
||||
return widget::container(widget::text("No video open"))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(theme::Container::WindowBackground)
|
||||
.into();
|
||||
};
|
||||
|
||||
let video_player = VideoPlayer::new(video)
|
||||
.mouse_hidden(!self.controls)
|
||||
.on_end_of_stream(Message::EndOfStream)
|
||||
.on_missing_plugin(Message::MissingPlugin)
|
||||
.on_new_frame(Message::NewFrame)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let mouse_area = widget::mouse_area(video_player).on_double_press(Message::Fullscreen);
|
||||
|
||||
let mut popover = widget::popover(mouse_area).position(widget::popover::Position::Bottom);
|
||||
if self.controls {
|
||||
popover = popover.popup(
|
||||
widget::container(
|
||||
widget::row::with_capacity(5)
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(8)
|
||||
.padding([0, 8])
|
||||
.push(
|
||||
widget::button::icon(
|
||||
if self.video_opt.as_ref().map_or(true, |video| 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::PlayPause),
|
||||
)
|
||||
.push(widget::text(format_time(self.position)).font(font::mono()))
|
||||
.push(
|
||||
Slider::new(0.0..=self.duration, self.position, Message::Seek)
|
||||
.step(0.1)
|
||||
.on_release(Message::SeekRelease),
|
||||
)
|
||||
.push(
|
||||
widget::text(format_time(self.duration - self.position))
|
||||
.font(font::mono()),
|
||||
)
|
||||
.push(
|
||||
widget::button::icon(
|
||||
widget::icon::from_name("view-fullscreen-symbolic").size(16),
|
||||
)
|
||||
.on_press(Message::Fullscreen),
|
||||
),
|
||||
)
|
||||
.style(theme::Container::WindowBackground),
|
||||
);
|
||||
}
|
||||
|
||||
widget::container(popover)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(theme::Container::Custom(Box::new(|_theme| {
|
||||
widget::container::Appearance::default().with_background(Color::BLACK)
|
||||
})))
|
||||
.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))
|
||||
}
|
||||
Event::Mouse(MouseEvent::CursorMoved { .. }) => Some(Message::ShowControls),
|
||||
_ => 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::app::Action;
|
||||
use crate::Action;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
pub enum Modifier {
|
||||
|
|
|
|||
586
src/main.rs
586
src/main.rs
|
|
@ -1,18 +1,588 @@
|
|||
// 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, font,
|
||||
iced::{
|
||||
event::{self, Event},
|
||||
keyboard::{Event as KeyEvent, Key, Modifiers},
|
||||
mouse::Event as MouseEvent,
|
||||
subscription::Subscription,
|
||||
window, Alignment, Color, Length, Limits,
|
||||
},
|
||||
theme,
|
||||
widget::{self, Slider},
|
||||
Application, ApplicationExt, Element,
|
||||
};
|
||||
use iced_video_player::{
|
||||
gst::{self, prelude::*},
|
||||
gst_pbutils, Video, VideoPlayer,
|
||||
};
|
||||
use std::{
|
||||
any::TypeId,
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{Config, CONFIG_VERSION},
|
||||
key_bind::{key_binds, KeyBind},
|
||||
};
|
||||
|
||||
mod config;
|
||||
mod key_bind;
|
||||
mod localize;
|
||||
|
||||
#[cfg(feature = "ffmpeg")]
|
||||
#[path = "ffmpeg/mod.rs"]
|
||||
mod app;
|
||||
static CONTROLS_TIMEOUT: Duration = Duration::new(2, 0);
|
||||
|
||||
#[cfg(feature = "gstreamer")]
|
||||
#[path = "gstreamer/mod.rs"]
|
||||
mod app;
|
||||
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 main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
app::main()
|
||||
/// 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 {
|
||||
Fullscreen,
|
||||
PlayPause,
|
||||
SeekBackward,
|
||||
SeekForward,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn message(&self) -> Message {
|
||||
match self {
|
||||
Self::Fullscreen => Message::Fullscreen,
|
||||
Self::PlayPause => Message::PlayPause,
|
||||
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),
|
||||
Fullscreen,
|
||||
Key(Modifiers, Key),
|
||||
AudioCode(usize),
|
||||
TextCode(usize),
|
||||
PlayPause,
|
||||
Seek(f64),
|
||||
SeekRelative(f64),
|
||||
SeekRelease,
|
||||
EndOfStream,
|
||||
MissingPlugin(gst::Message),
|
||||
NewFrame,
|
||||
Reload,
|
||||
ShowControls,
|
||||
SystemThemeModeChange(cosmic_theme::ThemeMode),
|
||||
}
|
||||
|
||||
/// The [`App`] stores application-specific state.
|
||||
pub struct App {
|
||||
core: Core,
|
||||
flags: Flags,
|
||||
controls: bool,
|
||||
controls_time: Instant,
|
||||
fullscreen: bool,
|
||||
key_binds: HashMap<KeyBind, Action>,
|
||||
video_opt: Option<Video>,
|
||||
position: f64,
|
||||
duration: f64,
|
||||
dragging: bool,
|
||||
audio_codes: Vec<String>,
|
||||
current_audio: i32,
|
||||
text_codes: Vec<String>,
|
||||
current_text: i32,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn close(&mut self) {
|
||||
self.video_opt = None;
|
||||
self.position = 0.0;
|
||||
self.duration = 0.0;
|
||||
self.dragging = false;
|
||||
self.audio_codes = Vec::new();
|
||||
self.current_audio = -1;
|
||||
self.text_codes = Vec::new();
|
||||
self.current_text = -1;
|
||||
}
|
||||
|
||||
fn load(&mut self) -> Command<Message> {
|
||||
self.close();
|
||||
|
||||
let video = match Video::new(&self.flags.url) {
|
||||
Ok(ok) => ok,
|
||||
Err(err) => {
|
||||
log::warn!("failed to open {:?}: {err}", self.flags.url);
|
||||
return Command::none();
|
||||
}
|
||||
};
|
||||
self.duration = video.duration().as_secs_f64();
|
||||
let pipeline = video.pipeline();
|
||||
self.video_opt = Some(video);
|
||||
|
||||
let n_audio = pipeline.property::<i32>("n-audio");
|
||||
self.audio_codes = Vec::with_capacity(n_audio as usize);
|
||||
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::<gst::tags::LanguageCode>() {
|
||||
language_code.get().to_string()
|
||||
} else {
|
||||
format!("Audio #{i}")
|
||||
},
|
||||
);
|
||||
}
|
||||
self.current_audio = pipeline.property::<i32>("current-audio");
|
||||
|
||||
let n_text = pipeline.property::<i32>("n-text");
|
||||
self.text_codes = Vec::with_capacity(n_text as usize);
|
||||
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::<gst::tags::LanguageCode>() {
|
||||
language_code.get().to_string()
|
||||
} else {
|
||||
format!("Subtitle #{i}")
|
||||
},
|
||||
);
|
||||
}
|
||||
self.current_text = pipeline.property::<i32>("current-text");
|
||||
|
||||
//TODO: Flags can be used to enable/disable subtitles
|
||||
let flags_value = pipeline.property_value("flags");
|
||||
println!("original flags {:?}", flags_value);
|
||||
match flags_value.transform::<i32>() {
|
||||
Ok(flags_transform) => match flags_transform.get::<i32>() {
|
||||
Ok(mut flags) => {
|
||||
flags |= GST_PLAY_FLAG_VIDEO | GST_PLAY_FLAG_AUDIO | GST_PLAY_FLAG_TEXT;
|
||||
match gst::glib::Value::from(flags).transform_with_type(flags_value.type_()) {
|
||||
Ok(value) => pipeline.set_property("flags", value),
|
||||
Err(err) => {
|
||||
log::warn!("failed to transform int to flags: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("failed to get flags as int: {err}");
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
log::warn!("failed to transform flags to int: {err}");
|
||||
}
|
||||
}
|
||||
println!("updated flags {:?}", pipeline.property_value("flags"));
|
||||
|
||||
self.update_title()
|
||||
}
|
||||
|
||||
fn update_controls(&mut self, in_use: bool) {
|
||||
if in_use {
|
||||
self.controls = true;
|
||||
self.controls_time = Instant::now();
|
||||
} else if self.controls && self.controls_time.elapsed() > CONTROLS_TIMEOUT {
|
||||
self.controls = false;
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
//TODO: filename?
|
||||
let title = "COSMIC Media Player";
|
||||
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(mut core: Core, flags: Self::Flags) -> (Self, Command<Self::Message>) {
|
||||
core.window.content_container = false;
|
||||
|
||||
let mut app = App {
|
||||
core,
|
||||
flags,
|
||||
controls: true,
|
||||
controls_time: Instant::now(),
|
||||
fullscreen: false,
|
||||
key_binds: key_binds(),
|
||||
video_opt: None,
|
||||
position: 0.0,
|
||||
duration: 0.0,
|
||||
dragging: false,
|
||||
audio_codes: Vec::new(),
|
||||
current_audio: -1,
|
||||
text_codes: Vec::new(),
|
||||
current_text: -1,
|
||||
};
|
||||
|
||||
let command = app.load();
|
||||
(app, command)
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> Command<Self::Message> {
|
||||
if self.fullscreen {
|
||||
return self.update(Message::Fullscreen);
|
||||
} else {
|
||||
Command::none()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::Fullscreen => {
|
||||
self.fullscreen = !self.fullscreen;
|
||||
self.core.window.show_headerbar = !self.fullscreen;
|
||||
return window::change_mode(
|
||||
window::Id::MAIN,
|
||||
if self.fullscreen {
|
||||
window::Mode::Fullscreen
|
||||
} else {
|
||||
window::Mode::Windowed
|
||||
},
|
||||
);
|
||||
}
|
||||
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::AudioCode(code) => {
|
||||
if let Ok(code) = i32::try_from(code) {
|
||||
if let Some(video) = &self.video_opt {
|
||||
let pipeline = video.pipeline();
|
||||
pipeline.set_property("current-audio", code);
|
||||
self.current_audio = pipeline.property("current-audio");
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::TextCode(code) => {
|
||||
if let Ok(code) = i32::try_from(code) {
|
||||
if let Some(video) = &self.video_opt {
|
||||
let pipeline = video.pipeline();
|
||||
pipeline.set_property("current-text", code);
|
||||
self.current_text = pipeline.property("current-text");
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::PlayPause => {
|
||||
if let Some(video) = &mut self.video_opt {
|
||||
video.set_paused(!video.paused());
|
||||
self.update_controls(true);
|
||||
}
|
||||
}
|
||||
Message::Seek(secs) => {
|
||||
if let Some(video) = &mut self.video_opt {
|
||||
self.dragging = true;
|
||||
self.position = secs;
|
||||
video.set_paused(true);
|
||||
let duration = Duration::try_from_secs_f64(self.position).unwrap_or_default();
|
||||
video.seek(duration, true).expect("seek");
|
||||
self.update_controls(true);
|
||||
}
|
||||
}
|
||||
Message::SeekRelative(secs) => {
|
||||
if let Some(video) = &mut self.video_opt {
|
||||
self.position = video.position().as_secs_f64();
|
||||
let duration =
|
||||
Duration::try_from_secs_f64(self.position + secs).unwrap_or_default();
|
||||
video.seek(duration, true).expect("seek");
|
||||
}
|
||||
}
|
||||
Message::SeekRelease => {
|
||||
if let Some(video) = &mut self.video_opt {
|
||||
self.dragging = false;
|
||||
let duration = Duration::try_from_secs_f64(self.position).unwrap_or_default();
|
||||
video.seek(duration, true).expect("seek");
|
||||
video.set_paused(false);
|
||||
self.update_controls(true);
|
||||
}
|
||||
}
|
||||
Message::EndOfStream => {
|
||||
println!("end of stream");
|
||||
}
|
||||
Message::MissingPlugin(element) => {
|
||||
if let Some(video) = &mut self.video_opt {
|
||||
video.set_paused(true);
|
||||
}
|
||||
return Command::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
match gst_pbutils::MissingPluginMessage::parse(&element) {
|
||||
Ok(missing_plugin) => {
|
||||
let mut install_ctx = gst_pbutils::InstallPluginsContext::new();
|
||||
install_ctx
|
||||
.set_desktop_id(&format!("{}.desktop", Self::APP_ID));
|
||||
let install_detail = missing_plugin.installer_detail();
|
||||
println!("installing plugins: {}", install_detail);
|
||||
let status = gst_pbutils::missing_plugins::install_plugins_sync(
|
||||
&[&install_detail],
|
||||
Some(&install_ctx),
|
||||
);
|
||||
log::info!("plugin install status: {}", status);
|
||||
log::info!(
|
||||
"gstreamer registry update: {:?}",
|
||||
gst::Registry::update()
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("failed to parse missing plugin message: {err}");
|
||||
}
|
||||
}
|
||||
message::app(Message::Reload)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
},
|
||||
|x| x,
|
||||
);
|
||||
}
|
||||
Message::NewFrame => {
|
||||
if let Some(video) = &self.video_opt {
|
||||
if !self.dragging {
|
||||
self.position = video.position().as_secs_f64();
|
||||
self.update_controls(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Reload => {
|
||||
return self.load();
|
||||
}
|
||||
Message::ShowControls => {
|
||||
self.update_controls(true);
|
||||
}
|
||||
Message::SystemThemeModeChange(_theme_mode) => {
|
||||
return self.update_config();
|
||||
}
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
|
||||
fn header_start(&self) -> Vec<Element<Self::Message>> {
|
||||
let mut row = widget::row::with_capacity(4)
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(8);
|
||||
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));
|
||||
row = row.push(widget::dropdown(
|
||||
&self.audio_codes,
|
||||
usize::try_from(self.current_audio).ok(),
|
||||
Message::AudioCode,
|
||||
));
|
||||
}
|
||||
if !self.text_codes.is_empty() {
|
||||
//TODO: allow toggling subtitles
|
||||
row = row.push(widget::icon::from_name("media-view-subtitles-symbolic").size(16));
|
||||
row = row.push(widget::dropdown(
|
||||
&self.text_codes,
|
||||
usize::try_from(self.current_text).ok(),
|
||||
Message::TextCode,
|
||||
));
|
||||
}
|
||||
vec![row.into()]
|
||||
}
|
||||
|
||||
/// Creates a view after each update.
|
||||
fn view(&self) -> Element<Self::Message> {
|
||||
let format_time = |time_float: f64| -> String {
|
||||
let time = time_float.floor() as i64;
|
||||
let seconds = time % 60;
|
||||
let minutes = (time / 60) % 60;
|
||||
let hours = (time / 60) / 60;
|
||||
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
||||
};
|
||||
|
||||
let Some(video) = &self.video_opt else {
|
||||
//TODO: open button if no video?
|
||||
return widget::container(widget::text("No video open"))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(theme::Container::WindowBackground)
|
||||
.into();
|
||||
};
|
||||
|
||||
let video_player = VideoPlayer::new(video)
|
||||
.mouse_hidden(!self.controls)
|
||||
.on_end_of_stream(Message::EndOfStream)
|
||||
.on_missing_plugin(Message::MissingPlugin)
|
||||
.on_new_frame(Message::NewFrame)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let mouse_area = widget::mouse_area(video_player).on_double_press(Message::Fullscreen);
|
||||
|
||||
let mut popover = widget::popover(mouse_area).position(widget::popover::Position::Bottom);
|
||||
if self.controls {
|
||||
popover = popover.popup(
|
||||
widget::container(
|
||||
widget::row::with_capacity(5)
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(8)
|
||||
.padding([0, 8])
|
||||
.push(
|
||||
widget::button::icon(
|
||||
if self.video_opt.as_ref().map_or(true, |video| 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::PlayPause),
|
||||
)
|
||||
.push(widget::text(format_time(self.position)).font(font::mono()))
|
||||
.push(
|
||||
Slider::new(0.0..=self.duration, self.position, Message::Seek)
|
||||
.step(0.1)
|
||||
.on_release(Message::SeekRelease),
|
||||
)
|
||||
.push(
|
||||
widget::text(format_time(self.duration - self.position))
|
||||
.font(font::mono()),
|
||||
)
|
||||
.push(
|
||||
widget::button::icon(
|
||||
widget::icon::from_name("view-fullscreen-symbolic").size(16),
|
||||
)
|
||||
.on_press(Message::Fullscreen),
|
||||
),
|
||||
)
|
||||
.style(theme::Container::WindowBackground),
|
||||
);
|
||||
}
|
||||
|
||||
widget::container(popover)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(theme::Container::Custom(Box::new(|_theme| {
|
||||
widget::container::Appearance::default().with_background(Color::BLACK)
|
||||
})))
|
||||
.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))
|
||||
}
|
||||
Event::Mouse(MouseEvent::CursorMoved { .. }) => Some(Message::ShowControls),
|
||||
_ => 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)
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue