Convert to libcosmic and parse audio

This commit is contained in:
Jeremy Soller 2024-01-24 14:31:39 -07:00
parent 31a352c524
commit 94a1244c6d
No known key found for this signature in database
GPG key ID: DCFCA852D3906975
18 changed files with 5458 additions and 316 deletions

4612
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,33 @@ version = "0.1.0"
edition = "2021"
[dependencies]
cpal = "0.15"
ffmpeg-next = "6"
softbuffer = "0.4"
winit = "0.29"
lazy_static = "1"
paste = "1"
serde = { version = "1", features = ["serde_derive"] }
tokio = "1"
# Internationalization
i18n-embed = { version = "0.13", features = ["fluent-system", "desktop-requester"] }
i18n-embed-fl = "0.6"
rust-embed = "6"
# Logging
env_logger = "0.10"
log = "0.4"
[dependencies.libcosmic]
git = "https://github.com/pop-os/libcosmic.git"
default-features = false
features = ["tokio", "winit"]
#path = "../libcosmic"
[features]
default = ["wgpu"]
wgpu = ["libcosmic/wgpu"]
[patch.crates-io]
smithay-client-toolkit = { git = "https://github.com/pop-os/client-toolkit", branch = "wayland-resize" }
[profile.release-with-debug]
inherits = "release"
debug = true

View file

@ -1,2 +1,10 @@
# cosmic-player
WIP COSMIC media player
## Dependencies
For debian-based systems:
```
sudo apt-get install clang libavcodec-dev libavdevice-dev libavfilter-dev libavformat-dev libavutil-dev pkg-config
```

5
debian/changelog vendored Normal file
View file

@ -0,0 +1,5 @@
cosmic-player (0.1.0) jammy; urgency=medium
* Initial release.
-- Jeremy Soller <jeremy@system76.com> Wed, 24 Jan 2024 07:22:06 -0700

22
debian/control vendored Normal file
View file

@ -0,0 +1,22 @@
Source: cosmic-player
Section: admin
Priority: optional
Maintainer: Jeremy Soller <jeremy@system76.com>
Build-Depends:
clang,
debhelper-compat (=13),
just (>= 1.13.0),
libavcodec-dev,
libavdevice-dev,
libavfilter-dev,
libavformat-dev,
libavutil-dev,
pkg-config,
rust-all,
Standards-Version: 4.6.2
Homepage: https://github.com/pop-os/cosmic-player
Package: cosmic-player
Architecture: amd64 arm64
Depends: ${misc:Depends}, ${shlibs:Depends}
Description: Cosmic Media Player

7
debian/copyright vendored Normal file
View file

@ -0,0 +1,7 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: cosmic-player
Upstream-Contact: Jeremy Soller <jeremy@system76.com>
Source: https://github.com/pop-os/cosmic-player
Files: *
Copyright: System76 <info@system76.com>
License: GPL-3.0

22
debian/rules vendored Normal file
View file

@ -0,0 +1,22 @@
#!/usr/bin/make -f
export DESTDIR = debian/cosmic-player
export VENDOR ?= 1
%:
dh $@
override_dh_auto_clean:
if ! ischroot && test "${VENDOR}" = "1"; then \
mkdir -p .cargo; \
cargo vendor | head -n -1 > .cargo/config; \
echo 'directory = "vendor"' >> .cargo/config; \
tar pcf vendor.tar vendor; \
rm -rf vendor; \
fi
override_dh_auto_build:
just build-vendored
override_dh_auto_install:
just rootdir=$(DESTDIR) install

1
debian/source/format vendored Normal file
View file

@ -0,0 +1 @@
3.0 (native)

4
debian/source/options vendored Normal file
View file

@ -0,0 +1,4 @@
tar-ignore=.github
tar-ignore=.vscode
tar-ignore=vendor
tar-ignore=target

4
i18n.toml Normal file
View file

@ -0,0 +1,4 @@
fallback_language = "en"
[fluent]
assets_dir = "i18n"

11
i18n/en/cosmic_player.ftl Normal file
View file

@ -0,0 +1,11 @@
# Context Pages
## Settings
settings = Settings
### Appearance
appearance = Appearance
theme = Theme
match-desktop = Match desktop
dark = Dark
light = Light

78
justfile Normal file
View file

@ -0,0 +1,78 @@
name := 'cosmic-player'
export APPID := 'com.system76.CosmicPlayer'
rootdir := ''
prefix := '/usr'
base-dir := absolute_path(clean(rootdir / prefix))
export INSTALL_DIR := base-dir / 'share'
bin-src := 'target' / 'release' / name
bin-dst := base-dir / 'bin' / name
desktop := APPID + '.desktop'
desktop-src := 'res' / desktop
desktop-dst := clean(rootdir / prefix) / 'share' / 'applications' / desktop
# Default recipe which runs `just build-release`
default: build-release
# Runs `cargo clean`
clean:
cargo clean
# Removes vendored dependencies
clean-vendor:
rm -rf .cargo vendor vendor.tar
# `cargo clean` and removes vendored dependencies
clean-dist: clean clean-vendor
# Compiles with debug profile
build-debug *args:
cargo build {{args}}
# Compiles with release profile
build-release *args: (build-debug '--release' args)
# Compiles release profile with vendored dependencies
build-vendored *args: vendor-extract (build-release '--frozen --offline' args)
# Runs a clippy check
check *args:
cargo clippy --all-features {{args}} -- -W clippy::pedantic
# Runs a clippy check with JSON message format
check-json: (check '--message-format=json')
# Profile memory usage with heaptrack
heaptrack:
cargo heaptrack --profile release-with-debug
# Run with debug logs
run *args:
env RUST_LOG=cosmic_player=info RUST_BACKTRACE=full cargo run --release {{args}}
# Installs files
install:
install -Dm0755 {{bin-src}} {{bin-dst}}
install -Dm0755 {{desktop-src}} {{desktop-dst}}
# Uninstalls installed files
uninstall:
rm {{bin-dst}}
# Vendor dependencies locally
vendor:
mkdir -p .cargo
cargo vendor --sync Cargo.toml \
| head -n -1 > .cargo/config
echo 'directory = "vendor"' >> .cargo/config
tar pcf vendor.tar vendor
rm -rf vendor
# Extracts vendored dependencies
vendor-extract:
rm -rf vendor
tar pxf vendor.tar

View file

@ -0,0 +1,10 @@
#TODO: more build-out, desktop actions, translations?
[Desktop Entry]
Name=COSMIC Media Player
Exec=cosmic-player %F
Terminal=false
Type=Application
StartupNotify=true
#TODO Icon=
Categories=COSMIC;AudioVideo;Player;Video;
Keywords=Audio;Film;Movie;Music;Sound;Video;

39
src/config.rs Normal file
View file

@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::{
cosmic_config::{self, cosmic_config_derive::CosmicConfigEntry, CosmicConfigEntry},
theme,
};
use serde::{Deserialize, Serialize};
pub const CONFIG_VERSION: u64 = 1;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum AppTheme {
Dark,
Light,
System,
}
impl AppTheme {
pub fn theme(&self) -> theme::Theme {
match self {
Self::Dark => theme::Theme::dark(),
Self::Light => theme::Theme::light(),
Self::System => theme::system_preference(),
}
}
}
#[derive(Clone, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Config {
pub app_theme: AppTheme,
}
impl Default for Config {
fn default() -> Self {
Self {
app_theme: AppTheme::System,
}
}
}

322
src/ffmpeg.rs Normal file
View file

@ -0,0 +1,322 @@
extern crate ffmpeg_next as ffmpeg;
use cosmic::widget;
use cpal::{
traits::{DeviceTrait, HostTrait, StreamTrait},
FromSample, SizedSample,
};
use ffmpeg::{
format::{input, Pixel},
media::Type,
software::{resampling, scaling},
util::{
channel_layout,
format::sample,
frame::{audio::Audio, video::Video},
},
};
use std::{
collections::VecDeque,
error::Error,
path::{Path, PathBuf},
slice,
sync::{mpsc, Arc, Mutex},
thread,
time::Instant,
};
pub struct VideoFrame(Video);
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)
}
}
fn cpal(audio_queue_lock: Arc<Mutex<VecDeque<f32>>>) -> cpal::SupportedStreamConfig {
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 config = config.clone();
thread::spawn(move || {
match config.sample_format() {
cpal::SampleFormat::I8 => {
cpal_thread::<i8>(device, config.into(), audio_queue_lock)
}
cpal::SampleFormat::I16 => {
cpal_thread::<i16>(device, config.into(), audio_queue_lock)
}
// cpal::SampleFormat::I24 => cpal_thread::<I24>(device, config.into(), audio_queue_lock),
cpal::SampleFormat::I32 => {
cpal_thread::<i32>(device, config.into(), audio_queue_lock)
}
// cpal::SampleFormat::I48 => cpal_thread::<I48>(device, config.into(), audio_queue_lock),
cpal::SampleFormat::I64 => {
cpal_thread::<i64>(device, config.into(), audio_queue_lock)
}
cpal::SampleFormat::U8 => {
cpal_thread::<u8>(device, config.into(), audio_queue_lock)
}
cpal::SampleFormat::U16 => {
cpal_thread::<u16>(device, config.into(), audio_queue_lock)
}
// cpal::SampleFormat::U24 => cpal_thread::<U24>(device, config.into(), audio_queue_lock),
cpal::SampleFormat::U32 => {
cpal_thread::<u32>(device, config.into(), audio_queue_lock)
}
// cpal::SampleFormat::U48 => cpal_thread::<U48>(device, config.into(), audio_queue_lock),
cpal::SampleFormat::U64 => {
cpal_thread::<u64>(device, config.into(), audio_queue_lock)
}
cpal::SampleFormat::F32 => {
cpal_thread::<f32>(device, config.into(), audio_queue_lock)
}
cpal::SampleFormat::F64 => {
cpal_thread::<f64>(device, config.into(), audio_queue_lock)
}
sample_format => panic!("unsupported sample format '{sample_format}'"),
}
.unwrap();
});
}
config
}
fn cpal_thread<T>(
device: cpal::Device,
config: cpal::StreamConfig,
audio_queue_lock: Arc<Mutex<VecDeque<f32>>>,
) -> Result<(), Box<dyn Error>>
where
T: SizedSample + FromSample<f32>,
{
let data_fn = {
let audio_queue_lock = audio_queue_lock.clone();
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
let mut underrun = 0;
{
//TODO: buffer audio
let mut audio_queue = audio_queue_lock.lock().unwrap();
for sample in data {
let float = match audio_queue.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)?;
stream.play()?;
loop {
//TODO: move this code to ffmpeg_thread so we don't have to sleep here?
std::thread::sleep(std::time::Duration::from_millis(1000));
}
Ok(())
}
fn ffmpeg_thread<P: AsRef<Path>>(
path: P,
video_frame_lock: Arc<Mutex<Option<VideoFrame>>>,
audio_config: cpal::SupportedStreamConfig,
audio_queue_lock: Arc<Mutex<VecDeque<f32>>>,
) -> Result<(), ffmpeg::Error> {
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_context_decoder =
ffmpeg::codec::context::Context::from_parameters(video_stream.parameters())?;
let mut video_decoder = video_context_decoder.decoder().video()?;
let video_format = video_decoder.format();
let video_width = video_decoder.width();
let video_height = video_decoder.height();
let mut video_frame_count = 0;
let (raw_frame_tx, raw_frame_rx) = mpsc::channel();
thread::spawn(move || -> Result<(), ffmpeg::Error> {
let mut video_scaler = scaling::context::Context::get(
video_format,
video_width,
video_height,
Pixel::RGBA,
video_width,
video_height,
scaling::Flags::FAST_BILINEAR,
)?;
loop {
let start = Instant::now();
let mut video_frames = 0;
let mut scaled_frame = Video::empty();
while let Ok(raw_frame) = raw_frame_rx.try_recv() {
video_scaler.run(&raw_frame, &mut scaled_frame)?;
video_frames += 1;
}
if video_frames > 0 {
let missed = {
let mut video_frame_opt = video_frame_lock.lock().unwrap();
let missed = video_frame_opt.is_some();
*video_frame_opt = Some(VideoFrame(scaled_frame));
missed
};
if missed {
log::warn!("missed scaled video frame at {}", video_frame_count);
}
if video_frames > 1 {
log::warn!(
"missed {} raw video frame at {}",
video_frames - 1,
video_frame_count
);
}
video_frame_count += video_frames;
let duration = start.elapsed();
log::debug!("scaled {} video frames in {:?}", video_frames, duration);
}
}
});
let mut receive_and_process_decoded_video_frames =
|decoder: &mut ffmpeg::decoder::Video| -> Result<(), ffmpeg::Error> {
let start = Instant::now();
let mut video_frames = 0;
loop {
let mut decoded = Video::empty();
if decoder.receive_frame(&mut decoded).is_ok() {
raw_frame_tx.send(decoded).unwrap();
video_frames += 1;
} else {
break;
}
}
if video_frames > 0 {
let duration = start.elapsed();
log::debug!("received {} video frames in {:?}", video_frames, duration);
}
Ok(())
};
let audio_stream = ictx
.streams()
.best(Type::Audio)
.ok_or(ffmpeg::Error::StreamNotFound)?;
let audio_stream_index = audio_stream.index();
let audio_context_decoder =
ffmpeg::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 mut receive_and_process_decoded_audio_frames =
|decoder: &mut ffmpeg::decoder::Audio| -> Result<(), ffmpeg::Error> {
let mut decoded = Audio::empty();
let mut resampled = Audio::empty();
while decoder.receive_frame(&mut decoded).is_ok() {
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.extend(plane);
}
}
}
Ok(())
};
for (stream, packet) in ictx.packets() {
if stream.index() == video_stream_index {
video_decoder.send_packet(&packet)?;
receive_and_process_decoded_video_frames(&mut video_decoder)?;
} else if stream.index() == audio_stream_index {
audio_decoder.send_packet(&packet)?;
receive_and_process_decoded_audio_frames(&mut audio_decoder)?;
}
}
video_decoder.send_eof()?;
receive_and_process_decoded_video_frames(&mut video_decoder)?;
audio_decoder.send_eof()?;
receive_and_process_decoded_audio_frames(&mut audio_decoder)?;
Ok(())
}
pub fn run(path: PathBuf) -> Arc<Mutex<Option<VideoFrame>>> {
ffmpeg::init().unwrap();
let audio_queue_lock = Arc::new(Mutex::new(VecDeque::new()));
let audio_config = cpal(audio_queue_lock.clone());
let video_frame_lock = Arc::new(Mutex::new(None));
{
let video_frame_lock = video_frame_lock.clone();
thread::spawn(move || {
ffmpeg_thread(path, video_frame_lock, audio_config, audio_queue_lock).unwrap();
});
}
video_frame_lock
}

59
src/key_bind.rs Normal file
View file

@ -0,0 +1,59 @@
use cosmic::iced::keyboard::{KeyCode, Modifiers};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fmt};
use crate::Action;
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub enum Modifier {
Super,
Ctrl,
Alt,
Shift,
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct KeyBind {
pub modifiers: Vec<Modifier>,
pub key_code: KeyCode,
}
impl KeyBind {
pub fn matches(&self, modifiers: Modifiers, key_code: KeyCode) -> bool {
self.key_code == key_code
&& modifiers.logo() == self.modifiers.contains(&Modifier::Super)
&& modifiers.control() == self.modifiers.contains(&Modifier::Ctrl)
&& modifiers.alt() == self.modifiers.contains(&Modifier::Alt)
&& modifiers.shift() == self.modifiers.contains(&Modifier::Shift)
}
}
impl fmt::Display for KeyBind {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
for modifier in self.modifiers.iter() {
write!(f, "{:?} + ", modifier)?;
}
write!(f, "{:?}", self.key_code)
}
}
//TODO: load from config
pub fn key_binds() -> HashMap<KeyBind, Action> {
let mut key_binds = HashMap::new();
macro_rules! bind {
([$($modifier:ident),+ $(,)?], $key_code:ident, $action:ident) => {{
key_binds.insert(
KeyBind {
modifiers: vec![$(Modifier::$modifier),+],
key_code: KeyCode::$key_code,
},
Action::$action,
);
}};
}
//TODO: key bindings
key_binds
}

48
src/localize.rs Normal file
View file

@ -0,0 +1,48 @@
// SPDX-License-Identifier: GPL-3.0-only
use i18n_embed::{
fluent::{fluent_language_loader, FluentLanguageLoader},
DefaultLocalizer, LanguageLoader, Localizer,
};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "i18n/"]
struct Localizations;
lazy_static::lazy_static! {
pub static ref LANGUAGE_LOADER: FluentLanguageLoader = {
let loader: FluentLanguageLoader = fluent_language_loader!();
loader
.load_fallback_language(&Localizations)
.expect("Error while loading fallback language");
loader
};
}
#[macro_export]
macro_rules! fl {
($message_id:literal) => {{
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id)
}};
($message_id:literal, $($args:expr),*) => {{
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id, $($args), *)
}};
}
// Get the `Localizer` to be used for localizing this library.
pub fn localizer() -> Box<dyn Localizer> {
Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations))
}
pub fn localize() {
let localizer = localizer();
let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages();
if let Err(error) = localizer.select(&requested_languages) {
eprintln!("Error while loading language for App List {}", error);
}
}

View file

@ -1,147 +1,394 @@
extern crate ffmpeg_next as ffmpeg;
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use ffmpeg::format::{input, Pixel};
use ffmpeg::media::Type;
use ffmpeg::software::scaling::{context::Context, flag::Flags};
use ffmpeg::util::frame::video::Video;
use std::cmp;
use std::env;
use std::fs::File;
use std::io::prelude::*;
use std::num::NonZeroU32;
use std::rc::Rc;
use std::thread;
use winit::event::{Event, KeyEvent, WindowEvent};
use winit::event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy};
use winit::keyboard::{Key, NamedKey};
use winit::window::WindowBuilder;
use cosmic::{
app::{message, Command, Core, Settings},
cosmic_config::{self, CosmicConfigEntry},
cosmic_theme, executor,
iced::{
event::{self, Event},
keyboard::{Event as KeyEvent, KeyCode, Modifiers},
subscription::{self, Subscription},
window, Alignment, Length,
},
widget, Application, ApplicationExt, Element,
};
use std::{
any::TypeId,
collections::HashMap,
env,
path::PathBuf,
process,
sync::{Arc, Mutex},
time::Instant,
};
fn ffmpeg(event_loop_proxy: EventLoopProxy<Video>) -> Result<(), ffmpeg::Error> {
ffmpeg::init().unwrap();
use config::{AppTheme, Config, CONFIG_VERSION};
mod config;
if let Ok(mut ictx) = input(&env::args().nth(1).expect("Cannot open file.")) {
let input = ictx
.streams()
.best(Type::Video)
.ok_or(ffmpeg::Error::StreamNotFound)?;
let video_stream_index = input.index();
mod ffmpeg;
let context_decoder = ffmpeg::codec::context::Context::from_parameters(input.parameters())?;
let mut decoder = context_decoder.decoder().video()?;
use key_bind::{key_binds, KeyBind};
mod key_bind;
let mut scaler = Context::get(
decoder.format(),
decoder.width(),
decoder.height(),
Pixel::RGB24,
1280, //TODO decoder.width(),
720, //TODO decoder.height(),
Flags::BILINEAR,
)?;
mod localize;
let mut receive_and_process_decoded_frames =
|decoder: &mut ffmpeg::decoder::Video| -> Result<(), ffmpeg::Error> {
let mut decoded = Video::empty();
while decoder.receive_frame(&mut decoded).is_ok() {
let mut rgb_frame = Video::empty();
scaler.run(&decoded, &mut rgb_frame)?;
match event_loop_proxy.send_event(rgb_frame) {
Ok(()) => {}
Err(_err) => {
panic!("event loop closed");
}
}
/// 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 (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
}
Ok(())
};
for (stream, packet) in ictx.packets() {
if stream.index() == video_stream_index {
decoder.send_packet(&packet)?;
receive_and_process_decoded_frames(&mut decoder)?;
}
(Some(config_handler), config)
}
decoder.send_eof()?;
receive_and_process_decoded_frames(&mut decoder)?;
Err(err) => {
log::error!("failed to create config handler: {}", err);
(None, Config::default())
}
};
//TODO: support multiple paths
let path = match env::args().skip(1).next() {
Some(arg) => PathBuf::from(arg),
None => {
log::error!("no argument provided");
process::exit(1);
}
};
let video_frame_lock = ffmpeg::run(path);
let mut settings = Settings::default();
settings = settings.theme(config.app_theme.theme());
#[cfg(target_os = "redox")]
{
// Redox does not support resize if doing CSDs
settings = settings.client_decorations(false);
}
//TODO: allow size limits on iced_winit
//settings = settings.size_limits(Limits::NONE.min_width(400.0).min_height(200.0));
let flags = Flags {
config_handler,
config,
video_frame_lock
};
cosmic::app::run::<App>(settings, flags)?;
Ok(())
}
fn main() {
let event_loop = EventLoopBuilder::<Video>::with_user_event()
.build()
.unwrap();
let event_loop_proxy = event_loop.create_proxy();
thread::spawn(move || {
ffmpeg(event_loop_proxy).unwrap();
});
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Action {
Todo,
}
let window = Rc::new(WindowBuilder::new().build(&event_loop).unwrap());
let context = softbuffer::Context::new(window.clone()).unwrap();
let mut surface = softbuffer::Surface::new(&context, window.clone()).unwrap();
impl Action {
pub fn message(&self) -> Message {
match self {
Self::Todo => Message::Todo,
}
}
}
let mut rgb_frame_opt: Option<Video> = None;
event_loop
.run(move |event, elwt| {
elwt.set_control_flow(ControlFlow::Wait);
#[derive(Clone)]
pub struct Flags {
config_handler: Option<cosmic_config::Config>,
config: Config,
video_frame_lock: Arc<Mutex<Option<ffmpeg::VideoFrame>>>,
}
match event {
Event::WindowEvent {
window_id,
event: WindowEvent::RedrawRequested,
} if window_id == window.id() => {
if let (Some(width), Some(height)) = {
let size = window.inner_size();
(NonZeroU32::new(size.width), NonZeroU32::new(size.height))
} {
surface.resize(width, height).unwrap();
//TODO: send size back to ffmpeg thread
/// Messages that are used specifically by our [`App`].
#[derive(Clone, Debug)]
pub enum Message {
Todo,
AppTheme(AppTheme),
Config(Config),
Key(Modifiers, KeyCode),
SystemThemeModeChange(cosmic_theme::ThemeMode),
Tick(Instant),
ToggleContextPage(ContextPage),
WindowClose,
WindowNew,
}
let mut buffer = surface.buffer_mut().unwrap();
let buffer_width = width.get() as usize;
let buffer_height = height.get() as usize;
if let Some(rgb_frame) = &rgb_frame_opt {
let data = rgb_frame.data(0);
let data_width = rgb_frame.width() as usize;
let data_height = rgb_frame.height() as usize;
//TODO: stride?
for y in 0..cmp::min(buffer_height, data_height) {
for x in 0..cmp::min(buffer_width, data_width) {
let data_index = (y * data_width + x) * 3;
let red = data[data_index] as u32;
let green = data[data_index + 1] as u32;
let blue = data[data_index + 2] as u32;
let buffer_index = y * buffer_width + x;
buffer[buffer_index] = blue | (green << 8) | (red << 16);
}
#[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
);
}
}
buffer.present().unwrap();
}
None => {
self.flags.config.$name = $value;
log::warn!(
"failed to save config {:?}: no config handler",
stringify!($name)
);
}
}
Event::WindowEvent {
event:
WindowEvent::CloseRequested
| WindowEvent::KeyboardInput {
event:
KeyEvent {
logical_key: Key::Named(NamedKey::Escape),
..
},
..
},
window_id,
} if window_id == window.id() => {
elwt.exit();
}
Event::UserEvent(rgb_frame) => {
rgb_frame_opt = Some(rgb_frame);
window.request_redraw();
}
_ => {}
};
}
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_code) => {
for (key_bind, action) in self.key_binds.iter() {
if key_bind.matches(modifiers, key_code) {
return self.update(action.message());
}
}
}
Message::SystemThemeModeChange(_theme_mode) => {
return self.update_config();
}
Message::Tick(_time) => {
let start = Instant::now();
match {
let mut video_frame_opt = self.flags.video_frame_lock.lock().unwrap();
video_frame_opt.take()
} {
Some(video_frame) => {
self.handle_opt = Some(video_frame.into_handle());
let duration = start.elapsed();
log::debug!("converted video frame to handle in {:?}", duration);
}
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(),
})
.unwrap();
}
/// 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_code,
modifiers,
}) => Some(Message::Key(modifiers, key_code)),
_ => 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)
}),
])
}
}