showreet 0.5.1

A GPU-accelerated video thumbnail browser with inline playback
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>, // playbin for seeking
    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),
                        }
                    }
                }
                // Scroll to keep selected item visible
                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;
                // When exiting fullscreen, scroll to the selected video
                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 {
                    // Query current position, then seek relative
                    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()?;
        // Use single parse::launch like Video::new does – ensures proper ghost-pad wiring
        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")?;

        // Extract appsink following iced_video_player's Video::new pattern
        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")) }