Ferret v1.2.3 - A modern, accuracy-first video player for Linux, written in Rust.
This commit is contained in:
parent
1b8c38f17b
commit
6e71d72d1f
File diff suppressed because it is too large
Load Diff
|
|
@ -8,7 +8,7 @@ members = [
|
|||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0"
|
||||
version = "1.2.3"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
license = "GPL-2.0-or-later"
|
||||
|
|
@ -51,6 +51,9 @@ serde = { version = "1", features = ["derive"] }
|
|||
serde_json = "1"
|
||||
libc = "0.2"
|
||||
|
||||
# Random / shuffle
|
||||
rand = "0.8"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
|
|
|
|||
|
|
@ -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_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_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`. ----
|
||||
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 prev_track(_: &PlaybackState) -> Cmd { Cmd::PlaylistPrev }
|
||||
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
|
||||
// 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_p, prev_track),
|
||||
(is_v, toggle_subs),
|
||||
(is_r, random_next),
|
||||
(is_s, cycle_random),
|
||||
];
|
||||
TABLE
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -114,6 +114,10 @@ struct FerretApp {
|
|||
/// know what Cmd to emit once we have the path.
|
||||
dialog_result_rx: crossbeam_channel::Receiver<(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 {
|
||||
|
|
@ -133,6 +137,7 @@ impl FerretApp {
|
|||
cmd_rx,
|
||||
dialog_result_rx,
|
||||
dialog_result_tx,
|
||||
has_focus: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,6 +202,16 @@ impl FerretApp {
|
|||
self.windows.video = Some(video_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();
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -339,14 +354,35 @@ impl FerretApp {
|
|||
self.fullscreen = !self.fullscreen;
|
||||
if self.fullscreen {
|
||||
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 {
|
||||
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.
|
||||
if let Some(overlay) = self.overlay.as_mut() {
|
||||
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 {
|
||||
|
|
@ -369,6 +405,10 @@ impl ApplicationHandler for FerretApp {
|
|||
|
||||
match kind {
|
||||
WindowKind::Video => match event {
|
||||
WindowEvent::Focused(gained) => {
|
||||
self.has_focus = gained;
|
||||
self.update_overlay_focus();
|
||||
}
|
||||
WindowEvent::KeyboardInput {
|
||||
event:
|
||||
KeyEvent {
|
||||
|
|
@ -419,6 +459,10 @@ impl ApplicationHandler for FerretApp {
|
|||
_ => {}
|
||||
},
|
||||
WindowKind::Overlay => match event {
|
||||
WindowEvent::Focused(gained) => {
|
||||
self.has_focus = gained;
|
||||
self.update_overlay_focus();
|
||||
}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
// The renderer sets pixels_per_point=1.0, so egui's
|
||||
// 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
|
||||
/// thread runs the encode and sends the result back on `tx` when done.
|
||||
/// Runs in the background so the UI stays responsive during encoding.
|
||||
|
|
|
|||
|
|
@ -21,3 +21,4 @@ tracing = { workspace = true }
|
|||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,9 +1,82 @@
|
|||
//! Commands sent from the UI thread to the engine thread.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::CoreResult;
|
||||
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 --------------------------------------------
|
||||
//
|
||||
|
|
@ -191,6 +264,15 @@ pub enum Cmd {
|
|||
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 -----------------------------------------------------
|
||||
|
||||
/// Shutdown libmpv and exit the engine thread.
|
||||
|
|
|
|||
|
|
@ -18,9 +18,11 @@
|
|||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crossbeam_channel::{bounded, Receiver, Sender};
|
||||
use parking_lot::Mutex;
|
||||
use rand::seq::SliceRandom;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
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::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::event::{EngineEvent, EngineEventBus, EngineEventSender, EndReason};
|
||||
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<()> {
|
||||
match cmd {
|
||||
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");
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ pub mod error;
|
|||
pub mod options;
|
||||
|
||||
pub use cmd::{
|
||||
Cmd, LoadOptions, LoopMode, MarkerExportFormat,
|
||||
Cmd, LoadOptions, LoopMode, MarkerExportFormat, RandomMode,
|
||||
MAGIC_FILE_DIALOG, MAGIC_FOLDER_DIALOG, MAGIC_PLAYLIST_DIALOG,
|
||||
MAGIC_SAVE_DIALOG_TXT, MAGIC_SAVE_DIALOG_JSON,
|
||||
MAGIC_SUBTITLE_DIALOG, MAGIC_IMPORT_MARKERS_DIALOG,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
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
|
||||
/// have the same shape, just different `type` values ("audio" vs "sub").
|
||||
|
|
@ -101,6 +101,9 @@ pub struct PlaybackState {
|
|||
pub marker_b: Option<f64>,
|
||||
/// Is A→B loop currently enabled?
|
||||
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
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ use egui::{Color32, Context, Layout, Ui, Vec2};
|
|||
|
||||
use player_core::event::EngineEvent;
|
||||
use player_core::state::PlaybackState;
|
||||
use player_core::{Cmd, LoopMode};
|
||||
use player_core::{Cmd, LoopMode, RandomMode};
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::Theme;
|
||||
|
|
@ -570,6 +570,36 @@ impl OverlayApp {
|
|||
|
||||
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(
|
||||
egui::RichText::new("Speed")
|
||||
.color(fg_dim).size(10.0).strong(),
|
||||
|
|
@ -719,7 +749,7 @@ impl OverlayApp {
|
|||
}
|
||||
ui.separator();
|
||||
ui.label(
|
||||
egui::RichText::new("ferret 1.0.0")
|
||||
egui::RichText::new("ferret 1.2.0")
|
||||
.color(fg_dim).size(10.0),
|
||||
);
|
||||
ui.label(
|
||||
|
|
@ -742,8 +772,14 @@ impl OverlayApp {
|
|||
LoopMode::File => " loop:file".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(
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
|
@ -923,7 +959,7 @@ impl OverlayApp {
|
|||
.strong(),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new("1.0.0")
|
||||
egui::RichText::new("1.2.0")
|
||||
.color(fg_dim)
|
||||
.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));
|
||||
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
|
||||
ui.with_layout(Layout::centered_and_justified(egui::Direction::TopDown), |ui| {
|
||||
let time_str = self.state.time_str();
|
||||
|
|
|
|||
|
|
@ -221,13 +221,16 @@ impl FileDialog {
|
|||
/// needs to select something first.
|
||||
fn confirm(&self) -> Option<FileDialogResult> {
|
||||
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 sel.is_dir() {
|
||||
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() {
|
||||
// Save: need a filename.
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue