ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
357 lines
12 KiB
Rust
357 lines
12 KiB
Rust
//! Slide audio and video: insert, play, explain failures (SHOW-10).
|
|
|
|
use std::{
|
|
path::{Path, PathBuf},
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use ely_gpui_component::{
|
|
buttons::Button, data_display::PropertyGroup, feedback::InlineMessage, primitives::Severity,
|
|
theme::ControlSize,
|
|
};
|
|
use ely_lok::{LokView, show::Media};
|
|
use ely_office::prelude::*;
|
|
use ely_platform::media::{Player, State};
|
|
use gpui::{
|
|
AnyElement, App, Context, Entity, IntoElement, ParentElement as _, Styled as _, SurfaceSource,
|
|
TaskExt as _, Window, div,
|
|
};
|
|
|
|
use crate::deck::Deck;
|
|
use crate::design::{Panel, on_deck};
|
|
|
|
/// How long a file may take to show it plays.
|
|
const PROBE: Duration = Duration::from_secs(10);
|
|
/// A sound's frame on the slide, 1/100 mm.
|
|
const SOUND: (i64, i64) = (2000, 2000);
|
|
|
|
/// A file checked before it goes in.
|
|
pub struct Probe {
|
|
tab: u64,
|
|
path: PathBuf,
|
|
player: Player,
|
|
started: Instant,
|
|
}
|
|
|
|
/// The media object playing: tab, slide, place, URL.
|
|
pub struct Playing {
|
|
tab: u64,
|
|
slide: String,
|
|
index: usize,
|
|
url: String,
|
|
player: Player,
|
|
frame: Option<SurfaceSource>,
|
|
}
|
|
|
|
/// `m:ss`.
|
|
fn clock(seconds: f64) -> String {
|
|
let whole = seconds.max(0.0).round() as u64;
|
|
format!("{}:{:02}", whole / 60, whole % 60)
|
|
}
|
|
|
|
fn name(path: &Path) -> String {
|
|
path.file_name().map_or_else(
|
|
|| path.display().to_string(),
|
|
|n| n.to_string_lossy().into_owned(),
|
|
)
|
|
}
|
|
|
|
impl Deck {
|
|
/// Picks a sound or video, checking it plays first.
|
|
pub fn pick_media(&mut self, cx: &mut Context<Self>) {
|
|
self.pick(false, cx, |deck, path, cx| deck.probe_media(path, cx));
|
|
}
|
|
|
|
fn probe_media(&mut self, path: PathBuf, cx: &mut Context<Self>) {
|
|
let Some(tab) = self.core.active else {
|
|
return;
|
|
};
|
|
let player = match Player::open(&path) {
|
|
Ok(player) => player,
|
|
Err(error) => return self.media_refused(&path, &error.to_string(), cx),
|
|
};
|
|
log::info!("show: checks a media file");
|
|
let started = Instant::now();
|
|
self.probe = Some(Probe {
|
|
tab,
|
|
path,
|
|
player,
|
|
started,
|
|
});
|
|
cx.spawn(async move |this, cx| -> gpui::Result<()> {
|
|
loop {
|
|
cx.background_executor()
|
|
.timer(Duration::from_millis(50))
|
|
.await;
|
|
if this.update(cx, |deck, cx| deck.probe_tick(cx))? {
|
|
return Ok(());
|
|
}
|
|
}
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
/// One look at the file being checked; true when settled.
|
|
fn probe_tick(&mut self, cx: &mut Context<Self>) -> bool {
|
|
let Some(probe) = &self.probe else {
|
|
return true;
|
|
};
|
|
let state = probe.player.state();
|
|
if state == State::Loading && probe.started.elapsed() < PROBE {
|
|
return false;
|
|
}
|
|
let probe = self.probe.take().expect("checked above");
|
|
match state {
|
|
State::Ready { video, .. } => self.insert_media(&probe, video, cx),
|
|
State::Failed(reason) => self.media_refused(&probe.path, &reason, cx),
|
|
State::Loading => self.media_refused(&probe.path, "it did not load in time", cx),
|
|
}
|
|
true
|
|
}
|
|
|
|
fn insert_media(&mut self, probe: &Probe, video: Option<(u32, u32)>, cx: &mut Context<Self>) {
|
|
let Some(tab) = self.tab_mut(probe.tab) else {
|
|
return;
|
|
};
|
|
let slide = tab.extra.shown_size();
|
|
// Pixels at 96 per inch, within most of the slide.
|
|
let (w, h) = match video {
|
|
Some((w, h)) => {
|
|
let (w, h) = (i64::from(w) * 2540 / 96, i64::from(h) * 2540 / 96);
|
|
let fit = (slide.0 as f64 * 0.8 / w as f64)
|
|
.min(slide.1 as f64 * 0.8 / h as f64)
|
|
.min(1.0);
|
|
((w as f64 * fit) as i64, (h as f64 * fit) as i64)
|
|
}
|
|
None => SOUND,
|
|
};
|
|
let url = ely_lok::file_url(&probe.path);
|
|
log::info!("show: inserts media {w} x {h}");
|
|
self.run_in(
|
|
Some(probe.tab),
|
|
"media.insert",
|
|
&[&url, "false", &w.to_string(), &h.to_string()],
|
|
cx,
|
|
);
|
|
self.force_reread(probe.tab, cx);
|
|
}
|
|
|
|
fn media_refused(&mut self, path: &Path, reason: &str, cx: &mut Context<Self>) {
|
|
log::warn!("show: media refused: {reason}");
|
|
let text = Deck::text(
|
|
cx,
|
|
"show.media.refused",
|
|
&[("name", &name(path)), ("reason", reason)],
|
|
);
|
|
self.toast(text, Severity::Danger, cx);
|
|
}
|
|
|
|
/// The shown slide's name and its media at `index`.
|
|
fn here(&self, index: usize) -> Option<(u64, String, Media)> {
|
|
let tab = self.active_tab()?;
|
|
let slide = tab
|
|
.extra
|
|
.slides
|
|
.list
|
|
.get(tab.extra.slides.current)?
|
|
.name
|
|
.clone();
|
|
let media = tab.extra.media.iter().find(|media| media.index == index)?;
|
|
Some((tab.id, slide, media.clone()))
|
|
}
|
|
|
|
/// Stops playback unless the same object is still shown.
|
|
pub(crate) fn check_playing(&mut self) {
|
|
let Some(playing) = &self.playing else {
|
|
return;
|
|
};
|
|
let same = self.here(playing.index).is_some_and(|(tab, slide, media)| {
|
|
(tab, slide, media.url) == (playing.tab, playing.slide.clone(), playing.url.clone())
|
|
});
|
|
if !same {
|
|
log::info!("show: the playing media object is gone from view");
|
|
playing.player.pause();
|
|
self.playing = None;
|
|
}
|
|
}
|
|
|
|
/// The media object picked on the shown slide.
|
|
pub(crate) fn picked_media(&self) -> Option<(u64, Media)> {
|
|
let tab = self.active_tab()?;
|
|
let objects = tab.extra.objects.as_ref()?;
|
|
let mut picked = objects
|
|
.objects
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, o)| o.selected);
|
|
let (at, _) = picked.next().filter(|_| picked.next().is_none())?;
|
|
let media = tab.extra.media.iter().find(|media| media.index == at)?;
|
|
Some((tab.id, media.clone()))
|
|
}
|
|
|
|
/// Plays or pauses the picked media object.
|
|
pub fn toggle_media(&mut self, cx: &mut Context<Self>) {
|
|
let Some((_, media)) = self.picked_media() else {
|
|
return;
|
|
};
|
|
let Some((tab, slide, _)) = self.here(media.index) else {
|
|
return;
|
|
};
|
|
self.check_playing();
|
|
if let Some(playing) = self.playing.as_ref().filter(|p| p.index == media.index) {
|
|
match (playing.player.playing(), playing.player.ended()) {
|
|
(true, _) => playing.player.pause(),
|
|
// A finished clip starts over.
|
|
(false, true) => {
|
|
playing.player.seek(0.0);
|
|
playing.player.play();
|
|
}
|
|
(false, false) => playing.player.play(),
|
|
}
|
|
return cx.notify();
|
|
}
|
|
let opened = (ely_lok::file_path(&media.file).map_err(|error| error.to_string()))
|
|
.and_then(|path| Player::open(&path).map_err(|error| error.to_string()));
|
|
match opened {
|
|
Ok(player) => {
|
|
log::info!("show: plays media object {}", media.index);
|
|
player.play();
|
|
self.playing = Some(Playing {
|
|
tab,
|
|
slide,
|
|
index: media.index,
|
|
url: media.url.clone(),
|
|
player,
|
|
frame: None,
|
|
});
|
|
}
|
|
Err(error) => self.media_refused(Path::new(&media.url), &error, cx),
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
/// The playing video over its object; frames while it runs.
|
|
pub(crate) fn media_layer(
|
|
&mut self,
|
|
view: &Entity<LokView>,
|
|
window: &mut Window,
|
|
cx: &App,
|
|
) -> Option<AnyElement> {
|
|
self.check_playing();
|
|
let index = self.playing.as_ref()?.index;
|
|
let tab = self.active_tab()?;
|
|
let rect = tab.extra.objects.as_ref()?.objects.get(index)?.rect;
|
|
let repeat = tab.extra.media.iter().any(|m| m.index == index && m.repeat);
|
|
let playing = self.playing.as_mut()?;
|
|
// A failure shows in the panel, from the player's state.
|
|
let drawing = matches!(playing.player.tick(repeat), Ok(true));
|
|
if let Some(frame) = playing.player.frame() {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
playing.frame = Some(frame.into());
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
match frame {}
|
|
}
|
|
if drawing {
|
|
window.request_animation_frame();
|
|
}
|
|
let frame = playing.frame.clone()?;
|
|
let twips = |mm100: i64| (mm100 * 72 / 127) as i32;
|
|
let (x, y, w, h) = rect;
|
|
let lok = view.read(cx);
|
|
let (top_left, bottom_right) = (
|
|
lok.place((twips(x), twips(y))),
|
|
lok.place((twips(x + w), twips(y + h))),
|
|
);
|
|
let bounds = (top_left, bottom_right - top_left);
|
|
Some(video(frame, bounds))
|
|
}
|
|
}
|
|
|
|
/// A video picture over its object.
|
|
#[cfg(target_os = "macos")]
|
|
pub(crate) fn video(
|
|
frame: SurfaceSource,
|
|
(at, size): (gpui::Point<gpui::Pixels>, gpui::Point<gpui::Pixels>),
|
|
) -> AnyElement {
|
|
gpui::surface(frame)
|
|
.absolute()
|
|
.left(at.x)
|
|
.top(at.y)
|
|
.w(size.x)
|
|
.h(size.y)
|
|
.into_any_element()
|
|
}
|
|
|
|
/// No platform here makes video pictures.
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub(crate) fn video(
|
|
frame: SurfaceSource,
|
|
_: (gpui::Point<gpui::Pixels>, gpui::Point<gpui::Pixels>),
|
|
) -> AnyElement {
|
|
match frame {}
|
|
}
|
|
|
|
impl Panel {
|
|
/// Play, where it is, and why it cannot play.
|
|
pub(crate) fn media_group(&self, cx: &mut Context<Self>) -> Option<PropertyGroup> {
|
|
let deck = self.deck.upgrade()?;
|
|
let (tab, media) = deck.read(cx).picked_media()?;
|
|
let label = |key: &str| Deck::text(cx, key, &[]);
|
|
let group = PropertyGroup::new(label("show.media.group"));
|
|
if media.missing {
|
|
let what = ely_lok::file_path(&media.url)
|
|
.map_or(media.url.clone(), |path| path.display().to_string());
|
|
let why = Deck::text(cx, "show.media.missing", &[("path", &what)]);
|
|
return Some(group.row(
|
|
label("show.media.file"),
|
|
InlineMessage::new(Severity::Danger, why),
|
|
));
|
|
}
|
|
let read = deck.read(cx);
|
|
let playing = read
|
|
.playing
|
|
.as_ref()
|
|
.filter(|p| (p.tab, p.index) == (tab, media.index));
|
|
let (running, state) = match playing {
|
|
Some(p) => (
|
|
p.player.playing(),
|
|
Some((p.player.state(), p.player.position())),
|
|
),
|
|
None => (false, None),
|
|
};
|
|
if let Some((State::Failed(reason), _)) = &state {
|
|
let why = Deck::text(cx, "show.media.failed", &[("reason", reason)]);
|
|
return Some(group.row(
|
|
label("show.media.file"),
|
|
InlineMessage::new(Severity::Danger, why),
|
|
));
|
|
}
|
|
let weak = self.deck.clone();
|
|
let toggle = Button::new(
|
|
"show-media-play",
|
|
label(if running {
|
|
"show.media.pause"
|
|
} else {
|
|
"show.media.play"
|
|
}),
|
|
)
|
|
.size(ControlSize::Sm)
|
|
.on_click(move |_, _, cx| on_deck(&weak, cx, |deck, cx| deck.toggle_media(cx)));
|
|
let time = match state {
|
|
Some((State::Ready { seconds, .. }, at)) => {
|
|
format!("{} / {}", clock(at), clock(seconds))
|
|
}
|
|
_ => clock(0.0),
|
|
};
|
|
let row = div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.child(toggle)
|
|
.child(time);
|
|
Some(group.row(label("show.media.play"), row))
|
|
}
|
|
}
|