Ferret v1.2.3 - A modern, accuracy-first video player for Linux, written in Rust.

This commit is contained in:
Jeremy Anderson 2026-08-11 16:27:38 -04:00
parent 1b8c38f17b
commit 6e71d72d1f
12 changed files with 558 additions and 2796 deletions

2784
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.0.0" version = "1.2.3"
edition = "2021" edition = "2021"
rust-version = "1.75" rust-version = "1.75"
license = "GPL-2.0-or-later" license = "GPL-2.0-or-later"
@ -51,6 +51,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
libc = "0.2" libc = "0.2"
# Random / shuffle
rand = "0.8"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = "thin" lto = "thin"

View File

@ -42,6 +42,8 @@ pub fn key_to_cmd(key: &Key, state: &PlaybackState) -> Option<Cmd> {
fn is_n(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "n" || s == "N") } fn is_n(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "n" || s == "N") }
fn is_p(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "p" || s == "P") } fn is_p(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "p" || s == "P") }
fn is_v(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "v" || s == "V") } fn is_v(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "v" || s == "V") }
fn is_r(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "r" || s == "R") }
fn is_s(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "s" || s == "S") }
// ---- Factories: produce the Cmd. State-dependent ones read `state`. ---- // ---- Factories: produce the Cmd. State-dependent ones read `state`. ----
fn play_pause(_: &PlaybackState) -> Cmd { Cmd::PlayPause } fn play_pause(_: &PlaybackState) -> Cmd { Cmd::PlayPause }
@ -71,6 +73,10 @@ pub fn key_to_cmd(key: &Key, state: &PlaybackState) -> Option<Cmd> {
fn next_track(_: &PlaybackState) -> Cmd { Cmd::PlaylistNext } fn next_track(_: &PlaybackState) -> Cmd { Cmd::PlaylistNext }
fn prev_track(_: &PlaybackState) -> Cmd { Cmd::PlaylistPrev } fn prev_track(_: &PlaybackState) -> Cmd { Cmd::PlaylistPrev }
fn toggle_subs(_: &PlaybackState) -> Cmd { Cmd::ToggleSubVisibility } fn toggle_subs(_: &PlaybackState) -> Cmd { Cmd::ToggleSubVisibility }
fn random_next(_: &PlaybackState) -> Cmd { Cmd::RandomNext }
fn cycle_random(state: &PlaybackState) -> Cmd {
Cmd::SetRandomMode(state.random_mode.cycle())
}
// ---- Lookup table. Order matters only for `q`/`f` which are // ---- Lookup table. Order matters only for `q`/`f` which are
// intercepted by the caller; all other keys are mutually exclusive. ---- // intercepted by the caller; all other keys are mutually exclusive. ----
@ -92,6 +98,8 @@ pub fn key_to_cmd(key: &Key, state: &PlaybackState) -> Option<Cmd> {
(is_n, next_track), (is_n, next_track),
(is_p, prev_track), (is_p, prev_track),
(is_v, toggle_subs), (is_v, toggle_subs),
(is_r, random_next),
(is_s, cycle_random),
]; ];
TABLE TABLE
.iter() .iter()

View File

@ -114,6 +114,10 @@ struct FerretApp {
/// know what Cmd to emit once we have the path. /// know what Cmd to emit once we have the path.
dialog_result_rx: crossbeam_channel::Receiver<(DialogKind, DialogResult)>, dialog_result_rx: crossbeam_channel::Receiver<(DialogKind, DialogResult)>,
dialog_result_tx: crossbeam_channel::Sender<(DialogKind, DialogResult)>, dialog_result_tx: crossbeam_channel::Sender<(DialogKind, DialogResult)>,
/// Whether either of our windows currently has keyboard focus.
/// When false, the overlay drops from AlwaysOnTop so it doesn't block
/// other applications.
has_focus: bool,
} }
impl FerretApp { impl FerretApp {
@ -133,6 +137,7 @@ impl FerretApp {
cmd_rx, cmd_rx,
dialog_result_rx, dialog_result_rx,
dialog_result_tx, dialog_result_tx,
has_focus: true,
} }
} }
@ -197,6 +202,16 @@ impl FerretApp {
self.windows.video = Some(video_window_arc); self.windows.video = Some(video_window_arc);
self.windows.overlay = Some(overlay_window_arc); self.windows.overlay = Some(overlay_window_arc);
// 8. Tell X11 the overlay is a helper window for the video window.
// This makes the WM:
// - Skip the overlay in the taskbar / alt-tab list
// - Keep the overlay visually grouped with the video window
// - Un-fullscreen both when focus leaves
set_x11_overlay_hints(
self.windows.video.as_ref().unwrap(),
self.windows.overlay.as_ref().unwrap(),
);
self.request_redraw_both(); self.request_redraw_both();
Ok(()) Ok(())
} }
@ -339,14 +354,35 @@ impl FerretApp {
self.fullscreen = !self.fullscreen; self.fullscreen = !self.fullscreen;
if self.fullscreen { if self.fullscreen {
video.set_fullscreen(Some(winit::window::Fullscreen::Borderless(None))); video.set_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
// Also fullscreen the overlay so it covers the entire screen.
// Without this, the overlay stays at the old windowed size while
// the video fills the screen — controls would be cut off.
if let Some(overlay_win) = self.windows.overlay.as_ref() {
overlay_win.set_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
}
} else { } else {
video.set_fullscreen(None); video.set_fullscreen(None);
if let Some(overlay_win) = self.windows.overlay.as_ref() {
overlay_win.set_fullscreen(None);
}
} }
// Sync state to overlay so the fullscreen button reflects it. // Sync state to overlay so the fullscreen button reflects it.
if let Some(overlay) = self.overlay.as_mut() { if let Some(overlay) = self.overlay.as_mut() {
overlay.app.set_fullscreen(self.fullscreen); overlay.app.set_fullscreen(self.fullscreen);
} }
} }
/// Update the overlay window level based on focus state.
/// When we have focus: overlay is AlwaysOnTop (controls visible above video).
/// When we lose focus: overlay drops to Normal so it doesn't block other apps.
fn update_overlay_focus(&mut self) {
let Some(overlay_win) = self.windows.overlay.as_ref() else { return; };
if self.has_focus {
overlay_win.set_window_level(winit::window::WindowLevel::AlwaysOnTop);
} else {
overlay_win.set_window_level(winit::window::WindowLevel::Normal);
}
}
} }
impl ApplicationHandler for FerretApp { impl ApplicationHandler for FerretApp {
@ -369,6 +405,10 @@ impl ApplicationHandler for FerretApp {
match kind { match kind {
WindowKind::Video => match event { WindowKind::Video => match event {
WindowEvent::Focused(gained) => {
self.has_focus = gained;
self.update_overlay_focus();
}
WindowEvent::KeyboardInput { WindowEvent::KeyboardInput {
event: event:
KeyEvent { KeyEvent {
@ -419,6 +459,10 @@ impl ApplicationHandler for FerretApp {
_ => {} _ => {}
}, },
WindowKind::Overlay => match event { WindowKind::Overlay => match event {
WindowEvent::Focused(gained) => {
self.has_focus = gained;
self.update_overlay_focus();
}
WindowEvent::CursorMoved { position, .. } => { WindowEvent::CursorMoved { position, .. } => {
// The renderer sets pixels_per_point=1.0, so egui's // The renderer sets pixels_per_point=1.0, so egui's
// coordinate system matches physical pixels directly. // coordinate system matches physical pixels directly.
@ -619,6 +663,146 @@ fn set_x11_window_background(window: &Arc<winit::window::Window>, pixel: u64) {
} }
} }
/// Set X11 hints on the overlay window so the window manager treats it as a
/// helper/child of the video window rather than a standalone top-level window.
///
/// Three hints are set:
///
/// 1. **`_NET_WM_WINDOW_TYPE = DIALOG`** — tells EWMH-compliant WMs that the
/// overlay is a dialog/helper window, not a normal application window. Most
/// WMs respond by:
/// - Skipping it in the taskbar and alt-tab list
/// - Grouping it with its parent window
/// - Not giving it its own virtual desktop entry
///
/// 2. **`XSetTransientForHint`** — X11 ICCCM hint that marks the overlay as a
/// "transient" (short-lived) window belonging to the video window. WMs use
/// this to:
/// - Center the dialog over its parent
/// - Keep it on the same screen/monitor
/// - Un-map it when the parent is withdrawn or iconified
///
/// 3. **`_NET_WM_STATE: _NET_WM_STATE_SKIP_TASKBAR`** — explicit hint for WMs
/// that don't respect _NET_WM_WINDOW_TYPE=DIALOG for taskbar suppression.
///
/// Together, these hints ensure the overlay appears as a single integrated UI
/// with the video window, not as a separate top-level window that clutters
/// the taskbar and blocks other apps when fullscreen.
fn set_x11_overlay_hints(
video: &Arc<winit::window::Window>,
overlay: &Arc<winit::window::Window>,
) {
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
let Ok(video_handle) = video.window_handle() else { return; };
let Ok(overlay_handle) = overlay.window_handle() else { return; };
let Ok(disp_handle) = overlay.display_handle() else { return; };
let video_raw = video_handle.as_raw();
let overlay_raw = overlay_handle.as_raw();
let disp_raw = disp_handle.as_raw();
match (
video_raw,
overlay_raw,
disp_raw,
) {
(
raw_window_handle::RawWindowHandle::Xlib(video_x),
raw_window_handle::RawWindowHandle::Xlib(overlay_x),
raw_window_handle::RawDisplayHandle::Xlib(d),
) => {
#[link(name = "X11")]
extern "C" {
fn XInternAtom(
display: *mut std::os::raw::c_void,
name: *const std::os::raw::c_char,
only_if_exists: std::os::raw::c_int,
) -> std::os::raw::c_ulong;
fn XSetTransientForHint(
display: *mut std::os::raw::c_void,
w: std::os::raw::c_ulong,
prop_window: std::os::raw::c_ulong,
) -> std::os::raw::c_int;
fn XChangeProperty(
display: *mut std::os::raw::c_void,
w: std::os::raw::c_ulong,
property: std::os::raw::c_ulong,
atype: std::os::raw::c_ulong,
format: std::os::raw::c_int,
mode: std::os::raw::c_int,
data: *const std::os::raw::c_uchar,
nelements: std::os::raw::c_int,
) -> std::os::raw::c_int;
fn XFlush(display: *mut std::os::raw::c_void) -> std::os::raw::c_int;
}
let Some(display_ptr) = d.display else {
warn!("XlibDisplayHandle.display is None; cannot set overlay hints");
return;
};
let display = display_ptr.as_ptr();
unsafe {
let video_xid = video_x.window as std::os::raw::c_ulong;
let overlay_xid = overlay_x.window as std::os::raw::c_ulong;
// 1. Set _NET_WM_WINDOW_TYPE = DIALOG on the overlay.
let atom_window_type =
XInternAtom(display, b"_NET_WM_WINDOW_TYPE\0".as_ptr() as *const _, 0);
let atom_dialog =
XInternAtom(display, b"_NET_WM_WINDOW_TYPE_DIALOG\0".as_ptr() as *const _, 0);
let xa_atom = XInternAtom(display, b"ATOM\0".as_ptr() as *const _, 0);
XChangeProperty(
display,
overlay_xid,
atom_window_type,
xa_atom,
32,
0, // PropModeReplace
&atom_dialog as *const _ as *const std::os::raw::c_uchar,
1,
);
// 2. Set _NET_WM_STATE = _NET_WM_STATE_SKIP_TASKBAR.
let atom_wm_state =
XInternAtom(display, b"_NET_WM_STATE\0".as_ptr() as *const _, 0);
let atom_skip_taskbar =
XInternAtom(display, b"_NET_WM_STATE_SKIP_TASKBAR\0".as_ptr() as *const _, 0);
XChangeProperty(
display,
overlay_xid,
atom_wm_state,
xa_atom,
32,
0,
&atom_skip_taskbar as *const _ as *const std::os::raw::c_uchar,
1,
);
// 3. Set transient-for hint: overlay belongs to video window.
XSetTransientForHint(display, overlay_xid, video_xid);
XFlush(display);
}
info!(
"set overlay X11 hints: DIALOG type + SKIP_TASKBAR + transient-for video window"
);
}
(
raw_window_handle::RawWindowHandle::Xcb(_),
raw_window_handle::RawWindowHandle::Xcb(_),
raw_window_handle::RawDisplayHandle::Xcb(_),
) => {
warn!("XCB backend detected; overlay WM hints not set (Xlib path only)");
}
_ => {
// Not X11 — no WM hints needed (Wayland compositors handle this
// via their own protocol; macOS/iOS use NSPanel/etc.).
}
}
}
/// Spawn a worker thread to run ffmpeg for A-B loop video export. The /// Spawn a worker thread to run ffmpeg for A-B loop video export. The
/// thread runs the encode and sends the result back on `tx` when done. /// thread runs the encode and sends the result back on `tx` when done.
/// Runs in the background so the UI stays responsive during encoding. /// Runs in the background so the UI stays responsive during encoding.

View File

@ -21,3 +21,4 @@ tracing = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
libc = { workspace = true } libc = { workspace = true }
rand = { workspace = true }

View File

@ -1,9 +1,82 @@
//! Commands sent from the UI thread to the engine thread. //! Commands sent from the UI thread to the engine thread.
use serde::{Deserialize, Serialize};
use crate::error::CoreResult; use crate::error::CoreResult;
use mpv_bindings::command::{LoadMode, SeekFlags, SeekMode}; use mpv_bindings::command::{LoadMode, SeekFlags, SeekMode};
use serde::{Deserialize, Serialize};
// ---- Random / shuffle mode ----------------------------------------------
/// Describes how the random/shuffle feature selects the next file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RandomMode {
/// Shuffle is disabled; normal sequential playback.
Off,
/// Randomly pick from media files in the **same directory** as the
/// currently playing file.
SameFolder,
/// Randomly pick from media files in the **entire folder tree** rooted at
/// the current file's parent directory (recursive descent).
WholeTree,
/// Two-level random: first uniformly pick a subdirectory that contains
/// media files, then uniformly pick a file within it. This avoids the
/// VLC bug where selecting a subfolder always plays the same first file.
StepAware,
}
impl RandomMode {
/// Cycle through modes: Off → SameFolder → WholeTree → StepAware → Off.
pub fn cycle(self) -> Self {
const ORDER: [RandomMode; 4] = [
RandomMode::Off,
RandomMode::SameFolder,
RandomMode::WholeTree,
RandomMode::StepAware,
];
let idx = ORDER
.iter()
.position(|m| *m == self)
.expect("RandomMode is exhaustive over ORDER");
ORDER[(idx + 1) % ORDER.len()]
}
/// Human-readable label for menus.
pub fn label(self) -> &'static str {
const TABLE: [(RandomMode, &str); 4] = [
(RandomMode::Off, "Random: Off"),
(RandomMode::SameFolder, "Random: Same Folder"),
(RandomMode::WholeTree, "Random: Whole Tree"),
(RandomMode::StepAware, "Random: Step-Aware"),
];
TABLE
.iter()
.copied()
.find(|(m, _)| *m == self)
.map(|(_, l)| l)
.expect("RandomMode is exhaustive over TABLE")
}
/// Short label for the status bar.
pub fn short_label(self) -> &'static str {
const TABLE: [(RandomMode, &str); 4] = [
(RandomMode::Off, "rand:off"),
(RandomMode::SameFolder, "rand:folder"),
(RandomMode::WholeTree, "rand:tree"),
(RandomMode::StepAware, "rand:step"),
];
TABLE
.iter()
.copied()
.find(|(m, _)| *m == self)
.map(|(_, l)| l)
.expect("RandomMode is exhaustive over TABLE")
}
}
impl Default for RandomMode {
fn default() -> Self {
RandomMode::Off
}
}
// ---- Magic dialog-request strings -------------------------------------------- // ---- Magic dialog-request strings --------------------------------------------
// //
@ -191,6 +264,15 @@ pub enum Cmd {
path: String, path: String,
}, },
// ---- Random / shuffle ------------------------------------------------
/// Set the random/shuffle mode. Off disables it; other modes control how
/// the next random file is selected (same folder, whole tree, step-aware).
SetRandomMode(RandomMode),
/// Pick a random file according to the current `random_mode` and load it.
RandomNext,
// ---- Lifecycle ----------------------------------------------------- // ---- Lifecycle -----------------------------------------------------
/// Shutdown libmpv and exit the engine thread. /// Shutdown libmpv and exit the engine thread.

View File

@ -18,9 +18,11 @@
use std::sync::Arc; use std::sync::Arc;
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
use std::time::Duration; use std::time::Duration;
use std::path::{Path, PathBuf};
use crossbeam_channel::{bounded, Receiver, Sender}; use crossbeam_channel::{bounded, Receiver, Sender};
use parking_lot::Mutex; use parking_lot::Mutex;
use rand::seq::SliceRandom;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use mpv_bindings::event::{Event as MpvEvent, EventId, LogLevel}; use mpv_bindings::event::{Event as MpvEvent, EventId, LogLevel};
@ -28,7 +30,7 @@ use mpv_bindings::handle::Builder as MpvBuilder;
use mpv_bindings::property::{Format, Property}; use mpv_bindings::property::{Format, Property};
use mpv_bindings::MpvHandle; use mpv_bindings::MpvHandle;
use crate::cmd::{build_loadfile, build_seek, Cmd, LoopMode, MarkerExportFormat}; use crate::cmd::{build_loadfile, build_seek, Cmd, LoopMode, MarkerExportFormat, RandomMode};
use crate::error::{CoreError, CoreResult}; use crate::error::{CoreError, CoreResult};
use crate::event::{EngineEvent, EngineEventBus, EngineEventSender, EndReason}; use crate::event::{EngineEvent, EngineEventBus, EngineEventSender, EndReason};
use crate::options::EngineOptions; use crate::options::EngineOptions;
@ -304,6 +306,111 @@ fn engine_main(
} }
} }
// ---- Random / shuffle file-scanning helpers --------------------------------
const MEDIA_EXTENSIONS: &[&str] = &[
"mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "mp3", "flac",
"wav", "ogg", "opus", "m4a", "aac", "wma", "m2ts", "ts", "3gp",
"f4v", "ogv", "weba",
];
fn is_media_file(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| MEDIA_EXTENSIONS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
}
/// Collect media files directly in `dir` (non-recursive).
fn collect_files_in_dir(dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && is_media_file(&path) {
files.push(path);
}
}
}
files
}
/// Recursively collect media files under `dir`.
fn collect_files_recursive(dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && is_media_file(&path) {
files.push(path);
} else if path.is_dir() {
files.extend(collect_files_recursive(&path));
}
}
}
files
}
/// Step-aware collection: uniformly pick a subdirectory containing media,
/// then return only the media files within that subdirectory.
fn collect_files_step_aware(dir: &Path) -> Vec<PathBuf> {
let mut subdirs_with_media: Vec<PathBuf> = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if !collect_files_in_dir(&path).is_empty() {
subdirs_with_media.push(path);
}
}
}
}
// Include files directly in the root as another candidate group.
let root_files = collect_files_in_dir(dir);
if !root_files.is_empty() {
subdirs_with_media.push(dir.to_path_buf());
}
if subdirs_with_media.is_empty() {
return Vec::new();
}
let mut rng = rand::thread_rng();
if let Some(chosen_dir) = subdirs_with_media.choose(&mut rng) {
return collect_files_in_dir(chosen_dir);
}
Vec::new()
}
/// Unified entry point: returns the candidate list depending on mode.
fn collect_random_candidates(current_path: &Path, mode: RandomMode) -> Vec<PathBuf> {
// Determine the base directory to scan:
// - If current_path is a directory, scan it directly (user loaded a
// folder, media files are inside it).
// - If current_path is a file, scan its parent directory (user loaded
// a single file, siblings are in the same folder).
let dir = if current_path.is_dir() {
current_path
} else {
// Rust doesn't count guard conditions toward exhaustiveness, so we
// must cover all Option variants explicitly with Some(_) | None.
match current_path.parent() {
Some(d) if !d.as_os_str().is_empty() => d,
Some(_) | None => return Vec::new(),
}
};
match mode {
RandomMode::Off => Vec::new(),
RandomMode::SameFolder => collect_files_in_dir(dir),
RandomMode::WholeTree => collect_files_recursive(dir),
RandomMode::StepAware => collect_files_step_aware(dir),
}
}
fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()> { fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()> {
match cmd { match cmd {
Cmd::LoadFile { path, options } => { Cmd::LoadFile { path, options } => {
@ -637,6 +744,46 @@ fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()>
warn!("ExportABLoopVideo reached engine — should be intercepted by main app"); warn!("ExportABLoopVideo reached engine — should be intercepted by main app");
Ok(()) Ok(())
} }
// ---- Random / shuffle -------------------------------------------
Cmd::SetRandomMode(mode) => {
bus.update_state(|s| s.random_mode = *mode);
bus.send(EngineEvent::StateChanged);
info!("random mode set to {}", mode.label());
Ok(())
}
Cmd::RandomNext => {
let path_snapshot = bus.snapshot().path.clone();
let mode = bus.snapshot().random_mode;
if let Some(ref current) = path_snapshot {
let current_path = Path::new(current);
let candidates = collect_random_candidates(current_path, mode);
if candidates.is_empty() {
let msg = format!("no media files found for random mode ({})", mode.label());
warn!("{msg}");
bus.send(EngineEvent::Error { message: msg });
return Ok(());
}
let mut rng = rand::thread_rng();
let pick = candidates.choose(&mut rng).unwrap();
info!("random next: {}", pick.display());
let path_str = pick.to_string_lossy().into_owned();
let mpv_cmd = mpv_bindings::command::Command::loadfile(
&path_str,
mpv_bindings::command::LoadMode::Replace,
)?;
mpv.command(&mpv_cmd)?;
// Unpause so the new file starts playing.
mpv.set_property(&Property::flag("pause", false))?;
} else {
bus.send(EngineEvent::Error {
message: "Random Next: no file currently loaded".into(),
});
}
Ok(())
}
Cmd::Shutdown => unreachable!("handled by caller"), Cmd::Shutdown => unreachable!("handled by caller"),
} }
} }

View File

@ -32,7 +32,7 @@ pub mod error;
pub mod options; pub mod options;
pub use cmd::{ pub use cmd::{
Cmd, LoadOptions, LoopMode, MarkerExportFormat, Cmd, LoadOptions, LoopMode, MarkerExportFormat, RandomMode,
MAGIC_FILE_DIALOG, MAGIC_FOLDER_DIALOG, MAGIC_PLAYLIST_DIALOG, MAGIC_FILE_DIALOG, MAGIC_FOLDER_DIALOG, MAGIC_PLAYLIST_DIALOG,
MAGIC_SAVE_DIALOG_TXT, MAGIC_SAVE_DIALOG_JSON, MAGIC_SAVE_DIALOG_TXT, MAGIC_SAVE_DIALOG_JSON,
MAGIC_SUBTITLE_DIALOG, MAGIC_IMPORT_MARKERS_DIALOG, MAGIC_SUBTITLE_DIALOG, MAGIC_IMPORT_MARKERS_DIALOG,

View File

@ -2,7 +2,7 @@
use serde::{Serialize, Serializer}; use serde::{Serialize, Serializer};
use crate::cmd::LoopMode; use crate::cmd::{LoopMode, RandomMode};
/// One mpv track-list entry. Used for both audio and subtitle tracks — they /// One mpv track-list entry. Used for both audio and subtitle tracks — they
/// have the same shape, just different `type` values ("audio" vs "sub"). /// have the same shape, just different `type` values ("audio" vs "sub").
@ -101,6 +101,9 @@ pub struct PlaybackState {
pub marker_b: Option<f64>, pub marker_b: Option<f64>,
/// Is A→B loop currently enabled? /// Is A→B loop currently enabled?
pub marker_loop_enabled: bool, pub marker_loop_enabled: bool,
/// Current random/shuffle mode. Mirrors the engine's last `SetRandomMode` cmd.
pub random_mode: RandomMode,
} }
// Serialize Track for the JSON marker export. We do it manually so the // Serialize Track for the JSON marker export. We do it manually so the

View File

@ -26,7 +26,7 @@ use egui::{Color32, Context, Layout, Ui, Vec2};
use player_core::event::EngineEvent; use player_core::event::EngineEvent;
use player_core::state::PlaybackState; use player_core::state::PlaybackState;
use player_core::{Cmd, LoopMode}; use player_core::{Cmd, LoopMode, RandomMode};
use crate::icons; use crate::icons;
use crate::theme::Theme; use crate::theme::Theme;
@ -570,6 +570,36 @@ impl OverlayApp {
ui.separator(); ui.separator();
ui.label(
egui::RichText::new("Random / Shuffle")
.color(fg_dim).size(10.0).strong(),
);
ui.add_space(2.0);
let random_modes = [
("Off", RandomMode::Off),
("Same Folder", RandomMode::SameFolder),
("Whole Tree", RandomMode::WholeTree),
("Step-Aware", RandomMode::StepAware),
];
let random_mode = self.state.random_mode;
for (label, mode) in random_modes {
let checked = random_mode == mode;
if ui.selectable_label(checked, label).clicked() {
pending_cmds.push(Cmd::SetRandomMode(mode));
ui.close_menu();
}
}
if ui.button("Random Next (R)").clicked() {
pending_cmds.push(Cmd::RandomNext);
ui.close_menu();
}
if ui.button("Cycle Random Mode (S)").clicked() {
pending_cmds.push(Cmd::SetRandomMode(random_mode.cycle()));
ui.close_menu();
}
ui.separator();
ui.label( ui.label(
egui::RichText::new("Speed") egui::RichText::new("Speed")
.color(fg_dim).size(10.0).strong(), .color(fg_dim).size(10.0).strong(),
@ -719,7 +749,7 @@ impl OverlayApp {
} }
ui.separator(); ui.separator();
ui.label( ui.label(
egui::RichText::new("ferret 1.0.0") egui::RichText::new("ferret 1.2.0")
.color(fg_dim).size(10.0), .color(fg_dim).size(10.0),
); );
ui.label( ui.label(
@ -742,8 +772,14 @@ impl OverlayApp {
LoopMode::File => " loop:file".into(), LoopMode::File => " loop:file".into(),
LoopMode::Playlist => " loop:list".into(), LoopMode::Playlist => " loop:list".into(),
}; };
let random_mode = self.state.random_mode;
let rand_str = if random_mode == RandomMode::Off {
String::new()
} else {
format!(" {}", random_mode.short_label())
};
ui.label( ui.label(
egui::RichText::new(format!("{status}{speed_str}{loop_str}")) egui::RichText::new(format!("{status}{speed_str}{loop_str}{rand_str}"))
.color(fg_dim).size(10.0), .color(fg_dim).size(10.0),
); );
}); });
@ -923,7 +959,7 @@ impl OverlayApp {
.strong(), .strong(),
); );
ui.label( ui.label(
egui::RichText::new("1.0.0") egui::RichText::new("1.2.0")
.color(fg_dim) .color(fg_dim)
.size(13.0), .size(13.0),
); );
@ -1094,6 +1130,16 @@ impl OverlayApp {
let ff_resp = self.icon_button(ui, "btn_frame_fwd", btn_size, |p, r, c| icons::frame_forward(p, r, c)); let ff_resp = self.icon_button(ui, "btn_frame_fwd", btn_size, |p, r, c| icons::frame_forward(p, r, c));
if ff_resp.clicked() { self.send(Cmd::FrameStep); } if ff_resp.clicked() { self.send(Cmd::FrameStep); }
ui.add_space(6.0);
// Shuffle button — cycles through random modes (Off → Folder → Tree → Step → Off).
let rand_active = self.state.random_mode != RandomMode::Off;
let rand_resp = self.icon_button_toggled(ui, "btn_random", btn_size, rand_active, |p, r, c| {
icons::shuffle(p, r, c, rand_active)
});
if rand_resp.clicked() { self.send(Cmd::SetRandomMode(self.state.random_mode.cycle())); }
rand_resp.on_hover_text(format!("Cycle Random Mode — {}", self.state.random_mode.label()));
// Center: time display // Center: time display
ui.with_layout(Layout::centered_and_justified(egui::Direction::TopDown), |ui| { ui.with_layout(Layout::centered_and_justified(egui::Direction::TopDown), |ui| {
let time_str = self.state.time_str(); let time_str = self.state.time_str();

View File

@ -221,13 +221,16 @@ impl FileDialog {
/// needs to select something first. /// needs to select something first.
fn confirm(&self) -> Option<FileDialogResult> { fn confirm(&self) -> Option<FileDialogResult> {
if self.kind.is_folder() { if self.kind.is_folder() {
// Folder select: return the selected directory (or current dir). // Folder select: return the selected directory, or fall back to
// the current directory so the user can open the folder they're
// already browsing without navigating up first.
if let Some(sel) = &self.selected { if let Some(sel) = &self.selected {
if sel.is_dir() { if sel.is_dir() {
return Some(FileDialogResult::Path(sel.to_string_lossy().into_owned())); return Some(FileDialogResult::Path(sel.to_string_lossy().into_owned()));
} }
} }
return None; // No subfolder selected — use the current directory itself.
return Some(FileDialogResult::Path(self.current_dir.to_string_lossy().into_owned()));
} }
if self.kind.is_save() { if self.kind.is_save() {
// Save: need a filename. // Save: need a filename.

View File

@ -405,3 +405,72 @@ pub fn speed_gauge(painter: &Painter, rect: Rect, color: Color32, speed: f32) {
Stroke::new(thick * 1.2, Color32::from_rgb(255, 168, 40)), Stroke::new(thick * 1.2, Color32::from_rgb(255, 168, 40)),
); );
} }
/// Shuffle icon — two crossed arrows with arrowheads. When `active` is true
/// the icon is rendered at full opacity; when false it is drawn dimmed to
/// indicate the feature is disengaged.
pub fn shuffle(painter: &Painter, rect: Rect, color: Color32, active: bool) {
let size = rect.height().min(rect.width()) * 0.55;
let cx = rect.center().x;
let cy = rect.center().y;
let thick = size * 0.10;
let alpha = if active { 1.0 } else { 0.3 };
let col = Color32::from_rgba_unmultiplied(
(color.r() as f32 * alpha) as u8,
(color.g() as f32 * alpha) as u8,
(color.b() as f32 * alpha) as u8,
(color.a() as f32 * alpha) as u8,
);
// Two lines crossing in an X pattern.
let half_w = size * 0.45;
let half_h = size * 0.28;
// Top-left → bottom-right arrow.
let tl = Pos2::new(cx - half_w, cy - half_h);
let br = Pos2::new(cx + half_w, cy + half_h);
painter.line_segment([tl, br], Stroke::new(thick, col));
// Bottom-left → top-right arrow.
let bl = Pos2::new(cx - half_w, cy + half_h);
let tr = Pos2::new(cx + half_w, cy - half_h);
painter.line_segment([bl, tr], Stroke::new(thick, col));
// Arrowheads.
let arrow = size * 0.16;
let angle = 0.4;
// Arrowhead at top-right (on bl→tr).
let dir_x = -(tr.x - bl.x);
let dir_y = -(tr.y - bl.y);
let len = (dir_x * dir_x + dir_y * dir_y).sqrt();
let dx = dir_x / len;
let dy = dir_y / len;
let px = -dy;
let py = dx;
painter.line_segment(
[tr, Pos2::new(tr.x + (dx + px) * arrow * angle, tr.y + (dy + py) * arrow * angle)],
Stroke::new(thick, col),
);
painter.line_segment(
[tr, Pos2::new(tr.x + (dx - px) * arrow * angle, tr.y + (dy - py) * arrow * angle)],
Stroke::new(thick, col),
);
// Arrowhead at bottom-right (on tl→br).
let dir_x2 = -(br.x - tl.x);
let dir_y2 = -(br.y - tl.y);
let len2 = (dir_x2 * dir_x2 + dir_y2 * dir_y2).sqrt();
let dx2 = dir_x2 / len2;
let dy2 = dir_y2 / len2;
let px2 = -dy2;
let py2 = dx2;
painter.line_segment(
[br, Pos2::new(br.x + (dx2 + px2) * arrow * angle, br.y + (dy2 + py2) * arrow * angle)],
Stroke::new(thick, col),
);
painter.line_segment(
[br, Pos2::new(br.x + (dx2 - px2) * arrow * angle, br.y + (dy2 - py2) * arrow * angle)],
Stroke::new(thick, col),
);
}