mod thumbnail;
use iced::widget::{container, mouse_area, scrollable, row, Image, text};
use iced::{Color, Element, Length, Subscription, Task, Event, keyboard};
use iced::window;
use iced_core::widget::operation::scrollable::{self as scroll_op, RelativeOffset};
use iced_video_player::{Video, VideoPlayer};
use gstreamer::prelude::*;
use std::path::{Path, PathBuf};
use thumbnail::Thumbnailer;
pub fn main() -> iced::Result {
iced::application::<App, Message, iced::Theme, iced::Renderer>(
App::boot, App::update, App::view,
)
.window(iced::window::Settings {
size: iced::Size::new(1600.0, 1000.0), transparent: true,
..iced::window::Settings::default()
})
.subscription(App::subscription)
.run()
}
#[derive(Debug, Clone)]
enum Message {
ZoomIn, ZoomOut, Select(usize),
ToggleWindowFullscreen, ToggleVideoFullscreen, Seek(i64),
Quit,
Key(keyboard::Event),
GotWindowId(Option<window::Id>),
}
struct App {
thumbnailer: Thumbnailer, zoom: u32,
video_files: Vec<PathBuf>, thumbnails: Vec<Option<PathBuf>>,
selected: usize,
current_video: Option<Video>,
current_pipeline: Option<gstreamer::Element>, window_id: Option<window::Id>,
window_fullscreen: bool,
video_fullscreen: bool,
scroll_id: iced::widget::Id,
}
impl App {
fn boot() -> (Self, Task<Message>) {
let dir = std::env::args().nth(1);
let mut app = Self {
thumbnailer: Thumbnailer::new(), zoom: 790,
video_files: vec![], thumbnails: vec![], selected: 0,
current_video: None, current_pipeline: None,
window_id: None, window_fullscreen: false, video_fullscreen: false,
scroll_id: iced::widget::Id::new("grid-scroll"),
};
if let Some(d) = dir { app.load_dir(&d); app.reload_t(); }
(app, window::oldest().map(|id| Message::GotWindowId(id)))
}
fn subscription(&self) -> Subscription<Message> {
iced::event::listen().map(|ev| {
if let Event::Keyboard(k) = ev { Message::Key(k) }
else { Message::ZoomIn }
})
}
fn update(&mut self, msg: Message) -> Task<Message> {
match msg {
Message::ZoomIn => {
if self.zoom < 790 { self.zoom = 790; self.reload_t(); }
Task::none()
}
Message::ZoomOut => {
if self.zoom > 520 { self.zoom = 520; self.reload_t(); }
Task::none()
}
Message::Select(i) => {
self.selected = i;
self.stop_video();
if i < self.video_files.len() {
let uri = format!("file://{}", self.video_files[i].to_string_lossy());
if let Ok(url) = url::Url::parse(&uri) {
match Self::create_video(&url) {
Ok((video, pipeline)) => {
self.current_video = Some(video);
self.current_pipeline = Some(pipeline);
}
Err(e) => eprintln!("Video error: {}", e),
}
}
}
let pr = per_row(self.zoom).max(1);
let row_idx = i / pr;
let total_rows = ((self.video_files.len() + pr - 1) / pr).max(1);
let rel_y = row_idx as f32 / total_rows as f32;
let op = scroll_op::snap_to(
self.scroll_id.clone(),
RelativeOffset { x: None, y: Some(rel_y) },
);
iced_runtime::task::widget(op)
}
Message::Quit => {
if let Some(id) = self.window_id {
return window::close(id);
}
Task::none()
}
Message::GotWindowId(id) => {
self.window_id = id;
Task::none()
}
Message::ToggleWindowFullscreen => {
if let Some(id) = self.window_id {
self.window_fullscreen = !self.window_fullscreen;
let mode = if self.window_fullscreen { window::Mode::Fullscreen } else { window::Mode::Windowed };
return window::set_mode(id, mode);
}
Task::none()
}
Message::ToggleVideoFullscreen => {
self.video_fullscreen = !self.video_fullscreen;
if !self.video_fullscreen && !self.video_files.is_empty() {
let pr = per_row(self.zoom).max(1);
let row_idx = self.selected / pr;
let total_rows = ((self.video_files.len() + pr - 1) / pr).max(1);
let rel_y = row_idx as f32 / total_rows as f32;
let op = scroll_op::snap_to(
self.scroll_id.clone(),
RelativeOffset { x: None, y: Some(rel_y) },
);
return iced_runtime::task::widget(op);
}
Task::none()
}
Message::Seek(delta_secs) => {
if let Some(ref pipeline) = self.current_pipeline {
if let Some(pos) = pipeline.query_position::<gstreamer::ClockTime>() {
let target = if delta_secs < 0 {
pos.saturating_sub(gstreamer::ClockTime::from_seconds((-delta_secs) as u64))
} else {
pos + gstreamer::ClockTime::from_seconds(delta_secs as u64)
};
let _ = pipeline.seek_simple(gstreamer::SeekFlags::FLUSH | gstreamer::SeekFlags::KEY_UNIT, target);
}
}
Task::none()
}
Message::Key(ev) => {
if let keyboard::Event::KeyPressed { key, modifiers, .. } = ev {
use keyboard::Key;
let ctrl = modifiers.command();
match &key {
Key::Character(c) if !ctrl => match c.as_str() {
"f"|"F" => return self.update(Message::ToggleVideoFullscreen),
"q"|"Q" => return self.update(Message::Quit),
_ => {}
},
Key::Named(keyboard::key::Named::F11) => {
return self.update(Message::ToggleWindowFullscreen);
}
Key::Character(c) if ctrl => match c.as_str() {
"="|"+" => return self.update(Message::ZoomIn),
"-" => return self.update(Message::ZoomOut),
_ => {}
},
Key::Named(keyboard::key::Named::Escape) => {
if self.video_fullscreen { return self.update(Message::ToggleVideoFullscreen); }
if self.window_fullscreen { return self.update(Message::ToggleWindowFullscreen); }
}
Key::Named(keyboard::key::Named::ArrowLeft) => {
return self.update(Message::Seek(-60));
}
Key::Named(keyboard::key::Named::ArrowRight) => {
return self.update(Message::Seek(60));
}
Key::Named(keyboard::key::Named::ArrowUp) => {
if self.selected > 0 { self.selected -= 1; }
return self.update(Message::Select(self.selected));
}
Key::Named(keyboard::key::Named::ArrowDown) => {
if self.selected + 1 < self.video_files.len() { self.selected += 1; }
return self.update(Message::Select(self.selected));
}
_ => {}
}
}
Task::none()
}
}
}
fn view(&self) -> Element<'_, Message> {
if self.video_fullscreen {
if let Some(ref v) = self.current_video {
return container(VideoPlayer::new(v).content_fit(iced::ContentFit::Cover))
.width(Length::Fill).height(Length::Fill)
.style(|_| container::Style { background: Some(iced::Background::Color(Color::BLACK)), ..container::Style::default() })
.into();
}
}
if self.video_files.is_empty() {
container(text("showreet /path/to/videos | F11: win-fullscreen F: video-fullscreen <- ->: seek up/down: prev/next q: quit").size(15.0))
.center_x(Length::Fill).center_y(Length::Fill)
.width(Length::Fill).height(Length::Fill).into()
} else {
container(self.grid()).width(Length::Fill).height(Length::Fill).into()
}
}
fn grid(&self) -> Element<'_, Message> {
let tw = self.zoom as f32; let th = tw * 9.0 / 16.0; let sp = 4.0;
let pr = per_row(self.zoom);
let mut lg = vec![vec![]];
for _ in &self.video_files { if lg.last().unwrap().len() >= pr { lg.push(vec![]); } lg.last_mut().unwrap().push(()); }
let mut rows = vec![];
for (ri, r) in lg.iter().enumerate() {
let mut cols = vec![];
for (ci, _) in r.iter().enumerate() {
let idx = ri * pr + ci; if idx >= self.video_files.len() { continue; }
let sel = idx == self.selected;
let elem: Element<'_, Message> = if sel {
if let Some(ref v) = self.current_video {
VideoPlayer::new(v).width(Length::Fixed(tw)).height(Length::Fixed(th)).into()
} else if let Some(t) = self.thumbnails.get(idx).and_then(|x| x.as_ref()) {
Image::new(t.to_string_lossy().to_string()).width(Length::Fixed(tw)).height(Length::Fixed(th)).into()
} else { ph(tw, th, "â–¶") }
} else if let Some(t) = self.thumbnails.get(idx).and_then(|x| x.as_ref()) {
Image::new(t.to_string_lossy().to_string()).width(Length::Fixed(tw)).height(Length::Fixed(th)).into()
} else { ph(tw, th, "🎬") };
let c = container(elem).padding(if sel { 3 } else { 2 });
let c: Element<'_, Message> = if sel {
c.style(|_| container::Style {
border: iced::Border { color: Color::from_rgb(0.2, 0.6, 1.0), width: 2.0, radius: 4.0.into() },
background: Some(iced::Background::Color(Color::from_rgba(0.2, 0.6, 1.0, 0.15))),
..container::Style::default()
}).into()
} else { c.into() };
cols.push(mouse_area(c).on_press(Message::Select(idx)).into());
}
if !cols.is_empty() { rows.push(row(cols).spacing(sp).into()); }
}
container(scrollable(iced::widget::column(rows).spacing(sp)).id(self.scroll_id.clone()))
.width(Length::Fill).height(Length::Fill)
.style(|_| container::Style { background: Some(iced::Background::Color(Color::TRANSPARENT)), ..container::Style::default() }).into()
}
fn create_video(uri: &url::Url) -> Result<(Video, gstreamer::Element), Box<dyn std::error::Error>> {
gstreamer::init()?;
let pipeline_str = format!(
"playbin uri=\"{}\" video-sink=\"videoscale ! videoconvert ! appsink name=iced_video drop=true caps=video/x-raw,format=NV12,pixel-aspect-ratio=1/1\"",
uri.as_str()
);
let pipeline = gstreamer::parse::launch(&pipeline_str)?
.downcast::<gstreamer::Pipeline>()
.map_err(|_| "not a pipeline")?;
let video_sink: gstreamer::Element = pipeline.property("video-sink");
let pad = video_sink.pads().first().cloned().ok_or("video-sink has no pads")?;
let pad = pad.dynamic_cast::<gstreamer::GhostPad>().map_err(|_| "not a ghost pad")?;
let bin = pad.parent_element().ok_or("ghost pad has no parent")?;
let bin = bin.downcast::<gstreamer::Bin>().map_err(|_| "parent not a bin")?;
let video_sink = bin.by_name("iced_video").ok_or("no iced_video element")?;
let video_sink = video_sink.downcast::<gstreamer_app::AppSink>().map_err(|_| "not appsink")?;
let video = Video::from_gst_pipeline(pipeline.clone(), video_sink, None)?;
Ok((video, pipeline.upcast_ref::<gstreamer::Element>().clone()))
}
fn stop_video(&mut self) {
if let Some(ref pipeline) = self.current_pipeline {
let _ = pipeline.set_state(gstreamer::State::Null);
}
self.current_video = None;
self.current_pipeline = None;
}
fn load_dir(&mut self, path: &str) {
self.video_files.clear(); self.thumbnails.clear(); self.selected = 0;
self.stop_video(); self.window_fullscreen = false; self.video_fullscreen = false;
if let Ok(e) = std::fs::read_dir(Path::new(path)) {
for p in e.filter_map(|x| x.ok()).map(|x| x.path()).filter(|p| is_video(p)) {
self.video_files.push(p); self.thumbnails.push(None);
}
}
}
fn reload_t(&mut self) {
self.thumbnails = self.video_files.iter().map(|p| self.thumbnailer.get_thumbnail(p, self.zoom)).collect();
}
}
fn per_row(zoom: u32) -> usize { ((1600.0 - 4.0) / (zoom as f32 + 4.0)).max(1.0) as usize }
fn ph(w: f32, h: f32, icon: &'static str) -> Element<'static, Message> {
container(text(icon).size(h * 0.4)).width(Length::Fixed(w)).height(Length::Fixed(h)).center_x(Length::Fill).center_y(Length::Fill).into()
}
fn is_video(p: &Path) -> bool { p.is_file() && matches!(p.extension().and_then(|e| e.to_str()),
Some("mp4"|"mkv"|"avi"|"mov"|"webm"|"flv"|"wmv"|"mpg"|"mpeg"|"m2ts"|"ts"|"ogv"|"3gp"|"m4v")) }