//! Media Browser (M12.7): directory listing, navigation history, Favorites, recent directories, //! file-type filter, import and Open in Source Monitor. All file access goes through the host's //! [`Services`] (`list_entries`, `volumes`, `home_dir`), so tests run against a fake filesystem //! and the web build lists the files the user picked or dropped. //! //! | Id | What | //! |---|---| //! | `mediaBrowser.roots` | Favorites, Local Drives, Network, recent directories, home | //! | `mediaBrowser.list` | entries of a directory (default: the current one), filtered by file type | //! | `mediaBrowser.navigate` | go to a directory, or `back` / `forward` / `up` | //! | `mediaBrowser.select` | the selected files (File ▸ Import from Media Browser uses them) | //! | `mediaBrowser.favorite` | Add to / Remove from Favorites | //! | `mediaBrowser.clearRecent` | Clear Recent Directories | //! | `mediaBrowser.settings` | file types, view (list / thumbnails), columns (Edit Columns…), Import as Image Sequence, Hover Scrub, thumbnail size | //! | `mediaBrowser.import` | Import (the selection or `paths`; a folder becomes a bin, its sub-folders bins inside it; Project ▸ Ingest settings apply) | //! | `mediaBrowser.openInSource` | Open In Source Monitor (Shift+O) | //! | `mediaBrowser.probe` | a file's media properties (frame rate, duration, video / audio info), cached | //! //! Navigation history and the selection are session state ([`BrowserState`]); Favorites, recent //! directories and the view settings are preferences ([`MediaBrowserPrefs`]). use std::collections::BTreeMap; use filmcraft_media::MediaInfo; use filmcraft_project::ItemId; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, str_p, u64_p}; use crate::{EngineError, Result, Services, Session}; /// One directory entry as the host reports it. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DirEntry { pub name: String, pub is_dir: bool, pub size: Option, /// Modification time (seconds since the Unix epoch). pub modified: Option, } /// Where a volume shows in the directory tree. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum VolumeKind { #[default] Local, Network, } /// A drive or network location (Local Drives / Network). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Volume { pub name: String, pub path: String, pub kind: VolumeKind, } /// File types shown (the "File Types Displayed" menu). pub const FILE_TYPES: [(&str, &str); 6] = [ ("all", "All Supported Files"), ("video", "Video Files"), ("audio", "Audio Files"), ("image", "Still Images"), ("project", "Projects and Interchange"), ("caption", "Captions"), ]; /// Columns the list view can show (Edit Columns…), in default order. pub const ALL_COLUMNS: [&str; 10] = ["Name", "Media Type", "Size", "Date Modified", "Frame Rate", "Media Duration", "Video Info", "Audio Info", "Video Codec", "Audio Codec"]; pub const DEFAULT_COLUMNS: [&str; 5] = ["Name", "Frame Rate", "Media Duration", "Video Info", "Size"]; /// Project and interchange files the browser can import. Avid Log Exchange (`.ale`) is left out: /// FilmCraft writes it (File ▸ Export) but, like Premiere, does not read it (#1292). const PROJECT_EXTENSIONS: [&str; 5] = ["fcproj", "edl", "xml", "fcpxml", "otio"]; const CAPTION_EXTENSIONS: [&str; 3] = ["srt", "vtt", "scc"]; const RECENT_MAX: usize = 10; /// Preferences of the Media Browser (`Preferences::media_browser`). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct MediaBrowserPrefs { pub favorites: Vec, /// Recent directories, newest first. pub recent: Vec, /// Directory shown when the browser opens ("" = the home directory). pub last_dir: String, /// One of [`FILE_TYPES`], or a file extension (`mov`). pub file_types: String, /// `list` | `thumbnails`. pub view: String, pub columns: Vec, pub import_as_image_sequence: bool, pub hover_scrub: bool, pub thumbnail_size: f32, } impl Default for MediaBrowserPrefs { fn default() -> Self { MediaBrowserPrefs { favorites: Vec::new(), recent: Vec::new(), last_dir: String::new(), file_types: "all".into(), view: "list".into(), columns: DEFAULT_COLUMNS.iter().map(|c| c.to_string()).collect(), import_as_image_sequence: false, hover_scrub: true, thumbnail_size: 120.0, } } } /// Session state of the Media Browser. #[derive(Clone, Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct BrowserState { /// The directory shown ("" = not chosen yet: the last or home directory). pub dir: String, pub back: Vec, pub forward: Vec, pub selection: Vec, /// Probed media properties by path (None = not a readable media file). #[serde(skip)] pub probes: BTreeMap>, } /// A listed entry with what the browser shows about it. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct Entry { pub name: String, pub path: String, pub is_dir: bool, /// `folder` | `video` | `audio` | `image` | `project` | `caption`. pub kind: &'static str, pub size: Option, pub modified: Option, /// A numbered still (Import as Image Sequence applies). pub numbered: bool, } fn ext_of(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() } /// The kind of file a name is, or None when the browser doesn't show it. pub fn kind_of(name: &str) -> Option<&'static str> { let e = ext_of(name); if filmcraft_media::VIDEO_EXTENSIONS.contains(&e.as_str()) { Some("video") } else if filmcraft_media::AUDIO_EXTENSIONS.contains(&e.as_str()) { Some("audio") } else if filmcraft_media::STILL_EXTENSIONS.contains(&e.as_str()) { Some("image") } else if PROJECT_EXTENSIONS.contains(&e.as_str()) { Some("project") } else if CAPTION_EXTENSIONS.contains(&e.as_str()) { Some("caption") } else { None } } /// Does a file of `kind` and extension pass the file-type filter? pub fn passes(filter: &str, kind: &str, name: &str) -> bool { match filter { "" | "all" => true, "video" | "audio" | "image" | "project" | "caption" => kind == filter, ext => ext_of(name) == ext.trim_start_matches('.').to_ascii_lowercase(), } } /// Every extension the browser lists (for the file-type menu). pub fn extensions() -> Vec<&'static str> { filmcraft_media::VIDEO_EXTENSIONS .iter() .chain(filmcraft_media::AUDIO_EXTENSIONS) .chain(filmcraft_media::STILL_EXTENSIONS) .chain(&PROJECT_EXTENSIONS) .chain(&CAPTION_EXTENSIONS) .copied() .collect() } fn windows_style(p: &str) -> bool { p.contains('\\') && !p.contains('/') } /// `dir` + `name` with the directory's separator. pub fn join(dir: &str, name: &str) -> String { let sep = if windows_style(dir) { '\\' } else { '/' }; if dir.is_empty() { name.to_string() } else if dir.ends_with(sep) { format!("{dir}{name}") } else { format!("{dir}{sep}{name}") } } /// The parent directory (None at a root). pub fn parent(dir: &str) -> Option { let sep = if windows_style(dir) { '\\' } else { '/' }; let t = dir.trim_end_matches(sep); if t.is_empty() { return None; } let i = t.rfind(sep)?; let p = &t[..i]; if p.is_empty() { Some(sep.to_string()) } else if windows_style(dir) && p.ends_with(':') { Some(format!("{p}\\")) } else { Some(p.to_string()) } } /// The last path component (a directory's name in the tree). pub fn base_name(path: &str) -> String { let sep = if windows_style(path) { '\\' } else { '/' }; let t = path.trim_end_matches(sep); t.rsplit(sep).next().filter(|n| !n.is_empty()).unwrap_or(path).to_string() } /// List a directory: sub-directories first, then files the browser shows (hidden files left out), /// each group by name (case-insensitive). pub fn list(services: &dyn Services, dir: &str, filter: &str) -> std::io::Result> { let raw = services.list_entries(dir).unwrap_or_else(|| Err(std::io::Error::other("this host cannot list directories")))?; let mut out: Vec = raw .into_iter() .filter(|e| !e.name.starts_with('.')) .filter_map(|e| { let path = join(dir, &e.name); if e.is_dir { return Some(Entry { numbered: false, name: e.name, path, is_dir: true, kind: "folder", size: None, modified: e.modified }); } let kind = kind_of(&e.name)?; if !passes(filter, kind, &e.name) { return None; } let numbered = kind == "image" && filmcraft_media::sequence::Numbered::parse(&path).is_some(); Some(Entry { numbered, name: e.name, path, is_dir: false, kind, size: e.size, modified: e.modified }) }) .collect(); out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); Ok(out) } /// The directory shown: the current one, else the last one, else home, else the first volume. pub fn current_dir(s: &Session) -> String { if !s.browser.dir.is_empty() { return s.browser.dir.clone(); } let last = &s.prefs.media_browser.last_dir; if !last.is_empty() { return last.clone(); } s.services.home_dir().or_else(|| s.services.volumes().first().map(|v| v.path.clone())).unwrap_or_else(|| "/".into()) } fn save_prefs(s: &mut Session, prefs: crate::autosave::Preferences) -> Result<()> { if prefs != s.prefs { s.set_prefs(prefs).map_err(|e| EngineError::Other(format!("saving preferences: {e}")))?; } Ok(()) } fn state_json(s: &Session) -> Value { json!({ "dir": current_dir(s), "canBack": !s.browser.back.is_empty(), "canForward": !s.browser.forward.is_empty(), "canUp": parent(¤t_dir(s)).is_some(), "selection": s.browser.selection, "favorite": s.prefs.media_browser.favorites.contains(¤t_dir(s)), }) } fn roots(s: &mut Session, _: &Value) -> Result { let vols = s.services.volumes(); let (local, network): (Vec, Vec) = vols.into_iter().partition(|v| v.kind == VolumeKind::Local); let mb = &s.prefs.media_browser; let named = |v: &Vec| v.iter().map(|p| json!({"name": base_name(p), "path": p})).collect::>(); Ok(json!({ "favorites": named(&mb.favorites), "localDrives": local, "network": network, "recent": named(&mb.recent), "home": s.services.home_dir(), })) } fn list_cmd(s: &mut Session, p: &Value) -> Result { let dir = str_p(p, "path").map(str::to_string).unwrap_or_else(|| current_dir(s)); let filter = str_p(p, "fileTypes").map(str::to_string).unwrap_or_else(|| s.prefs.media_browser.file_types.clone()); let entries = list(&*s.services, &dir, &filter).map_err(|e| bad("mediaBrowser.list", format!("{dir}: {e}")))?; let mut out = state_json(s); out["dir"] = json!(dir); out["fileTypes"] = json!(filter); out["entries"] = serde_json::to_value(&entries).unwrap_or_default(); Ok(out) } fn go(s: &mut Session, dir: String, history: bool) -> Result<()> { // the directory must be listable list(&*s.services, &dir, "all").map_err(|e| bad("mediaBrowser.navigate", format!("{dir}: {e}")))?; let cur = current_dir(s); if history && cur != dir { s.browser.back.push(cur); s.browser.forward.clear(); } s.browser.dir = dir.clone(); s.browser.selection.clear(); let mut prefs = s.prefs.clone(); let mb = &mut prefs.media_browser; mb.recent.retain(|r| *r != dir); mb.recent.insert(0, dir.clone()); mb.recent.truncate(RECENT_MAX); mb.last_dir = dir; save_prefs(s, prefs) } fn navigate(s: &mut Session, p: &Value) -> Result { if bool_p(p, "back") == Some(true) { let to = s.browser.back.pop().ok_or_else(|| bad("mediaBrowser.navigate", "nothing to go back to"))?; let cur = current_dir(s); if let Err(e) = go(s, to.clone(), false) { s.browser.back.push(to); return Err(e); } s.browser.forward.push(cur); } else if bool_p(p, "forward") == Some(true) { let to = s.browser.forward.pop().ok_or_else(|| bad("mediaBrowser.navigate", "nothing to go forward to"))?; let cur = current_dir(s); if let Err(e) = go(s, to.clone(), false) { s.browser.forward.push(to); return Err(e); } s.browser.back.push(cur); } else if bool_p(p, "up") == Some(true) { let to = parent(¤t_dir(s)).ok_or_else(|| bad("mediaBrowser.navigate", "already at the top"))?; go(s, to, true)?; } else { let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.navigate", "need `path`, or `back` / `forward` / `up`"))?; go(s, path.to_string(), true)?; } list_cmd(s, &json!({})) } fn select(s: &mut Session, p: &Value) -> Result { let paths: Vec = p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); s.browser.selection = paths; Ok(state_json(s)) } fn favorite(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").map(str::to_string).unwrap_or_else(|| current_dir(s)); let remove = bool_p(p, "remove").unwrap_or(false); let mut prefs = s.prefs.clone(); let fav = &mut prefs.media_browser.favorites; if remove { if !fav.contains(&path) { return Err(bad("mediaBrowser.favorite", format!("{path} is not a favorite"))); } fav.retain(|f| *f != path); } else if !fav.contains(&path) { fav.push(path); } save_prefs(s, prefs)?; Ok(json!({"favorites": s.prefs.media_browser.favorites})) } fn settings(s: &mut Session, p: &Value) -> Result { let mut prefs = s.prefs.clone(); let mb = &mut prefs.media_browser; if let Some(v) = str_p(p, "fileTypes") { let v = v.trim().trim_start_matches('.').to_ascii_lowercase(); if !FILE_TYPES.iter().any(|f| f.0 == v) && !extensions().contains(&v.as_str()) { return Err(bad("mediaBrowser.settings", format!("unknown file type `{v}`"))); } mb.file_types = v; } if let Some(v) = str_p(p, "view") { mb.view = match v.to_ascii_lowercase().as_str() { "list" => "list".into(), "thumbnails" | "thumbnail" | "icon" => "thumbnails".into(), _ => return Err(bad("mediaBrowser.settings", "view is `list` or `thumbnails`")), }; } if let Some(a) = p.get("columns").and_then(Value::as_array) { let mut cols = vec!["Name".to_string()]; for c in a.iter().filter_map(Value::as_str) { let c = ALL_COLUMNS.iter().find(|x| x.eq_ignore_ascii_case(c)).ok_or_else(|| bad("mediaBrowser.settings", format!("unknown column `{c}`")))?; if !cols.iter().any(|x| x == c) { cols.push(c.to_string()); } } mb.columns = cols; } if let Some(v) = bool_p(p, "importAsImageSequence") { mb.import_as_image_sequence = v; } if let Some(v) = bool_p(p, "hoverScrub") { mb.hover_scrub = v; } if let Some(v) = f64_p(p, "thumbnailSize") { mb.thumbnail_size = (v as f32).clamp(60.0, 320.0); } save_prefs(s, prefs)?; Ok(serde_json::to_value(&s.prefs.media_browser).unwrap_or_default()) } fn paths_p(s: &Session, p: &Value) -> Vec { match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(), None => str_p(p, "path").map(|x| vec![x.to_string()]).unwrap_or_else(|| s.browser.selection.clone()), } } /// Most folders one import walks, and how deep it goes: a folder import mirrors the tree as bins, /// and symlinked folders can loop. const FOLDER_IMPORT_MAX_FOLDERS: usize = 4096; const FOLDER_IMPORT_MAX_DEPTH: usize = 32; /// A folder to import as a bin: its name, its files and its sub-folders that hold files. struct Folder { name: String, files: Vec, subs: Vec, } /// The files the browser shows in `dir` and its sub-folders, as a tree; `None` when the tree /// holds none (or `budget` folders were walked already). fn walk_folder(services: &dyn Services, dir: &str, depth: usize, budget: &mut usize) -> Option { *budget = budget.checked_sub(1)?; let entries = list(services, dir, "all").ok()?; let mut folder = Folder { name: base_name(dir), files: Vec::new(), subs: Vec::new() }; for e in entries { if !e.is_dir { folder.files.push(e.path); } else if depth < FOLDER_IMPORT_MAX_DEPTH && let Some(sub) = walk_folder(services, &e.path, depth + 1, budget) { folder.subs.push(sub); } } (!folder.files.is_empty() || !folder.subs.is_empty()).then_some(folder) } /// Add a `file.import` result to the merged one: arrays add up, other values (the Ingest result) /// are listed. fn merge_import(out: &mut serde_json::Map, r: Value) { let Value::Object(r) = r else { return }; for (k, v) in r { let slot = out.entry(k).or_insert_with(|| json!([])); let Value::Array(all) = slot else { continue }; match v { Value::Null => {} Value::Array(a) => all.extend(a), other => all.push(other), } } } /// Run `file.import` and merge its result; a failure becomes an error of the merged result. fn import_into(s: &mut Session, files: &[String], bin: Option, out: &mut serde_json::Map) { let mut q = json!({"paths": files, "imageSequence": false}); if let Some(b) = bin { q["bin"] = json!(b); } match s.execute("file.import", q) { Ok(r) => merge_import(out, r), Err(e) => merge_import(out, json!({"errors": [e.to_string()]})), } } /// Import a folder as a bin named after it, in `parent`: its files go into the bin, each /// sub-folder becomes a bin inside it (Premiere's Media Browser keeps the folder structure). fn import_folder(s: &mut Session, folder: &Folder, parent: Option, out: &mut serde_json::Map) -> Result<()> { let name = folder.name.clone(); let bin = s.edit("New Bin", |pr, _| Ok(pr.add_bin(&name, parent.map(filmcraft_project::BinId))))?; merge_import(out, json!({"bins": [bin.0]})); if !folder.files.is_empty() { import_into(s, &folder.files, Some(bin.0), out); } // recursion as deep as the walk went, which is bounded for sub in &folder.subs { import_folder(s, sub, Some(bin.0), out)?; } Ok(()) } /// `mediaBrowser.import {paths?, bin?, imageSequence?}`: imports files. A folder becomes a bin /// named after it (in `bin`) holding its files, and each sub-folder a bin inside that, as in /// Premiere; the result then also lists the new `bins`. Import as Image Sequence takes the first /// file instead. fn import(s: &mut Session, p: &Value) -> Result { let paths = paths_p(s, p); if paths.is_empty() { return Err(bad("mediaBrowser.import", "select files in the Media Browser")); } let seq = bool_p(p, "imageSequence").unwrap_or(s.prefs.media_browser.import_as_image_sequence); let bin = u64_p(p, "bin"); let mut files = Vec::new(); let mut folders = Vec::new(); let mut empty = Vec::new(); let mut budget = FOLDER_IMPORT_MAX_FOLDERS; for path in paths { let is_dir = matches!(s.services.list_entries(&path), Some(Ok(_))) && kind_of(&path).is_none(); if !is_dir { files.push(path); } else if seq { // an image sequence: the folder's frames match list(&*s.services, &path, "all") { Ok(entries) => files.extend(entries.into_iter().filter(|e| !e.is_dir).map(|e| e.path)), Err(_) => files.push(path), } } else { match walk_folder(&*s.services, &path, 0, &mut budget) { Some(f) => folders.push(f), None => empty.push(path), } } } // an image sequence imports from its first selected frame only if seq { files.truncate(1); } if folders.is_empty() { if files.is_empty() && !empty.is_empty() { return Err(bad("mediaBrowser.import", format!("no media files in {}", empty.join(", ")))); } let mut q = json!({"paths": files, "imageSequence": seq}); if let Some(b) = bin { q["bin"] = json!(b); } return s.execute("file.import", q); } let mut out = serde_json::Map::new(); merge_import(&mut out, json!({"items": [], "errors": empty.iter().map(|e| format!("{e}: no media files")).collect::>(), "bins": []})); if !files.is_empty() { import_into(s, &files, bin, &mut out); } for f in &folders { import_folder(s, f, bin, &mut out)?; } Ok(Value::Object(out)) } /// The project item that already references a file. pub fn item_for_path(s: &Session, path: &str) -> Option { s.project .items .values() .find(|i| i.as_media().is_some_and(|m| matches!(&m.media, filmcraft_project::MediaRef::File { path: p } if p == path))) .map(|i| i.id) } fn open_in_source(s: &mut Session, p: &Value) -> Result { let path = paths_p(s, p).into_iter().next().ok_or_else(|| bad("mediaBrowser.openInSource", "select a file in the Media Browser"))?; let item = match item_for_path(s, &path) { Some(i) => i, None => { let r = import(s, &json!({"paths": [path], "imageSequence": false}))?; r["items"] .as_array() .and_then(|a| a.first()) .and_then(Value::as_u64) .map(ItemId) .ok_or_else(|| bad("mediaBrowser.openInSource", "the file could not be imported"))? } }; s.execute("source.open", json!({"item": item.0}))?; Ok(json!({"item": item.0})) } /// Media properties of a file (cached per path). Only the container's index is read; a format /// without a streaming reader is read whole only when small (see [`MediaPool::probe_file`]), so /// browsing a folder of large AVI or WAV files doesn't read all of them (#157). /// /// [`MediaPool::probe_file`]: crate::media_pool::MediaPool::probe_file pub fn probe(s: &mut Session, path: &str) -> Option { if let Some(v) = s.browser.probes.get(path) { return v.clone(); } let info = if kind_of(path).is_some_and(|k| matches!(k, "video" | "audio" | "image")) { s.media.probe_file(path, &*s.services).ok().map(|src| src.info().clone()) } else { None }; s.browser.probes.insert(path.to_string(), info.clone()); info } /// The text of a list column for an entry (`probe`: its media properties, when known). pub fn column_text(e: &Entry, column: &str, probe: Option<&MediaInfo>) -> String { match column { "Name" => e.name.clone(), "Media Type" => { if e.is_dir { "Folder".into() } else { format!("{}{}", e.kind[..1].to_uppercase(), &e.kind[1..]) } } "Size" => e.size.map(format_size).unwrap_or_default(), "Date Modified" => e.modified.map(format_date).unwrap_or_default(), "Frame Rate" => probe.and_then(|i| i.video.as_ref()).filter(|_| e.kind == "video").map(|v| format!("{} fps", v.frame_rate.label())).unwrap_or_default(), "Media Duration" => probe .filter(|_| e.kind != "image") .map(|i| { let rate = i.video.as_ref().map(|v| v.frame_rate).unwrap_or_default(); filmcraft_time::format_time(i.duration, rate, false, filmcraft_time::TimeDisplay::Timecode, 48000) }) .unwrap_or_default(), "Video Info" => probe.and_then(|i| i.video.as_ref()).map(|v| format!("{} x {}", v.width, v.height)).unwrap_or_default(), "Audio Info" => probe.and_then(|i| i.audio()).map(|a| format!("{} Hz - {} ch", a.sample_rate, a.channels)).unwrap_or_default(), "Video Codec" => probe.and_then(|i| i.video.as_ref()).map(|v| v.codec.clone()).unwrap_or_default(), "Audio Codec" => probe.and_then(|i| i.audio()).map(|a| a.codec.clone()).unwrap_or_default(), _ => String::new(), } } fn format_size(b: u64) -> String { match b { b if b >= 1 << 30 => format!("{:.2} GB", b as f64 / (1u64 << 30) as f64), b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), b if b >= 1 << 10 => format!("{:.0} KB", b as f64 / 1024.0), b => format!("{b} B"), } } /// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds. pub fn format_date(secs: u64) -> String { let days = (secs / 86_400) as i64; let rem = secs % 86_400; // civil-from-days (Howard Hinnant's algorithm) let z = days + 719_468; let era = z.div_euclid(146_097); let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y }; format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem / 60) % 60) } fn probe_cmd(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.probe", "need `path`"))?.to_string(); let info = probe(s, &path); Ok(json!({"path": path, "media": info.is_some(), "info": info})) } pub(crate) fn commands() -> Vec { let c = |id, label, params, run| CommandSpec { id, label, menu: &[], shortcut: None, params, enabled: always, run, journal: true }; let q = |id, label, params, run| CommandSpec { id, label, menu: &[], shortcut: None, params, enabled: always, run, journal: false }; vec![ q("mediaBrowser.roots", "Media Browser Locations", "{}", roots), q("mediaBrowser.list", "List Directory", r#"{"path":str?,"fileTypes":str?}"#, list_cmd), c("mediaBrowser.navigate", "Go to Directory", r#"{"path":str} | {"back":true} | {"forward":true} | {"up":true}"#, navigate), c("mediaBrowser.select", "Select Files", r#"{"paths":[str]}"#, select), c("mediaBrowser.favorite", "Add to Favorites", r#"{"path":str?,"remove":bool?}"#, favorite), c("mediaBrowser.clearRecent", "Clear Recent Directories", "{}", |s, _| { let mut prefs = s.prefs.clone(); prefs.media_browser.recent.clear(); save_prefs(s, prefs)?; Ok(Value::Null) }), c( "mediaBrowser.settings", "Media Browser Settings", r#"{"fileTypes":"all|video|audio|image|project|caption|"?,"view":"list|thumbnails"?,"columns":[str]?,"importAsImageSequence":bool?,"hoverScrub":bool?,"thumbnailSize":f32?}"#, settings, ), c("mediaBrowser.import", "Import", r#"{"paths":[str]?,"bin":binId?,"imageSequence":bool?}"#, import), CommandSpec { id: "mediaBrowser.openInSource", label: "Open In Source Monitor", menu: &[], shortcut: None, params: r#"{"path":str?}"#, enabled: always, run: open_in_source, journal: true, }, q("mediaBrowser.probe", "Media File Properties", r#"{"path":str}"#, probe_cmd), ] } // ------------------------------------------------------------------------------------ native fs /// Directory entries through `std::fs` (the desktop host). pub fn std_list_entries(dir: &str) -> std::io::Result> { let dir = if dir.is_empty() { "." } else { dir }; let rd = std::fs::read_dir(dir)?; Ok(rd .flatten() .map(|e| { // follow symlinks (aliases to folders show as folders) let md = std::fs::metadata(e.path()).ok(); let is_dir = md.as_ref().is_some_and(|m| m.is_dir()); DirEntry { name: e.file_name().to_string_lossy().to_string(), is_dir, size: md.as_ref().filter(|m| m.is_file()).map(|m| m.len()), modified: md.and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()), } }) .collect()) } /// Drives and network locations of the desktop host. pub fn std_volumes() -> Vec { let mut v = Vec::new(); let dir_names = |d: &str| -> Vec { std::fs::read_dir(d) .map(|rd| rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect()) .unwrap_or_default() }; if cfg!(windows) { for l in 'A'..='Z' { let p = format!("{l}:\\"); if std::path::Path::new(&p).exists() { v.push(Volume { name: format!("({l}:)"), path: p, kind: VolumeKind::Local }); } } return v; } if cfg!(target_os = "macos") { let mut names = dir_names("/Volumes"); names.sort(); for n in names { v.push(Volume { path: format!("/Volumes/{n}"), name: n, kind: VolumeKind::Local }); } if v.is_empty() { v.push(Volume { name: "/".into(), path: "/".into(), kind: VolumeKind::Local }); } if std::path::Path::new("/Network").is_dir() { v.push(Volume { name: "Network".into(), path: "/Network".into(), kind: VolumeKind::Network }); } return v; } v.push(Volume { name: "/".into(), path: "/".into(), kind: VolumeKind::Local }); for base in ["/media", "/mnt", "/run/media"] { for n in dir_names(base) { let p = format!("{base}/{n}"); // /media// let inner = dir_names(&p); if base != "/mnt" && !inner.is_empty() { for m in inner { v.push(Volume { path: format!("{p}/{m}"), name: m, kind: VolumeKind::Local }); } } else { v.push(Volume { name: n, path: p, kind: VolumeKind::Local }); } } } v } /// The desktop user's home directory. Windows prefers `USERPROFILE`: shells like Git Bash set /// `HOME` to a Unix-style path (`/c/Users/…`) that native file APIs cannot open. pub fn std_home_dir() -> Option { let vars = if cfg!(windows) { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] }; vars.iter().find_map(|v| std::env::var(v).ok().filter(|h| !h.is_empty())) }