Convert to libcosmic and parse audio
This commit is contained in:
parent
31a352c524
commit
94a1244c6d
18 changed files with 5458 additions and 316 deletions
4612
Cargo.lock
generated
4612
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
31
Cargo.toml
31
Cargo.toml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
5
debian/changelog
vendored
Normal 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
22
debian/control
vendored
Normal 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
7
debian/copyright
vendored
Normal 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
22
debian/rules
vendored
Normal 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
1
debian/source/format
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.0 (native)
|
||||
4
debian/source/options
vendored
Normal file
4
debian/source/options
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
tar-ignore=.github
|
||||
tar-ignore=.vscode
|
||||
tar-ignore=vendor
|
||||
tar-ignore=target
|
||||
4
i18n.toml
Normal file
4
i18n.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
fallback_language = "en"
|
||||
|
||||
[fluent]
|
||||
assets_dir = "i18n"
|
||||
11
i18n/en/cosmic_player.ftl
Normal file
11
i18n/en/cosmic_player.ftl
Normal 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
78
justfile
Normal 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
|
||||
10
res/com.system76.CosmicPlayer.desktop
Normal file
10
res/com.system76.CosmicPlayer.desktop
Normal 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
39
src/config.rs
Normal 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
322
src/ffmpeg.rs
Normal 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
59
src/key_bind.rs
Normal 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
48
src/localize.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
491
src/main.rs
491
src/main.rs
|
|
@ -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)
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue