mod browser;
mod console;
mod egl;
mod gbm;
mod history;
mod input;
mod kms;
mod library;
mod mpv;
mod shell;
mod thumbs;
mod upnp;
mod vk;
mod youtube;
use std::time::{Duration, Instant};
use drm::Device as BasicDevice;
use drm::buffer::DrmFourcc;
use drm::control::Device as ControlDevice;
use crate::gbm::{Allocator, BufferObjectFlags as Use, Scanout};
use kms::{Card, Output};
use youtube::auth::Account;
fn main() {
let args: Vec<String> = std::env::args().collect();
if matches!(args.get(1).map(String::as_str), Some("--help" | "-h" | "help")) {
print_help();
return;
}
if args.get(1).map(String::as_str) == Some("mpv") {
let url = args.get(2).cloned().unwrap_or_else(default_test_media);
if let Err(e) = run_mpv(&url) {
fail(&format!("mpv: {e}"));
}
return;
}
if args.get(1).map(String::as_str) == Some("video") {
let dev = args.get(3).map(String::as_str);
let url = args.get(2).cloned().unwrap_or_else(default_test_media);
if let Err(e) = run_video(dev, &url) {
fail(&format!("video: {e}"));
}
return;
}
if args.get(1).map(String::as_str) == Some("input") {
run_input(args.get(2).map(String::as_str));
return;
}
if args.get(1).map(String::as_str) == Some("shell") {
if let Err(e) = run_shell(args.get(2).map(String::as_str)) {
fail(&format!("shell: {e}"));
}
return;
}
if args.get(1).map(String::as_str) == Some("comp") {
let dev = args.get(3).map(String::as_str);
let url = args.get(2).cloned().unwrap_or_else(default_test_media);
if let Err(e) = run_compositor(dev, &url) {
fail(&format!("compositor: {e}"));
}
return;
}
if args.get(1).map(String::as_str) == Some("bench") {
if let Err(error) = run_bench(args.get(2).map(String::as_str)) {
fail(&format!("bench: {error}"));
}
return;
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
let base = std::path::PathBuf::from(home).join("medias");
let mut roots: Vec<(String, std::path::PathBuf)> = Vec::new();
let mut dev: Option<String> = None;
for a in &args[1..] {
if let Some(v) = a.strip_prefix("--videos") {
roots.push(parse_root(v, "Videos", &base));
} else if let Some(v) = a.strip_prefix("--music") {
roots.push(parse_root(v, "Music", &base));
} else if a.starts_with('/') {
dev = Some(a.clone());
} else {
fail(&format!("unknown argument: {a} (try --help)"));
}
}
if roots.is_empty() {
roots = vec![
("Videos".into(), base.join("Videos")),
("Music".into(), base.join("Music")),
];
}
if let Err(e) = run_app(dev.as_deref(), roots) {
fail(&format!("app: {e}"));
}
}
fn open_display(dev: Option<&str>) -> Result<(Card, Output, kms::DisplaySession), String> {
let (card, path) = match dev {
Some(p) => (Card::open(p).map_err(|e| format!("open {p}: {e}"))?, p.to_string()),
None => kms::open_primary().map_err(e2s)?,
};
println!("display: {path}");
let output = Output::discover(&card).map_err(|e| format!("discover on {path}: {e}"))?;
card.acquire_master_lock().map_err(|e| {
format!(
"acquire DRM master on {path}: {e}\n\
Use an active console VT with access to this device and no \
desktop/compositor holding it."
)
})?;
let session = kms::DisplaySession::new(&card, &output)
.map_err(|e| format!("console session: {e}"))?;
Ok((card, output, session))
}
fn parse_root(v: &str, default_label: &str, base: &std::path::Path) -> (String, std::path::PathBuf) {
let spec = v.strip_prefix('=').unwrap_or("");
if spec.is_empty() {
return (default_label.to_string(), base.join(default_label));
}
let resolve = |p: &str| {
let pb = std::path::PathBuf::from(p);
if pb.is_absolute() { pb } else { base.join(pb) }
};
match spec.split_once('=') {
Some((label, path)) => (label.to_string(), resolve(path)),
None => {
let path = resolve(spec);
let label = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| default_label.to_string());
(label, path)
}
}
}
fn run_bench(dev: Option<&str>) -> Result<(), String> {
let (card, output, mut display) = open_display(dev)?;
let alloc = Allocator::new(&card).map_err(|e| format!("gbm: {e}"))?;
let (w, h) = output.mode.size();
println!("mode {}x{}@{} ui {:?} video {:?}", w, h, output.mode.vrefresh(), output.ui_plane, output.video_plane);
println!("\n=== Vulkan ===");
let gpu = match vk::Gpu::new() {
Ok(g) => {
println!("chosen: {} queue_family={}", g.name, g.queue_family);
Some(g)
}
Err(e) => {
eprintln!("vulkan bring-up failed: {e}");
None
}
};
println!("\n=== KMS present (CPU) ===");
run_cpu(&card, &output, &alloc, w.into(), h.into()).map_err(|e| format!("present: {e}"))?;
if let Some(gpu) = gpu.filter(|_| !console::stopping()) {
println!("\n=== KMS present (Vulkan render into scanout dmabuf) ===");
if let Err(e) = run_gpu(&card, &output, &alloc, &gpu, w.into(), h.into()) {
eprintln!("gpu present: {e}");
}
}
display.restore();
Ok(())
}
fn run_mpv(url: &str) -> Result<(), String> {
println!("=== libmpv ({url}) ===");
let m = mpv::Mpv::new()?;
m.set_option("vo", "null")?; m.set_option("ao", "null")?;
m.set_option("hwdec", "no")?; m.set_option("msg-level", "all=error")?;
m.init()?;
m.loadfile(url)?;
let start = Instant::now();
let mut last = -1.0;
loop {
match m.wait_event(0.2) {
mpv::Event::FileLoaded => {
println!("file loaded: duration={:.2}s", m.get_f64("duration").unwrap_or(0.0));
}
mpv::Event::EndFile(_) | mpv::Event::PlaybackError => {
println!("end-file");
break;
}
mpv::Event::Shutdown => break,
_ => {}
}
if let Some(t) = m.get_f64("time-pos") {
if t - last >= 0.5 {
println!(" time-pos {t:.2}s");
last = t;
}
}
if start.elapsed().as_secs() > 20 {
println!("timeout");
break;
}
}
println!("done in {:.1}s", start.elapsed().as_secs_f64());
Ok(())
}
enum Node {
Dir(std::path::PathBuf),
Media(std::path::PathBuf),
UpnpRoot, UpnpDir(std::sync::Arc<upnp::Server>, String), UpnpMedia(String, upnp::Kind), YtRoot, #[cfg(feature = "youtube-tv")]
YtForYou, YtFromHistory, YtSubsLatest, YtSearch, YtTrending, YtSubs, YtChannel(String), YtPlaylists, YtPlaylist(String), YtLiked, YtSource(String), YtVideo(String), YtSignIn(Account), YtSignOut(Account), }
struct SignIn {
account: Account,
creds: youtube::auth::Credentials,
device: youtube::auth::DeviceCode,
qr: Option<(usize, Vec<bool>)>, status: String,
next_poll: Instant,
expires_at: Instant,
}
struct Item {
name: String,
node: Node,
container: bool, meta: String, sub: String, thumb: String, }
impl Item {
fn new(name: impl Into<String>, node: Node, container: bool) -> Item {
Item { name: name.into(), node, container, meta: String::new(), sub: String::new(), thumb: String::new() }
}
fn video(title: impl Into<String>, url: String) -> Item {
let thumb = youtube::video_id(&url).map(|id| youtube::thumb_url(&id)).unwrap_or_default();
Item { thumb, ..Item::new(title, Node::YtVideo(url), false) }
}
}
struct Level {
title: String,
items: Vec<Item>,
browse: browser::Browser,
thumbs: bool, }
impl Level {
fn new(title: impl Into<String>, items: Vec<Item>) -> Self {
let names: Vec<String> = items
.iter()
.map(|item| if item.sub.is_empty() { item.name.clone() } else { format!("{} {}", item.name, item.sub) })
.collect();
let browse = browser::Browser::new(names.iter().map(String::as_str));
let thumbs = items.iter().any(|item| !item.thumb.is_empty());
Self { title: title.into(), items, browse, thumbs }
}
fn query(title: impl Into<String>) -> Self {
Self { title: title.into(), items: Vec::new(), browse: browser::Browser::query_input(), thumbs: false }
}
fn layout(&self, wide: bool) -> shell::Layout {
match (self.thumbs, wide) {
(false, _) => shell::Layout::Text,
(true, false) => shell::Layout::List,
(true, true) => shell::Layout::Grid,
}
}
}
fn view_path() -> Option<std::path::PathBuf> {
Some(std::path::PathBuf::from(std::env::var("HOME").ok()?).join("medias/.view"))
}
fn load_wide_view() -> bool {
view_path().and_then(|p| std::fs::read_to_string(p).ok()).is_some_and(|s| s.trim() == "grid")
}
fn autoplay_path() -> Option<std::path::PathBuf> {
Some(std::path::PathBuf::from(std::env::var("HOME").ok()?).join("medias/.autoplay"))
}
fn load_autoplay() -> bool {
autoplay_path().and_then(|p| std::fs::read_to_string(p).ok()).is_none_or(|s| s.trim() != "off")
}
fn save_autoplay(on: bool) {
if let Some(p) = autoplay_path() {
let _ = std::fs::write(p, if on { "on\n" } else { "off\n" });
}
}
fn save_wide_view(wide: bool) {
if let Some(p) = view_path() {
let _ = std::fs::write(p, if wide { "grid\n" } else { "list\n" });
}
}
fn fmt_dur(secs: u32) -> String {
let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
}
fn yt_video_items(videos: Vec<youtube::model::Video>) -> Vec<Item> {
videos
.into_iter()
.map(|v| {
let meta = if v.duration > 0 { fmt_dur(v.duration) } else { String::new() };
Item { meta, sub: v.channel.clone(), thumb: youtube::thumb_url(&v.id), ..Item::new(v.title.clone(), Node::YtVideo(v.watch_url()), false) }
})
.collect()
}
fn yt_channel_items(channels: Vec<youtube::model::Channel>) -> Vec<Item> {
channels
.into_iter()
.map(|c| Item { thumb: c.thumbs.list().into(), ..Item::new(c.title, Node::YtChannel(c.id), true) })
.collect()
}
fn yt_playlist_items(playlists: Vec<youtube::model::Playlist>) -> Vec<Item> {
playlists
.into_iter()
.map(|p| {
let meta = if p.count > 0 { p.count.to_string() } else { String::new() };
Item { meta, thumb: p.thumbs.list().into(), ..Item::new(p.title, Node::YtPlaylist(p.id), true) }
})
.collect()
}
fn yt_home_items(yt: &youtube::Service, has_history: bool) -> Vec<Item> {
let mut items = Vec::new();
#[cfg(feature = "youtube-tv")]
items.push(Item::new("For you", Node::YtForYou, true));
if has_history {
items.push(Item::new("From your history", Node::YtFromHistory, true));
}
if yt.signed_in() {
items.push(Item::new("Latest from subscriptions", Node::YtSubsLatest, true));
}
items.push(Item::new("Search", Node::YtSearch, true));
if yt.data_api_available() {
items.push(Item::new("Trending", Node::YtTrending, true));
}
if yt.signed_in() {
items.push(Item::new("Subscriptions", Node::YtSubs, true));
items.push(Item::new("Playlists", Node::YtPlaylists, true));
items.push(Item::new("Liked videos", Node::YtLiked, true));
}
for source in youtube::sources() {
items.push(Item::new(source.label, Node::YtSource(source.query), true));
}
if yt.signed_in() {
let label = match yt.account() {
Some(name) => format!("Sign out ({name})"),
None => "Sign out".into(),
};
items.push(Item::new(label, Node::YtSignOut(Account::DataApi), false));
} else {
items.push(Item::new("Sign in", Node::YtSignIn(Account::DataApi), false));
}
#[cfg(feature = "youtube-tv")]
if yt.tv_signed_in() {
items.push(Item::new("Sign out of YouTube TV", Node::YtSignOut(Account::Tv), false));
}
items
}
fn yt_home(yt: &youtube::Service, history: &history::History) -> Level {
Level::new("YouTube", yt_home_items(yt, !yt_history_ids(history).is_empty()))
}
enum Loaded {
Level(Level),
Play { key: String, name: String, target: String, audio: Vec<String> }, Failed(String),
}
struct Pending {
label: String,
rx: std::sync::mpsc::Receiver<Loaded>,
started: Instant,
}
fn spawn_load(label: impl Into<String>, job: impl FnOnce() -> Loaded + Send + 'static) -> Pending {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(job());
});
Pending { label: label.into(), rx, started: Instant::now() }
}
fn level_or_failed(title: String, items: Result<Vec<Item>, String>) -> Loaded {
match items {
Ok(items) if items.is_empty() => Loaded::Failed(format!("{title}: nothing found")),
Ok(items) => Loaded::Level(Level::new(title, items)),
Err(error) => Loaded::Failed(format!("{title}: {error}")),
}
}
#[cfg(feature = "youtube-tv")]
fn yt_for_you(yt: &mut youtube::Service) -> Pending {
let token = yt.tv_access_token();
spawn_load("For you", move || {
let Some(token) = token else { return Loaded::Failed("For you: YouTube TV sign-in unavailable".into()) };
let videos = youtube::innertube::recommendations(&token);
if matches!(&videos, Ok(v) if v.is_empty()) {
eprintln!("youtube: recommendations empty (MEDIAS_YT_DEBUG=1 dumps the response)");
}
level_or_failed("For you".into(), videos.map(yt_video_items))
})
}
fn yt_play(url: String, name: String, prefs: youtube::innertube::StreamPrefs) -> Pending {
spawn_load(name.clone(), move || {
let direct = youtube::video_id(&url)
.ok_or_else(|| "no video id in URL".to_string())
.and_then(|id| youtube::innertube::streams(&id, &prefs));
let (target, audio) = match direct {
Ok(streams) => {
let audio = streams.audio.iter().map(|a| mpv::edl_track(&a.url, &a.lang, &a.title)).collect();
(streams.video, audio)
}
Err(err) => {
eprintln!("youtube: direct stream failed ({err}); trying yt-dlp");
(url.clone(), Vec::new())
}
};
Loaded::Play { key: url, name, target, audio }
})
}
fn start_sign_in(yt: &youtube::Service, account: Account) -> Option<SignIn> {
let creds = yt.sign_in_credentials(account)?;
match youtube::auth::request_device_code(&creds, account.scope()) {
Ok(device) => {
let now = Instant::now();
Some(SignIn {
account,
creds,
qr: youtube::auth::qr_matrix(&device.scan_url()),
status: "Waiting for approval…".into(),
next_poll: now + Duration::from_secs(device.interval.max(1)),
expires_at: now + Duration::from_secs(device.expires_in.max(60)),
device,
})
}
Err(error) => {
eprintln!("youtube sign-in: {error}");
None
}
}
}
fn yt_history_ids(history: &history::History) -> Vec<String> {
history.recent().into_iter().filter_map(youtube::video_id).collect()
}
fn yt_subs_latest(client: &youtube::api::Client) -> Result<Vec<Item>, String> {
let lists = client
.subscriptions()?
.iter()
.take(12)
.filter_map(|c| client.playlist_videos(&youtube::api::uploads_playlist_id(&c.id)).ok())
.map(|mut videos| {
videos.truncate(4);
videos
})
.collect();
let videos = youtube::interleave(lists, &Default::default());
if videos.is_empty() {
return Err("no subscription uploads found".into());
}
Ok(yt_video_items(videos))
}
fn yt_fetch(
yt: &mut youtube::Service,
title: &str,
build: impl FnOnce(&youtube::api::Client) -> Result<Vec<Item>, String> + Send + 'static,
) -> Pending {
let (title, client) = (title.to_string(), yt.client());
spawn_load(title.clone(), move || level_or_failed(title, client.and_then(|client| build(&client))))
}
fn ytdl_items(query: &str) -> Result<Vec<Item>, String> {
let videos = youtube::resolve(query).map_err(|e| e.to_string())?;
Ok(videos.into_iter().map(|v| Item::video(v.title, v.url)).collect())
}
fn yt_search(yt: &mut youtube::Service, query: String) -> Pending {
let client = yt.client().ok().filter(|client| client.usable());
spawn_load(format!("results for “{query}”"), move || {
let items = match client {
Some(client) => client.search(&query).map(yt_video_items),
None => ytdl_items(&format!("ytsearch25:{query}")),
};
level_or_failed(query, items)
})
}
fn card_sub(it: &Item, played: Option<String>) -> String {
let folder = matches!(it.node, Node::Dir(_) | Node::UpnpDir(..)) && it.sub.is_empty();
let base = if folder { "Folder" } else { it.sub.as_str() };
match played {
Some(date) if base.is_empty() => format!("Watched {date}"),
Some(date) => format!("{base} · watched {date}"),
None => base.to_string(),
}
}
fn card_icon(it: &Item) -> shell::Icon {
match &it.node {
Node::Media(p) if library::classify(p) == Some(library::Kind::Audio) => shell::Icon::Audio,
Node::UpnpMedia(_, upnp::Kind::Audio) => shell::Icon::Audio,
Node::Media(_) | Node::YtVideo(_) | Node::UpnpMedia(..) => shell::Icon::Video,
Node::Dir(_) | Node::UpnpDir(..) | Node::UpnpRoot => shell::Icon::Folder,
_ => shell::Icon::None,
}
}
fn lib_items(entries: Vec<library::Entry>) -> Vec<Item> {
entries
.into_iter()
.map(|e| {
let container = e.kind == library::Kind::Dir;
let thumb = std::path::absolute(&e.path).unwrap_or_else(|_| e.path.clone()).to_string_lossy().into_owned();
let node = if container { Node::Dir(e.path) } else { Node::Media(e.path) };
Item { thumb, ..Item::new(e.name, node, container) }
})
.collect()
}
fn info_step(bar: bool, hidden: bool, blank: bool) -> (bool, bool, bool) {
match (blank, bar, hidden) {
(true, ..) | (false, false, false) => (true, false, false),
(false, true, _) => (false, true, false),
(false, false, true) => (false, false, true),
}
}
struct Queue {
depth: usize,
items: Vec<(usize, String, String)>, pos: usize,
}
impl Queue {
fn new(level: &Level, depth: usize, current: usize) -> Option<Queue> {
let items: Vec<(usize, String, String)> = (0..level.browse.count())
.filter_map(|filtered| {
let src = level.browse.source_index(filtered)?;
let item = &level.items[src];
let target = match &item.node {
Node::Media(path) => path.to_string_lossy().into_owned(),
Node::UpnpMedia(url, _) => url.clone(),
_ => return None,
};
Some((src, item.name.clone(), target))
})
.collect();
let pos = items.iter().position(|(src, ..)| *src == current)?;
Some(Queue { depth, items, pos })
}
fn step(&mut self, step: isize) -> Option<Loaded> {
let pos = self.pos.checked_add_signed(step).filter(|&p| p < self.items.len())?;
self.pos = pos;
Some(self.play())
}
fn play(&self) -> Loaded {
let (_, name, target) = &self.items[self.pos];
Loaded::Play { key: target.clone(), name: name.clone(), target: target.clone(), audio: Vec::new() }
}
fn sync_selection(&self, stack: &mut [Level]) {
let (src, name, _) = &self.items[self.pos];
if let Some(level) = stack.get_mut(self.depth)
&& level.items.get(*src).is_some_and(|item| item.name == *name)
{
level.browse.select_source(*src);
}
}
}
fn upnp_server_items(servers: Vec<upnp::Server>) -> Vec<Item> {
servers
.into_iter()
.map(|server| {
let thumb = server.icon.clone();
let name = server.name.clone();
let node = Node::UpnpDir(std::sync::Arc::new(server), "0".into());
Item { thumb, sub: "Media server".into(), ..Item::new(name, node, true) }
})
.collect()
}
fn upnp_items(server: &std::sync::Arc<upnp::Server>, entries: Vec<upnp::Entry>) -> Vec<Item> {
entries
.into_iter()
.map(|entry| match entry {
upnp::Entry::Container { id, title, .. } => Item::new(title, Node::UpnpDir(server.clone(), id), true),
upnp::Entry::Item { title, url, kind, art, duration, artist, album } => {
let sub = [artist, album].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" · ");
let meta = if duration > 0 { fmt_dur(duration) } else { String::new() };
Item { thumb: art, sub, meta, ..Item::new(title, Node::UpnpMedia(url, kind), false) }
}
})
.collect()
}
#[derive(Clone)]
enum Pb {
Resume,
Library,
Stop,
SeekMenu,
AudioMenu,
SubMenu,
SeekAbs(f64),
SeekRel(f64),
SetAid(String),
SetSid(String),
Autoplay,
}
struct PbItem {
name: String,
node: Pb,
}
struct PbLevel {
title: String,
items: Vec<PbItem>,
selected: usize,
horizontal: bool, }
fn pb(name: &str, node: Pb) -> PbItem {
PbItem { name: name.into(), node }
}
fn pb_root(autoplay: bool) -> PbLevel {
PbLevel {
title: "Playback".into(),
selected: 0,
horizontal: false,
items: vec![
pb("Resume", Pb::Resume),
pb("Seek", Pb::SeekMenu),
pb("Audio", Pb::AudioMenu),
pb("Subtitles", Pb::SubMenu),
pb(if autoplay { "Play next in folder: on" } else { "Play next in folder: off" }, Pb::Autoplay),
pb("Browse library", Pb::Library),
pb("Stop", Pb::Stop),
],
}
}
fn pb_seek() -> PbLevel {
PbLevel {
title: "Seek".into(),
selected: 0,
horizontal: true,
items: vec![
pb("Beginning", Pb::SeekAbs(0.0)),
pb("-10m", Pb::SeekRel(-600.0)),
pb("-1m", Pb::SeekRel(-60.0)),
pb("+1m", Pb::SeekRel(60.0)),
pb("+10m", Pb::SeekRel(600.0)),
],
}
}
fn track_items(m: &mpv::Mpv, kind: &str, off: Option<&str>) -> PbLevel {
let n = m.get_f64("track-list/count").unwrap_or(0.0) as i64;
let mut items = Vec::new();
if let Some(label) = off {
items.push(pb(label, Pb::SetSid("no".into())));
}
for i in 0..n {
if m.get_string(&format!("track-list/{i}/type")).as_deref() != Some(kind) {
continue;
}
let id = m.get_string(&format!("track-list/{i}/id")).unwrap_or_default();
let lang = m.get_string(&format!("track-list/{i}/lang")).unwrap_or_default();
let title = m.get_string(&format!("track-list/{i}/title")).unwrap_or_default();
let selected = m.get_string(&format!("track-list/{i}/selected")).as_deref() == Some("yes");
let mut name = format!("{id}");
if !lang.is_empty() {
name.push_str(&format!(" {lang}"));
}
if !title.is_empty() {
name.push_str(&format!(" {title}"));
}
if selected {
name.push_str(" *");
}
let node = if kind == "audio" { Pb::SetAid(id) } else { Pb::SetSid(id) };
items.push(pb(&name, node));
}
let title = if kind == "audio" { "Audio" } else { "Subtitles" };
PbLevel { title: title.into(), items, selected: 0, horizontal: false }
}
fn run_app(dev: Option<&str>, roots: Vec<(String, std::path::PathBuf)>) -> Result<(), String> {
use std::sync::mpsc;
#[derive(PartialEq)]
enum Mode {
Browse, Play, Menu, SignIn, }
use youtube::auth::Poll;
println!("=== medias ===");
for (label, path) in &roots {
println!("root: {label} -> {}", path.display());
}
let (card, output, mut display) = open_display(dev)?;
if output.video_plane.is_none() {
return Err("need two planes for the app".into());
}
let alloc = Allocator::new(&card).map_err(|e| format!("gbm: {e}"))?;
let (w, h) = (u32::from(output.mode.size().0), u32::from(output.mode.size().1));
println!("mode: {w}x{h}@{}", output.mode.vrefresh());
let vscale = (h / 1080).max(1);
let (mut vw, mut vh) = (w / vscale, h / vscale);
let gpu = vk::Gpu::new()?;
let video = egl::VideoGl::new(&alloc)?;
let text = gpu.create_text()?;
let mut vbufs = Vec::new(); let mut _vfbos = Vec::new(); let mut vfbo_ids = Vec::new();
let mut ubufs = Vec::new(); let mut targets = Vec::new(); let mut ufbs = Vec::new(); let alloc_video = |vw: u32, vh: u32, vbufs: &mut Vec<_>, vfbos: &mut Vec<_>, ids: &mut Vec<_>| {
for _ in 0..2 {
let vb = alloc.alloc(&card, vw, vh, DrmFourcc::Xrgb8888, Use::SCANOUT | Use::RENDERING).map_err(|e| format!("video alloc: {e}"))?;
let gf = video.import(&vb)?;
ids.push(video.fbo_id(&gf));
vfbos.push(gf);
vbufs.push(vb);
}
Ok::<(), String>(())
};
alloc_video(vw, vh, &mut vbufs, &mut _vfbos, &mut vfbo_ids)?;
for _ in 0..2 {
let (ub, t) = gpu.create_scanout_target(&alloc, &card, &output, w, h, DrmFourcc::Argb8888)?;
ufbs.push(text.framebuffer(&gpu, &t)?);
targets.push(t);
ubufs.push(ub);
}
if vscale > 1 {
match output.test_two(&card, vbufs[0].fb, (vw as u16, vh as u16), ubufs[0].fb) {
Ok(()) => println!("video render: {vw}x{vh} -> hw-scaled to {w}x{h}"),
Err(e) => {
println!("video render: {w}x{h} (plane scaling from {vw}x{vh} rejected: {e})");
for f in _vfbos.drain(..) {
video.release(f);
}
for b in vbufs.drain(..) {
let _ = card.destroy_framebuffer(b.fb);
}
vfbo_ids.clear();
(vw, vh) = (w, h);
alloc_video(vw, vh, &mut vbufs, &mut _vfbos, &mut vfbo_ids)?;
}
}
}
let m = mpv::Mpv::new()?;
m.set_option("vo", "libmpv")?;
let ao = std::env::var("MEDIAS_AO").unwrap_or_else(|_| "alsa,null".into());
m.set_option("ao", &ao)?;
if let Ok(dev) = std::env::var("MEDIAS_AUDIO_DEVICE") {
m.set_option("audio-device", &dev)?;
}
let hwdec = std::env::var("MEDIAS_HWDEC").unwrap_or_else(|_| "auto".into());
m.set_option("hwdec", &hwdec)?;
m.set_option("idle", "yes")?;
let ytdl_path = youtube::ytdl_path();
m.set_option("ytdl", "yes")?;
m.set_option("script-opts", &format!("ytdl_hook-ytdl_path={ytdl_path}"))?;
let ytdl_format =
std::env::var("MEDIAS_YTDL_FORMAT").unwrap_or_else(|_| "bestvideo[height<=?1080]+bestaudio/best".into());
m.set_option("ytdl-format", &ytdl_format)?;
if let Ok(path) = std::env::var("MEDIAS_MPV_LOG")
&& !path.is_empty()
{
m.set_option("log-file", &path)?;
m.set_option("msg-level", "all=v")?;
} else {
m.set_option("msg-level", "all=error")?;
}
m.init()?;
let rc = m.render_context(egl::mpv_get_proc_address)?;
let mut root_items: Vec<Item> = roots
.into_iter()
.map(|(label, path)| Item::new(label, Node::Dir(path), true))
.collect();
root_items.push(Item::new("Media servers", Node::UpnpRoot, true));
root_items.push(Item::new("YouTube", Node::YtRoot, true));
let mut stack = vec![Level::new("medias", root_items)];
let mut pbstack: Vec<PbLevel> = Vec::new(); let mut mode = Mode::Browse;
let mut now_title = String::new();
let mut current_path = String::new(); let mut resume_to: Option<f64> = None; let mut resume_seek_started = false;
let mut history = history::History::load();
let mut yt = youtube::Service::load();
let stream_prefs = youtube::innertube::StreamPrefs::from_env();
let mut signin: Option<SignIn> = None;
let mut thumb_cache = thumbs::Thumbs::new();
let mut pending: Option<Pending> = None; let mut ready: Option<Loaded> = None; let mut notice: Option<(String, Instant)> = None; let mut play_started = Instant::now(); let mut spin_phase = usize::MAX; let spin_epoch = Instant::now();
const SPIN_DELAY: Duration = Duration::from_millis(150); const NOTICE_SECS: Duration = Duration::from_secs(5);
let mut wide = load_wide_view(); let mut autoplay = load_autoplay(); let mut queue: Option<Queue> = None;
let (tx, rx) = mpsc::channel::<input::Key>();
let sources = input::spawn(tx, display.owns_vt());
if !sources.available() {
return Err("no keyboard or CEC input available; check access to /dev/input/event* and /dev/cec0".into());
}
let mut frame = vk::Frame::default();
let mut vfront = 0usize; let mut video_live = false;
let mut video_redraw = false; let mut modeset = false;
let mut ui_front = 0usize; let mut ui_dirty = true; let mut last_sec = u64::MAX;
let mut playing = false; let mut loaded = false; let mut osd_until = Instant::now(); let mut info_hidden = false; let mut blank = false; let mut vol_until = Instant::now(); let mut toast_until = Instant::now(); let mut toast = String::new();
let mut last_vis = (false, false, false); let osd_secs = Duration::from_secs(10);
let stats = std::env::var("MEDIAS_STATS").is_ok();
let (mut presents, mut vrenders) = (0u32, 0u32);
let mut last_stats = Instant::now();
let playback_result = (|| -> Result<(), String> {
'app: loop {
if console::stopping() {
break;
}
loop {
match m.wait_event(0.0) {
mpv::Event::None => break,
mpv::Event::FileLoaded => {
loaded = true;
resume_seek_started = false;
if let Some(t) = resume_to {
if let Err(error) = m.command(&["seek", &t.to_string(), "absolute+exact"]) {
eprintln!("resume seek: {error}");
resume_to = None;
}
}
}
mpv::Event::Seek if loaded && resume_to.is_some() => resume_seek_started = true,
mpv::Event::PlaybackRestart => {
if loaded && resume_seek_started {
resume_to = None;
resume_seek_started = false;
}
if !video_live && resume_to.is_none() {
video_live = true;
video_redraw = true;
}
}
mpv::Event::EndFile(true) if loaded => {
loaded = false;
history.set_pos(¤t_path, 0.0); history.save();
playing = false;
match queue.as_mut().filter(|_| autoplay).and_then(|q| q.step(1)) {
Some(next) => {
queue.as_ref().unwrap().sync_selection(&mut stack);
ready = Some(next);
}
None => {
mode = Mode::Browse;
blank = false;
}
}
ui_dirty = true;
}
mpv::Event::PlaybackError => {
eprintln!("play: could not open {current_path} (MEDIAS_MPV_LOG=<path> logs mpv's reason)");
notice = Some((format!("Could not play {now_title}"), Instant::now() + NOTICE_SECS));
loaded = false;
playing = false;
resume_to = None;
mode = Mode::Browse;
blank = false;
ui_dirty = true;
history.save();
}
mpv::Event::Shutdown => break 'app,
_ => {}
}
}
let mut acted = false;
for key in rx.try_iter() {
let key = if mode != Mode::Browse && key == input::Key::Char(' ') {
input::Key::Play
} else { key };
acted = true;
match mode {
Mode::Browse if pending.is_some() => {
if key == input::Key::Back {
pending = None;
}
}
Mode::Browse => {
notice = None;
let level = stack.last_mut().unwrap();
if key == input::Key::Info && level.thumbs && !level.browse.searching() {
wide = !wide;
save_wide_view(wide);
continue;
}
let keyboard = level.browse.keyboard().is_some();
let (cols, capacity) = shell::browse_shape(w as f32, h as f32, keyboard, level.layout(wide));
level.browse.set_columns(cols);
match level.browse.handle(key, capacity) {
browser::Action::Open(index) => {
let l = stack.last().unwrap();
let item = &l.items[index];
let (name, label) = (item.name.clone(), item.name.clone());
match &item.node {
Node::Dir(p) => {
let p = p.clone();
pending = Some(spawn_load(name.clone(), move || match library::list(&p) {
Ok(e) => Loaded::Level(Level::new(name, lib_items(e))),
Err(err) => Loaded::Failed(format!("open {}: {err}", p.display())),
}));
}
Node::UpnpRoot => {
pending = Some(spawn_load("Media servers", || {
match upnp::discover(Duration::from_millis(1500)) {
servers if servers.is_empty() => {
Loaded::Failed("No UPnP/DLNA media servers found".into())
}
servers => Loaded::Level(Level::new("Media servers", upnp_server_items(servers))),
}
}));
}
Node::UpnpDir(server, id) => {
let (server, id) = (server.clone(), id.clone());
pending = Some(spawn_load(name.clone(), move || {
let items = upnp::browse(&server, &id).map(|e| upnp_items(&server, e));
level_or_failed(name, items)
}));
}
Node::YtRoot => {
if yt.signed_in() && yt.account().is_none() {
yt.refresh_account();
}
stack.push(yt_home(&yt, &history));
}
#[cfg(feature = "youtube-tv")]
Node::YtForYou if !yt.tv_signed_in() => {
signin = start_sign_in(&yt, Account::Tv);
if signin.is_some() {
mode = Mode::SignIn;
}
}
#[cfg(feature = "youtube-tv")]
Node::YtForYou => pending = Some(yt_for_you(&mut yt)),
Node::YtFromHistory => {
let ids = yt_history_ids(&history);
pending = Some(spawn_load("From your history", move || {
let watched = ids.iter().cloned().collect();
let videos = youtube::innertube::from_history(&ids[..ids.len().min(10)], &watched);
level_or_failed("From your history".into(), Ok(yt_video_items(videos)))
}));
}
Node::YtSubsLatest => {
pending = Some(yt_fetch(&mut yt, "Latest from subscriptions", yt_subs_latest));
}
Node::YtSearch => stack.push(Level::query("Search")),
Node::YtTrending => {
pending = Some(yt_fetch(&mut yt, "Trending", |c| Ok(yt_video_items(c.trending()?))));
}
Node::YtSubs => {
pending = Some(yt_fetch(&mut yt, "Subscriptions", |c| Ok(yt_channel_items(c.subscriptions()?))));
}
Node::YtChannel(id) => {
let id = id.clone();
pending = Some(yt_fetch(&mut yt, &label, move |c| Ok(yt_video_items(c.channel_uploads(&id)?))));
}
Node::YtPlaylists => {
pending = Some(yt_fetch(&mut yt, "Playlists", |c| Ok(yt_playlist_items(c.playlists()?))));
}
Node::YtPlaylist(id) => {
let id = id.clone();
pending = Some(yt_fetch(&mut yt, &label, move |c| Ok(yt_video_items(c.playlist_videos(&id)?))));
}
Node::YtLiked => {
pending = Some(yt_fetch(&mut yt, "Liked videos", |c| {
Ok(yt_video_items(c.playlist_videos(youtube::api::LIKED_PLAYLIST)?))
}));
}
Node::YtSource(q) => {
let q = q.clone();
pending = Some(spawn_load(label.clone(), move || level_or_failed(label, ytdl_items(&q))));
}
Node::YtSignIn(account) => {
let account = *account;
signin = start_sign_in(&yt, account);
if signin.is_some() || yt.sign_in_credentials(account).is_none() {
mode = Mode::SignIn;
}
}
Node::YtSignOut(account) => {
let account = *account;
yt.sign_out(account);
*stack.last_mut().unwrap() = yt_home(&yt, &history);
}
Node::Media(_) | Node::UpnpMedia(..) => {
queue = Queue::new(l, stack.len() - 1, index);
ready = queue.as_ref().map(Queue::play);
}
Node::YtVideo(url) => {
queue = None;
pending = Some(yt_play(url.clone(), name, stream_prefs.clone()));
}
}
}
browser::Action::Submit(query) => pending = Some(yt_search(&mut yt, query)),
browser::Action::Back => {
if stack.len() > 1 {
stack.pop();
} else if playing {
mode = Mode::Play;
let _ = m.set_property("pause", "no");
osd_until = Instant::now() + osd_secs;
} else {
break 'app;
}
}
browser::Action::Resume if playing => {
mode = Mode::Play;
let _ = m.set_property("pause", "no");
osd_until = Instant::now() + osd_secs;
}
_ => {}
}
}
Mode::Play => {
if key == input::Key::Info {
let show;
(show, info_hidden, blank) = info_step(Instant::now() < osd_until, info_hidden, blank);
osd_until = if show { Instant::now() + osd_secs } else { Instant::now() };
ui_dirty = true;
continue;
}
if blank && !matches!(key, input::Key::Up | input::Key::Down) {
blank = false;
ui_dirty = true;
}
if !blank {
osd_until = Instant::now() + osd_secs; info_hidden = false;
}
match key {
input::Key::Back => {
mode = Mode::Browse;
let _ = m.set_property("pause", "yes");
checkpoint_playback(&m, &mut history, ¤t_path, loaded && resume_to.is_none());
}
input::Key::Select | input::Key::Pause => {
mode = Mode::Menu;
pbstack = vec![pb_root(autoplay)];
let _ = m.set_property("pause", "yes");
checkpoint_playback(&m, &mut history, ¤t_path, loaded && resume_to.is_none());
}
input::Key::Play => {
let _ = m.command(&["cycle", "pause"]);
}
input::Key::Stop => {
checkpoint_playback(&m, &mut history, ¤t_path, loaded && resume_to.is_none());
loaded = false;
let _ = m.command(&["stop"]);
playing = false;
mode = Mode::Browse;
}
input::Key::Audio => {
let _ = m.command(&["cycle", "audio"]);
let title = m.get_string("current-tracks/audio/title")
.or_else(|| m.get_string("current-tracks/audio/lang"))
.or_else(|| m.get_string("aid"))
.unwrap_or_else(|| "?".into());
toast = format!("Audio track: {title}");
toast_until = Instant::now() + Duration::from_secs(2);
}
input::Key::Subtitle => {
let _ = m.command(&["cycle", "sub"]);
toast = format!("Subtitles: {}", m.get_string("sid").unwrap_or_else(|| "off".into()));
toast_until = Instant::now() + Duration::from_secs(2);
}
input::Key::Left => {
let _ = m.command(&["seek", "-10"]);
}
input::Key::Right => {
let _ = m.command(&["seek", "10"]);
}
input::Key::Prev => {
let _ = m.command(&["seek", "-60"]); }
input::Key::Next => {
let _ = m.command(&["seek", "60"]); }
input::Key::SkipNext | input::Key::Char('>') => {
if let Some(next) = queue.as_mut().and_then(|q| q.step(1)) {
queue.as_ref().unwrap().sync_selection(&mut stack);
ready = Some(next);
}
}
input::Key::SkipPrev | input::Key::Char('<') => {
if m.get_f64("time-pos").unwrap_or(0.0) > 5.0 {
let _ = m.command(&["seek", "0", "absolute"]);
} else if let Some(prev) = queue.as_mut().and_then(|q| q.step(-1)) {
queue.as_ref().unwrap().sync_selection(&mut stack);
ready = Some(prev);
}
}
input::Key::Up => {
let _ = m.command(&["add", "volume", "5"]);
vol_until = Instant::now() + Duration::from_secs(2);
}
input::Key::Down => {
let _ = m.command(&["add", "volume", "-5"]);
vol_until = Instant::now() + Duration::from_secs(2);
}
input::Key::Menu => {
mode = Mode::Menu;
pbstack = vec![pb_root(autoplay)];
let _ = m.set_property("pause", "yes");
}
_ => {}
}
}
Mode::Menu => {
let (n, horizontal) = {
let l = pbstack.last().unwrap();
(l.items.len(), l.horizontal)
};
let (prev, next) = if horizontal {
(input::Key::Left, input::Key::Right)
} else {
(input::Key::Up, input::Key::Down)
};
match key {
k if k == prev && n > 0 => {
let l = pbstack.last_mut().unwrap();
l.selected = (l.selected + n - 1) % n;
}
k if k == next && n > 0 => {
let l = pbstack.last_mut().unwrap();
l.selected = (l.selected + 1) % n;
}
input::Key::PageUp | input::Key::Prev if !horizontal => {
let l = pbstack.last_mut().unwrap();
l.selected = l.selected.saturating_sub(shell::playback_capacity(w as f32, h as f32));
}
input::Key::PageDown | input::Key::Next if !horizontal => {
let l = pbstack.last_mut().unwrap();
l.selected = l.selected.saturating_add(shell::playback_capacity(w as f32, h as f32))
.min(n.saturating_sub(1));
}
input::Key::Home => pbstack.last_mut().unwrap().selected = 0,
input::Key::End => pbstack.last_mut().unwrap().selected = n.saturating_sub(1),
input::Key::Back => {
if pbstack.len() > 1 {
pbstack.pop();
} else {
mode = Mode::Play;
let _ = m.set_property("pause", "no");
osd_until = Instant::now() + osd_secs;
}
}
input::Key::Play | input::Key::Pause | input::Key::Menu => {
mode = Mode::Play;
let _ = m.set_property("pause", "no");
osd_until = Instant::now() + osd_secs;
}
input::Key::Stop => {
checkpoint_playback(&m, &mut history, ¤t_path, loaded && resume_to.is_none());
loaded = false;
let _ = m.command(&["stop"]);
playing = false;
mode = Mode::Browse;
}
input::Key::Select if n > 0 => {
let node = pbstack.last().unwrap().items[pbstack.last().unwrap().selected].node.clone();
match node {
Pb::Resume => {
let _ = m.set_property("pause", "no");
mode = Mode::Play;
osd_until = Instant::now() + osd_secs;
}
Pb::Library => {
checkpoint_playback(&m, &mut history, ¤t_path, loaded && resume_to.is_none());
mode = Mode::Browse; }
Pb::Stop => {
checkpoint_playback(&m, &mut history, ¤t_path, loaded && resume_to.is_none());
loaded = false;
let _ = m.command(&["stop"]);
playing = false;
mode = Mode::Browse;
}
Pb::Autoplay => {
autoplay = !autoplay;
save_autoplay(autoplay);
let sel = pbstack[0].selected;
pbstack[0] = pb_root(autoplay);
pbstack[0].selected = sel;
}
Pb::SeekMenu => pbstack.push(pb_seek()),
Pb::AudioMenu => pbstack.push(track_items(&m, "audio", None)),
Pb::SubMenu => pbstack.push(track_items(&m, "sub", Some("Off"))),
Pb::SeekAbs(t) => {
let _ = m.command(&["seek", &t.to_string(), "absolute"]);
let _ = m.set_property("pause", "no");
mode = Mode::Play;
osd_until = Instant::now() + osd_secs;
}
Pb::SeekRel(d) => {
let _ = m.command(&["seek", &d.to_string()]);
}
Pb::SetAid(id) => {
let _ = m.set_property("aid", &id);
let sel = pbstack.last().map(|l| l.selected).unwrap_or(0);
if let Some(l) = pbstack.last_mut() {
*l = track_items(&m, "audio", None);
l.selected = sel.min(l.items.len().saturating_sub(1));
}
}
Pb::SetSid(id) => {
let _ = m.set_property("sid", &id);
let sel = pbstack.last().map(|l| l.selected).unwrap_or(0);
if let Some(l) = pbstack.last_mut() {
*l = track_items(&m, "sub", Some("Off"));
l.selected = sel.min(l.items.len().saturating_sub(1));
}
}
}
}
_ => {}
}
}
Mode::SignIn => {
if key == input::Key::Back {
signin = None;
mode = Mode::Browse;
ui_dirty = true;
}
}
}
}
ui_dirty |= acted;
if ready.is_none() && let Some(polled) = pending.as_ref().map(|p| (p.rx.try_recv(), p.label.clone())) {
match polled {
(Ok(result), _) => ready = Some(result),
(Err(std::sync::mpsc::TryRecvError::Disconnected), label) => {
ready = Some(Loaded::Failed(format!("{label}: load failed")));
}
(Err(std::sync::mpsc::TryRecvError::Empty), _) => {}
}
if ready.is_some() {
pending = None;
}
}
match ready.take() {
Some(Loaded::Level(level)) => {
stack.push(level);
ui_dirty = true;
}
Some(Loaded::Failed(message)) => {
eprintln!("{message}");
notice = Some((message, Instant::now() + NOTICE_SECS));
ui_dirty = true;
}
Some(Loaded::Play { key, name, target, audio }) => {
if playing && loaded && resume_to.is_none() {
if let Some(t) = m.get_f64("time-pos") {
history.set_pos(¤t_path, t);
}
history.save();
}
let audio: Vec<&str> = audio.iter().map(String::as_str).collect();
match m.set_audio_files(&audio).and_then(|()| m.loadfile(&target)) {
Ok(()) => {
resume_to = history.pos(&key);
now_title = name;
current_path = key.clone();
playing = true;
loaded = false;
resume_seek_started = false;
mode = Mode::Play;
vfront ^= 1;
video.clear(vfbo_ids[vfront]);
video_live = false;
play_started = Instant::now();
let _ = m.set_property("pause", "no");
history.touch(&key);
history.save();
osd_until = Instant::now() + osd_secs;
}
Err(err) => {
eprintln!("play: {err}");
notice = Some((format!("Could not play {name}: {err}"), Instant::now() + NOTICE_SECS));
}
}
ui_dirty = true;
}
None => {}
}
if notice.as_ref().is_some_and(|(_, until)| Instant::now() >= *until) {
notice = None;
ui_dirty = true;
}
let spinning = pending.as_ref().is_some_and(|p| p.started.elapsed() >= SPIN_DELAY)
|| (mode == Mode::Play && playing && !video_live && play_started.elapsed() >= SPIN_DELAY);
let phase = if spinning { (spin_epoch.elapsed().as_millis() / 83) as usize % 12 } else { usize::MAX };
if phase != spin_phase {
spin_phase = phase;
ui_dirty = true;
}
if thumb_cache.pump(&text, &gpu) && mode == Mode::Browse {
ui_dirty = true;
}
if mode == Mode::SignIn && let Some(si) = signin.as_mut() {
let now = Instant::now();
if now >= si.expires_at {
si.status = "Code expired — press Back".into();
ui_dirty = true;
} else if now >= si.next_poll {
match youtube::auth::poll_once(&si.creds, &si.device.device_code) {
Poll::Pending => si.next_poll = now + Duration::from_secs(si.device.interval),
Poll::SlowDown => {
si.device.interval += 5;
si.next_poll = now + Duration::from_secs(si.device.interval);
}
Poll::Granted(tokens) => {
let account = si.account;
yt.complete_sign_in(account, tokens);
signin = None;
*stack.last_mut().unwrap() = yt_home(&yt, &history);
#[cfg(feature = "youtube-tv")]
if account == Account::Tv {
pending = Some(yt_for_you(&mut yt));
}
mode = Mode::Browse;
ui_dirty = true;
}
Poll::Denied => {
si.status = "Access denied — press Back".into();
ui_dirty = true;
}
Poll::Expired => {
si.status = "Code expired — press Back".into();
ui_dirty = true;
}
Poll::Error(err) => {
si.status = format!("Error: {err} — press Back");
ui_dirty = true;
}
}
}
}
if matches!(mode, Mode::Play | Mode::Menu) && (std::mem::take(&mut video_redraw) || rc.frame_ready()) {
let vback = vfront ^ 1;
rc.render(vfbo_ids[vback], vw as i32, vh as i32)?;
video.finish();
if video_live {
vfront = vback;
}
vrenders += 1;
}
if playing && loaded && resume_to.is_none() {
if let Some(t) = m.get_f64("time-pos") {
history.set_pos(¤t_path, t);
}
}
let now = Instant::now();
let show_bar = mode == Mode::Play && !blank && now < osd_until;
let show_vol = mode == Mode::Play && !blank && now < vol_until;
let show_toast = mode == Mode::Play && !blank && now < toast_until;
if (show_bar, show_vol, show_toast) != last_vis {
ui_dirty = true;
last_vis = (show_bar, show_vol, show_toast);
}
if show_bar {
let sec = m.get_f64("time-pos").unwrap_or(0.0) as u64;
if sec != last_sec {
last_sec = sec;
ui_dirty = true;
}
}
if ui_dirty {
match mode {
Mode::Browse => {
let l = stack.last_mut().unwrap();
let layout = l.layout(wide);
let (cols, capacity) = shell::browse_shape(w as f32, h as f32, l.browse.keyboard().is_some(), layout);
l.browse.set_columns(cols);
let range = l.browse.window(capacity);
let first = range.start;
let played = |it: &Item| match &it.node {
Node::Media(p) => history.date(&p.to_string_lossy()),
Node::YtVideo(u) | Node::UpnpMedia(u, _) => history.date(u),
_ => None,
};
let (mut rows, mut cards) = (Vec::new(), Vec::new());
if layout == shell::Layout::Text {
rows = range
.map(|index| {
let it = &l.items[l.browse.source_index(index).unwrap()];
let label = if it.container { format!("{}/", it.name) } else { it.name.clone() };
let right = if !it.meta.is_empty() { it.meta.clone() } else { played(it).unwrap_or_default() };
(label, right)
})
.collect();
} else {
thumb_cache.begin_frame();
let next = range.end..(range.end + capacity).min(l.browse.count());
for index in next.rev() {
thumb_cache.prefetch(&l.items[l.browse.source_index(index).unwrap()].thumb);
}
for index in range.rev() {
let it = &l.items[l.browse.source_index(index).unwrap()];
let sub = card_sub(it, played(it));
let thumb = thumb_cache.get(&it.thumb);
cards.push(shell::Card { title: it.name.clone(), sub, meta: it.meta.clone(), thumb, icon: card_icon(it) });
}
cards.reverse();
}
let view = shell::BrowseView {
title: &l.title, layout, rows: &rows, cards: &cards, first,
selected: l.browse.selected(), matches: l.browse.count(),
total: l.browse.total(), query: l.browse.query(),
searching: l.browse.searching(), keyboard: l.browse.keyboard(),
search_focused: l.browse.search_focused(),
};
shell::render_browse(&mut frame, &text, &view, w as f32, h as f32);
if let Some(p) = pending.as_ref().filter(|_| spin_phase != usize::MAX) {
shell::render_status(&mut frame, &text, &format!("Loading {}…", p.label), Some(spin_phase), w as f32, h as f32);
} else if let Some((message, _)) = ¬ice {
shell::render_status(&mut frame, &text, message, None, w as f32, h as f32);
}
}
Mode::Play if blank => shell::render_blank(&mut frame, &text, w as f32, h as f32),
Mode::Play => {
let pos = m.get_f64("time-pos").unwrap_or(0.0);
let dur = m.get_f64("duration").unwrap_or(0.0);
let vol = m.get_f64("volume").unwrap_or(0.0);
let toast_opt = if show_toast { Some(toast.as_str()) } else { None };
shell::render_osd(&mut frame, &text, &now_title, pos, dur, vol, show_bar, show_vol, toast_opt, w as f32, h as f32);
if spin_phase != usize::MAX {
shell::render_status(&mut frame, &text, &format!("Loading {now_title}…"), Some(spin_phase), w as f32, h as f32);
}
}
Mode::Menu => {
let l = pbstack.last().unwrap();
let items: Vec<String> = l.items.iter().map(|it| it.name.clone()).collect();
if l.horizontal {
let pos = m.get_f64("time-pos").unwrap_or(0.0);
let dur = m.get_f64("duration").unwrap_or(0.0);
shell::render_seek(&mut frame, &text, &now_title, pos, dur, &items, l.selected, w as f32, h as f32);
} else {
shell::render_overlay(&mut frame, &text, &l.title, &items, l.selected, w as f32, h as f32);
}
}
Mode::SignIn => match &signin {
Some(si) => shell::render_signin(
&mut frame, &text,
&si.device.user_code, &si.device.verification_url, &si.status,
si.qr.as_ref().map(|(n, m)| (*n, m.as_slice())),
w as f32, h as f32,
),
None => shell::render_signin_setup(&mut frame, &text, w as f32, h as f32),
},
}
let ui_back = ui_front ^ 1;
text.render(&gpu, &targets[ui_back], ufbs[ui_back], &frame)?;
ui_front = ui_back;
ui_dirty = false;
}
if console::stopping() {
break;
}
if modeset {
output.present_two(&card, vbufs[vfront].fb, (vw as u16, vh as u16), ubufs[ui_front].fb).map_err(e2s)?;
} else {
output
.modeset_two(&card, vbufs[vfront].fb, (vw as u16, vh as u16), ubufs[ui_front].fb)
.map_err(|e| format!("modeset (video {vw}x{vh}, UI {w}x{h}): {e}"))?;
modeset = true;
}
presents += 1;
if stats && last_stats.elapsed() >= Duration::from_secs(1) {
let secs = last_stats.elapsed().as_secs_f64();
let vf_fps = m.get_f64("estimated-vf-fps").unwrap_or(0.0);
let drops = m.get_f64("frame-drop-count").unwrap_or(0.0);
println!(
"stats: present {:.0}/s video-render {:.0}/s mpv vf-fps {vf_fps:.1} drops {drops:.0}",
presents as f64 / secs,
vrenders as f64 / secs,
);
presents = 0;
vrenders = 0;
last_stats = Instant::now();
}
}
Ok(())
})();
finish_playback(&m, &mut history, ¤t_path, loaded, resume_to.is_some());
drop(sources);
display.restore();
drop(rc);
drop(m);
for fb in ufbs {
gpu.destroy_framebuffer(fb);
}
text.destroy(&gpu);
for t in targets {
gpu.destroy_target(t);
}
for b in vbufs.into_iter().chain(ubufs) {
let _ = card.destroy_framebuffer(b.fb);
}
playback_result
}
fn run_shell(dev: Option<&str>) -> Result<(), String> {
use std::sync::mpsc;
println!("=== shell ===");
let (card, output, mut display) = open_display(dev)?;
let alloc = Allocator::new(&card).map_err(|e| format!("gbm: {e}"))?;
let (w, h) = (u32::from(output.mode.size().0), u32::from(output.mode.size().1));
let gpu = vk::Gpu::new()?;
let text = gpu.create_text()?;
let mut buffers = Vec::new();
let mut targets = Vec::new();
let mut framebuffers = Vec::new();
for _ in 0..2 {
let (buffer, target) = gpu.create_scanout_target(&alloc, &card, &output, w, h, DrmFourcc::Argb8888)?;
framebuffers.push(text.framebuffer(&gpu, &target)?);
targets.push(target);
buffers.push(buffer);
}
let (tx, rx) = mpsc::channel::<input::Key>();
let sources = input::spawn(tx, display.owns_vt());
let have_input = sources.available();
println!("input source: {}", if have_input { "device" } else { "none (auto-demo)" });
let mut menu = shell::Menu::new("medias", &["Movies", "Series", "Music", "YouTube", "Settings"]);
let mut frame = vk::Frame::default();
shell::render(&mut frame, &text, &menu, w as f32, h as f32);
text.render(&gpu, &targets[0], framebuffers[0], &frame)?;
output.modeset(&card, buffers[0].fb).map_err(e2s)?;
let mut front = 0usize;
let start = Instant::now();
let mut last_auto = Instant::now();
'ui: loop {
if console::stopping() {
break;
}
for key in rx.try_iter() {
match menu.handle_with_page(key, shell::list_capacity(w as f32, h as f32)) {
shell::Action::Open(i) => println!("open: {}", menu.items[i]),
shell::Action::Exit => break 'ui,
shell::Action::None => {}
}
}
if !have_input && last_auto.elapsed() > Duration::from_millis(1200) {
menu.handle(input::Key::Down);
last_auto = Instant::now();
}
shell::render(&mut frame, &text, &menu, w as f32, h as f32);
let back = front ^ 1;
text.render(&gpu, &targets[back], framebuffers[back], &frame)?;
if console::stopping() {
break;
}
output.present(&card, buffers[back].fb).map_err(e2s)?;
front = back;
if !have_input && start.elapsed().as_secs() > 10 {
break;
}
}
drop(sources);
display.restore();
for fb in framebuffers {
gpu.destroy_framebuffer(fb);
}
text.destroy(&gpu);
for target in targets {
gpu.destroy_target(target);
}
for buffer in buffers {
card.destroy_framebuffer(buffer.fb).map_err(e2s)?;
}
Ok(())
}
fn run_input(evdev_path: Option<&str>) {
println!("=== input ===");
match input::Cec::open("/dev/cec0") {
Ok(mut cec) => {
println!("CEC /dev/cec0 open; press remote keys (Ctrl-C to stop)");
loop {
match cec.next() {
Ok(Some(k)) => println!("CEC {k:?}"),
Ok(None) => {}
Err(e) => {
eprintln!("CEC: {e}");
break;
}
}
}
return;
}
Err(e) => println!("CEC /dev/cec0: {e}"),
}
let path = evdev_path.unwrap_or("/dev/input/event0");
match input::Evdev::open(path) {
Ok(mut dev) => {
println!("evdev {path} open; press keys (Ctrl-C to stop)");
loop {
match dev.next() {
Ok(Some(k)) => println!("key {k:?}"),
Ok(None) => {}
Err(e) => {
eprintln!("evdev: {e}");
break;
}
}
}
}
Err(e) => {
println!("evdev {path}: {e}");
println!("no input source available (expected off-target; CEC/evdev live on the Pi)");
}
}
}
fn default_test_media() -> String {
"av://lavfi:testsrc=size=640x480:rate=30:duration=3".into()
}
fn run_video(dev: Option<&str>, url: &str) -> Result<(), String> {
println!("=== video ({url}) ===");
let (card, output, mut display) = open_display(dev)?;
let alloc = Allocator::new(&card).map_err(|e| format!("gbm: {e}"))?;
let (w, h) = output.mode.size();
let video = egl::VideoGl::new(&alloc)?;
let mut buffers = Vec::new();
let mut fbos = Vec::new();
for _ in 0..2 {
let buffer = alloc.alloc(&card, w.into(), h.into(), DrmFourcc::Xrgb8888, Use::SCANOUT | Use::RENDERING)
.map_err(|e| format!("alloc: {e}"))?;
fbos.push(video.import(&buffer)?);
buffers.push(buffer);
}
println!("mpv -> double-buffered GL -> KMS plane, {w}x{h}");
let m = mpv::Mpv::new()?;
m.set_option("vo", "libmpv")?; m.set_option("ao", "null")?;
m.set_option("hwdec", "no")?;
m.set_option("msg-level", "all=error")?;
m.init()?;
let rc = m.render_context(egl::mpv_get_proc_address)?;
m.loadfile(url)?;
let start = Instant::now();
let mut frames = 0u32;
let mut front = 0usize;
let mut modeset = false;
'play: loop {
if console::stopping() {
break;
}
loop {
match m.wait_event(0.0) {
mpv::Event::None => break,
mpv::Event::EndFile(_) | mpv::Event::PlaybackError | mpv::Event::Shutdown => break 'play,
mpv::Event::FileLoaded => println!("playing: {:.1}s", m.get_f64("duration").unwrap_or(0.0)),
_ => {}
}
}
if rc.frame_ready() {
let back = front ^ 1;
rc.render(video.fbo_id(&fbos[back]), w.into(), h.into())?;
video.finish();
front = back;
frames += 1;
}
if console::stopping() {
break;
}
if modeset {
output.present(&card, buffers[front].fb).map_err(|e| format!("present: {e}"))?;
} else if frames > 0 {
output.modeset(&card, buffers[front].fb).map_err(|e| format!("modeset: {e}"))?;
modeset = true;
} else {
std::thread::sleep(std::time::Duration::from_millis(1));
}
if start.elapsed().as_secs() > 20 {
break;
}
}
let secs = start.elapsed().as_secs_f64();
println!("rendered {frames} frames in {secs:.1}s ({:.1} fps)", frames as f64 / secs);
display.restore();
drop(rc);
drop(m);
for buffer in buffers {
card.destroy_framebuffer(buffer.fb).map_err(|e| format!("destroy fb: {e}"))?;
}
Ok(())
}
fn run_compositor(dev: Option<&str>, url: &str) -> Result<(), String> {
println!("=== compositor ({url}) ===");
let (card, output, mut display) = open_display(dev)?;
if output.video_plane.is_none() {
return Err("CRTC exposes only one plane; need two for compositing".into());
}
let alloc = Allocator::new(&card).map_err(|e| format!("gbm: {e}"))?;
let (w, h) = (u32::from(output.mode.size().0), u32::from(output.mode.size().1));
let video = egl::VideoGl::new(&alloc)?;
let gpu = vk::Gpu::new()?;
let renderer = gpu.create_renderer()?;
let mut video_buffers = Vec::new();
let mut video_fbos = Vec::new();
let mut ui_buffers = Vec::new();
let mut ui_targets = Vec::new();
let mut ui_fbs = Vec::new();
for _ in 0..2 {
let buffer = alloc.alloc(&card, w, h, DrmFourcc::Xrgb8888, Use::SCANOUT | Use::RENDERING)
.map_err(|e| format!("video alloc: {e}"))?;
video_fbos.push(video.import(&buffer)?);
video_buffers.push(buffer);
let (buffer, target) = gpu.create_scanout_target(&alloc, &card, &output, w, h, DrmFourcc::Argb8888)?;
ui_fbs.push(gpu.framebuffer(&renderer, &target)?);
ui_targets.push(target);
ui_buffers.push(buffer);
}
println!("double-buffered video and UI planes; {w}x{h}");
let m = mpv::Mpv::new()?;
m.set_option("vo", "libmpv")?;
m.set_option("ao", "null")?;
m.set_option("hwdec", "no")?;
m.set_option("loop", "inf")?; m.set_option("msg-level", "all=error")?;
m.init()?;
let rc = m.render_context(egl::mpv_get_proc_address)?;
m.loadfile(url)?;
let start = Instant::now();
let mut frames = 0u32;
let mut video_front = 0usize;
let mut ui_front = 0usize;
let mut video_ready = false;
let mut modeset = false;
'play: loop {
if console::stopping() {
break;
}
loop {
match m.wait_event(0.0) {
mpv::Event::None => break,
mpv::Event::Shutdown => break 'play,
_ => {}
}
}
if rc.frame_ready() {
let back = video_front ^ 1;
rc.render(video.fbo_id(&video_fbos[back]), w as i32, h as i32)?;
video.finish();
video_front = back;
video_ready = true;
}
if !video_ready {
if start.elapsed().as_secs() > 10 {
return Err("no video frame decoded".into());
}
std::thread::sleep(std::time::Duration::from_millis(1));
continue;
}
let t = (start.elapsed().as_secs_f32() * 1.5).sin() * 0.5 + 0.5;
let back = ui_front ^ 1;
gpu.draw(&renderer, &ui_targets[back], ui_fbs[back], t)?;
if console::stopping() {
break;
}
let video_fb = video_buffers[video_front].fb;
let ui_fb = ui_buffers[back].fb;
if modeset {
output.present_two(&card, video_fb, (w as u16, h as u16), ui_fb).map_err(e2s)?;
} else {
output.modeset_two(&card, video_fb, (w as u16, h as u16), ui_fb).map_err(e2s)?;
modeset = true;
}
ui_front = back;
frames += 1;
if start.elapsed().as_secs() > 10 {
break;
}
}
let secs = start.elapsed().as_secs_f64();
println!("composited {frames} frames in {secs:.1}s ({:.1} fps)", frames as f64 / secs);
display.restore();
drop(rc);
drop(m);
for fb in ui_fbs {
gpu.destroy_framebuffer(fb);
}
gpu.destroy_renderer(renderer);
for target in ui_targets {
gpu.destroy_target(target);
}
for buffer in video_buffers.into_iter().chain(ui_buffers) {
card.destroy_framebuffer(buffer.fb).map_err(e2s)?;
}
Ok(())
}
fn finish_playback(m: &mpv::Mpv, history: &mut history::History, path: &str, mut loaded: bool, pending_resume: bool) {
let drain_timeout = if loaded && m.get_f64("time-pos").is_none() { 0.1 } else { 0.0 };
loop {
match m.wait_event(drain_timeout) {
mpv::Event::None => break,
mpv::Event::EndFile(true) if loaded => {
history.set_pos(path, 0.0);
loaded = false;
}
mpv::Event::PlaybackError => loaded = false,
_ => {}
}
}
checkpoint_playback(m, history, path, loaded && !pending_resume);
}
fn checkpoint_playback(m: &mpv::Mpv, history: &mut history::History, path: &str, loaded: bool) {
if loaded && m.get_string("seeking").as_deref() != Some("yes")
&& let Some(position) = m.get_f64("time-pos") {
history.set_pos(path, position);
}
history.save();
}
fn e2s(e: std::io::Error) -> String {
e.to_string()
}
fn run_gpu(
card: &Card,
output: &Output,
alloc: &Allocator,
gpu: &vk::Gpu,
w: u32,
h: u32,
) -> std::io::Result<()> {
let (s, target) = gpu.create_scanout_target(alloc, card, output, w, h, DrmFourcc::Xrgb8888)
.map_err(std::io::Error::other)?;
println!("scanout bo: modifier={:#x} stride={} offset={}", s.modifier(), s.stride(), s.offset());
let renderer = gpu.create_renderer().map_err(std::io::Error::other)?;
let fb = gpu.framebuffer(&renderer, &target).map_err(std::io::Error::other)?;
println!("imported dmabuf; pipeline + framebuffer ready");
output.modeset(card, s.fb)?;
let n = 300;
let start = Instant::now();
for i in 0..n {
if console::stopping() {
break;
}
let t = (i as f32 / n as f32 * std::f32::consts::TAU).sin() * 0.5 + 0.5;
gpu.draw(&renderer, &target, fb, t).map_err(std::io::Error::other)?;
output.present(card, s.fb)?;
}
report("draw+flip", start, n);
gpu.destroy_framebuffer(fb);
gpu.destroy_renderer(renderer);
gpu.destroy_target(target);
card.destroy_framebuffer(s.fb)?;
Ok(())
}
fn run_cpu(card: &Card, output: &Output, alloc: &Allocator, w: u32, h: u32) -> std::io::Result<()> {
let flags = Use::SCANOUT | Use::WRITE | Use::LINEAR;
let mut front = alloc.alloc(card, w, h, DrmFourcc::Xrgb8888, flags)?;
let mut back = alloc.alloc(card, w, h, DrmFourcc::Xrgb8888, flags)?;
println!("allocated 2x {w}x{h} XRGB8888 scanout buffers");
fill_gradient(&mut front, 0)?;
fill_gradient(&mut back, 128)?;
output.modeset(card, front.fb)?;
println!("modeset ok\n");
bench_flips(card, output, &front, &back, 300)?;
bench_fill_flip(card, output, &mut back, 120)?;
card.destroy_framebuffer(front.fb)?;
card.destroy_framebuffer(back.fb)?;
Ok(())
}
fn bench_flips(card: &Card, o: &Output, a: &Scanout, b: &Scanout, n: u32) -> std::io::Result<()> {
let start = Instant::now();
for i in 0..n {
if console::stopping() {
break;
}
let fb = if i % 2 == 0 { a.fb } else { b.fb };
o.present(card, fb)?;
}
report("flip only", start, n);
Ok(())
}
fn bench_fill_flip(card: &Card, o: &Output, buf: &mut Scanout, n: u32) -> std::io::Result<()> {
let start = Instant::now();
for i in 0..n {
if console::stopping() {
break;
}
fill_gradient(buf, (i * 4) as u8)?;
o.present(card, buf.fb)?;
}
report("fill+flip", start, n);
Ok(())
}
fn report(label: &str, start: Instant, n: u32) {
let secs = start.elapsed().as_secs_f64();
println!(
"{label:10} {n} frames in {secs:.3}s => {:.1} fps ({:.2} ms/frame)",
n as f64 / secs,
secs * 1000.0 / n as f64,
);
}
fn fill_gradient(s: &mut Scanout, phase: u8) -> std::io::Result<()> {
let (w, h) = (s.width as usize, s.height as usize);
s.fill(|buf, stride| {
for y in 0..h {
let row = &mut buf[y * stride..y * stride + w * 4];
for x in 0..w {
let p = x * 4;
row[p] = (x * 255 / w) as u8 ^ phase; row[p + 1] = (y * 255 / h) as u8; row[p + 2] = 64; row[p + 3] = 0; }
}
})
}
fn print_help() {
print!(
"\
medias - media player for Raspberry Pi (DRM/KMS + Vulkan + libmpv, no X11)
USAGE:
medias [OPTIONS] [DEVICE]
run the player (default; DEVICE e.g. /dev/dri/card1
overrides the auto-detected KMS display node)
medias <SUBCOMMAND>
OPTIONS (repeatable; each adds a top-menu entry; default: Videos + Music):
--videos[=[LABEL=]PATH] a video library root
--music[=[LABEL=]PATH] a music library root
bare -> Videos / Music under ~/medias
=PATH -> entry labeled from PATH's name (relative to ~/medias)
=LABEL=PATH -> entry labeled LABEL
e.g. --videos=Movies=/mnt/movies --videos=Series=series --music=/mnt/music
SUBCOMMANDS:
(none) the media player app: browse + play
bench [DEVICE] graphics bring-up benchmarks (KMS/Vulkan present)
comp [FILE|URL] [DEVICE] two-plane compositor demo (video + UI overlay)
video [FILE|URL] [DEVICE] play one file/URL full screen (mpv -> KMS)
mpv [FILE|URL] headless libmpv decode/control test (no display)
shell [DEVICE] UI shell / menu demo (no video)
input [EVDEV] print input events from CEC or evdev
--help, -h, help this message
CONTENT (under $HOME/medias):
Videos/ scanned into the Videos menu
Music/ scanned into the Music menu
upnp.txt UPnP/DLNA servers outside multicast reach: one
description URL per line (e.g.
http://10.0.0.5:8200/rootDesc.xml); \"Media
servers\" also finds servers via SSDP (1.5 s)
youtube.txt YouTube sources, one per line: \"Label | query\"
query = ytsearchN:terms, or a channel/playlist URL
youtube_oauth.json YouTube sign-in: {{\"client_id\",\"client_secret\",
\"api_key\"?}}. The OAuth client must be of type
\"TVs and Limited Input devices\". Without it, search
still works anonymously via yt-dlp. Credentials may
also come from MEDIAS_YT_CLIENT_ID / _CLIENT_SECRET /
_API_KEY. The session is stored in .youtube_auth.
.youtube_tv_auth \"For you\" session: a second QR sign-in with
YouTube's TV client (as the Kodi plugin does), no
configuration needed. Only in builds with
--features youtube-tv.
.view thumbnail view: \"list\" or \"grid\"
.cache/thumbs/ thumbnails (YouTube: 30-day reuse; local files:
until the file changes)
REMOTE / KEYS:
menu: Up/Down move, OK/Enter select, Back up a level
(at root: back to video, or exit if none is loaded)
Lists scroll automatically. Left/Right, Channel +/-, or PgUp/PgDn
move by a page; Home/End select the first/last entry.
search: Type to filter the current directory, or press / or Ctrl-F.
Up from the first result focuses search; OK opens the keyboard.
Down from search returns to results.
Matching ignores case. Backspace deletes; Enter opens the selection.
Blue/Menu opens an on-screen keyboard: arrows move, OK types,
Done returns to results. Back closes the keyboard, leaves the
search bar, then clears the filter before leaving the directory.
Search is not recursive.
thumbnails: library folders and YouTube lists show thumbnails (video frame
at 15%, embedded or folder cover art, cover/folder/poster image or
first file for folders). Yellow/Info/F4 toggles a small-thumbnail
list and a wide grid (4 x 2 at 16:9; arrows move by card, Up from
the top row focuses search).
loading: folders, YouTube lists and stream lookups load in the background
behind a spinner; Back abandons the load. Failures show briefly.
play: Left/Right seek +/-10s, Rewind/Fast-Forward +/-60s, Up/Down volume,
Play toggles pause, Audio/Subtitle quick-cycle tracks, Stop stops.
OK or Pause open the playback menu; Back opens the file list.
The now-playing bar hides after 10s. Yellow/Info cycles: bar shown,
hidden, screen black (audio keeps playing; volume keys keep it
black, other keys light it), bar shown.
Next/Previous track (CEC Forward/Backward, terminal > and <) play
the next/previous file of the folder; Previous after 5s restarts.
At the end of a local or UPnP file the next one in its folder (in
the filtered order) plays, unless turned off.
menu (playback): Up/Down navigate, OK select, Back up a level / resume.
Entries: Resume, Seek, Audio and Subtitles (choose track), Play
next in folder (on/off, kept in ~/medias/.autoplay), Browse
library, Stop. The Seek row is horizontal (Left/Right); relative
jumps repeat without closing and keep the time on screen.
colored/extra buttons: Red=subtitles, Green=audio, Blue=menu, Yellow=info,
Channel Up/Down = seek +/-60s.
Playback resumes from the saved per-file position in ~/medias/.history.
Ctrl-C / Ctrl-Z save the current playback position before cleanup.
Sources: terminal keyboard text/navigation, HDMI-CEC, and evdev keys.
Unmapped remote/media key codes are printed to stderr.
ENVIRONMENT:
MEDIAS_HWDEC=auto|no|... mpv hardware decode (default auto; `no` = software)
MEDIAS_AO=<list> mpv audio output list (default alsa,null;
e.g. pipewire)
MEDIAS_AUDIO_DEVICE=<dev> mpv audio device (`aplay -L` for names,
e.g. alsa/sysdefault:CARD=vc4hdmi0)
MEDIAS_STATS=1 print per-second playback stats
MEDIAS_YT_MAX_HEIGHT=<px> YouTube stream height cap (default 1080)
MEDIAS_YT_CODECS=<list> allowed YouTube video codecs, preferred first
(default av01,vp9,avc1; Pi: avc1)
MEDIAS_YT_AUDIO_LANGS=<l> languages whose YouTube dubs are offered beside
the original audio, e.g. de,en (default: from
LANGUAGE/LC_ALL/LC_MESSAGES/LANG)
MEDIAS_YTDL_PATH=<path> yt-dlp for the playback fallback and anonymous
search/sources (default: found in /usr/bin etc.)
MEDIAS_MPV_LOG=<path> write mpv's verbose log (playback diagnosis)
MEDIAS_YT_DEBUG=1 dump the InnerTube browse response to
~/medias/innertube-browse.json
NOTES:
- All display commands auto-detect the KMS node; DEVICE overrides detection.
- Run with access to /dev/dri and input devices.
DRM master requires that no desktop/compositor owns the display.
- The same command works directly from SSH, Zellij, or a Linux console.
SSH/Zellij startup does not require sudo or a separate VT launcher.
On a real console, medias manages graphics mode and VT switching.
- Ctrl-C, Ctrl-Z, and termination signals exit and restore display state.
VT switches exit the player when it owns a real console VT.
- Optional: scripts/medias-vt uses sudo to launch on a separate VT.
- Video renders at <=1080p and the display scales it (big win at 4K).
- To use a user PipeWire from root: XDG_RUNTIME_DIR=/run/user/<uid> MEDIAS_AO=pipewire
"
);
}
fn fail(msg: &str) -> ! {
eprintln!("{msg}");
std::process::exit(1);
}
#[cfg(test)]
mod playback_tests {
use super::*;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
struct Fixture(std::path::PathBuf);
impl Fixture {
fn new() -> Self {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("medias-resume-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let size = 30u32 * 8000 * 2;
let mut wave = Vec::new();
wave.extend(b"RIFF"); wave.extend((size + 36).to_le_bytes()); wave.extend(b"WAVEfmt ");
wave.extend(16u32.to_le_bytes()); wave.extend(1u16.to_le_bytes()); wave.extend(1u16.to_le_bytes());
wave.extend(8000u32.to_le_bytes()); wave.extend(16000u32.to_le_bytes());
wave.extend(2u16.to_le_bytes()); wave.extend(16u16.to_le_bytes());
wave.extend(b"data"); wave.extend(size.to_le_bytes());
wave.resize(44 + size as usize, 0);
std::fs::write(dir.join("silence.wav"), wave).unwrap();
Self(dir)
}
fn media(&self) -> String { self.0.join("silence.wav").to_str().unwrap().to_owned() }
fn history(&self) -> history::History { history::History::load_from(self.0.join(".history")) }
}
impl Drop for Fixture {
fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); }
}
fn headless() -> mpv::Mpv {
let m = mpv::Mpv::new().unwrap();
for (name, value) in [("config", "no"), ("load-scripts", "no"), ("terminal", "no"), ("vo", "null"), ("ao", "null"), ("hwdec", "no"), ("idle", "yes"), ("pause", "yes"), ("ytdl", "no")] {
m.set_option(name, value).unwrap();
}
m.init().unwrap();
m
}
#[test]
#[ignore]
fn direct_youtube_streams_load_without_ytdlp() {
let prefs = youtube::innertube::StreamPrefs {
max_height: 1080,
codecs: vec!["av01".into(), "vp9".into(), "avc1".into()],
audio_langs: vec!["de".into()],
};
let streams = youtube::innertube::streams("0e3GPea1Tyg", &prefs).unwrap();
let titles: Vec<&str> = streams.audio.iter().map(|a| a.title.as_str()).collect();
assert_eq!(titles, ["English (US) (original)", "German (dubbed)"]);
let m = headless();
let audio: Vec<String> = streams.audio.iter().map(|a| mpv::edl_track(&a.url, &a.lang, &a.title)).collect();
m.set_audio_files(&audio.iter().map(String::as_str).collect::<Vec<_>>()).unwrap();
m.loadfile(&streams.video).unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
loop {
match m.wait_event(0.5) {
mpv::Event::FileLoaded => break,
mpv::Event::PlaybackError => panic!("direct stream failed to load"),
_ => assert!(Instant::now() < deadline, "timed out waiting for FileLoaded"),
}
}
assert_eq!(m.get_string("track-list/count").as_deref(), Some("3"));
assert_eq!(m.get_string("current-tracks/audio/title").as_deref(), Some("English (US) (original)"));
assert_eq!(m.get_string("track-list/2/lang").as_deref(), Some("de"));
}
fn load(m: &mpv::Mpv, path: &str) {
m.loadfile(path).unwrap();
let start = Instant::now();
while m.wait_event(0.01) != mpv::Event::FileLoaded {
assert!(start.elapsed() < Duration::from_secs(5));
}
}
fn seek(m: &mpv::Mpv, position: f64) {
m.command(&["seek", &position.to_string(), "absolute+exact"]).unwrap();
let start = Instant::now();
loop {
if m.get_f64("time-pos").is_some_and(|t| (t - position).abs() < 0.2)
&& m.get_string("seeking").as_deref() != Some("yes") { return; }
m.wait_event(0.01);
assert!(start.elapsed() < Duration::from_secs(5));
}
}
#[test]
fn shutdown_signals_persist_latest_position_before_mpv_destruction() {
const CHILD: &str = "MEDIAS_RESUME_SIGNAL";
let Some(number) = std::env::var_os(CHILD) else {
for signal in [libc::SIGINT, libc::SIGTSTP] {
let mut command = Command::new(std::env::current_exe().unwrap());
command.args(["--exact", "playback_tests::shutdown_signals_persist_latest_position_before_mpv_destruction"])
.env(CHILD, signal.to_string()).env_remove("SUDO_TTY").stdin(Stdio::null());
unsafe {
command.pre_exec(|| {
if libc::setsid() < 0 { return Err(std::io::Error::last_os_error()); }
Ok(())
});
}
assert!(command.status().unwrap().success());
}
return;
};
let signal: i32 = number.to_str().unwrap().parse().unwrap();
let console = console::Console::acquire().unwrap();
assert!(!console.owns_vt());
let fixture = Fixture::new();
let path = fixture.media();
let m = headless();
load(&m, &path);
seek(&m, 12.75);
let mut history = fixture.history();
history.set_pos(&path, 8.0);
history.save();
assert_eq!(unsafe { libc::raise(signal) }, 0);
assert!(console::stopping());
finish_playback(&m, &mut history, &path, true, false);
drop(m);
let resume = fixture.history().pos(&path).unwrap();
assert_eq!(resume, 12.0);
drop(console);
let m = headless();
load(&m, &path);
seek(&m, resume);
assert!(m.get_f64("time-pos").unwrap() >= 11.8);
}
#[test]
fn paused_resume_seek_reports_completion_before_later_backward_seek() {
let fixture = Fixture::new();
let path = fixture.media();
let m = headless();
load(&m, &path);
let start = Instant::now();
while m.wait_event(0.01) != mpv::Event::PlaybackRestart {
assert!(start.elapsed() < Duration::from_secs(5));
}
m.command(&["seek", "21.5", "absolute+exact"]).unwrap();
let mut seek_seen = false;
let start = Instant::now();
loop {
match m.wait_event(0.01) {
mpv::Event::Seek => seek_seen = true,
mpv::Event::PlaybackRestart => { assert!(seek_seen); break; }
_ => {}
}
assert!(start.elapsed() < Duration::from_secs(5));
}
let mut history = fixture.history();
finish_playback(&m, &mut history, &path, true, false);
assert_eq!(fixture.history().pos(&path), Some(21.0));
seek(&m, 9.25);
finish_playback(&m, &mut history, &path, true, false);
assert_eq!(fixture.history().pos(&path), Some(9.0));
}
#[test]
fn pending_load_or_resume_retains_cached_position() {
let fixture = Fixture::new();
let m = headless();
load(&m, &fixture.media());
seek(&m, 12.75);
let mut history = fixture.history();
history.set_pos("next-file", 24.0);
finish_playback(&m, &mut history, "next-file", false, false);
assert_eq!(fixture.history().pos("next-file"), Some(24.0));
history.set_pos(&fixture.media(), 21.0);
finish_playback(&m, &mut history, &fixture.media(), true, true);
assert_eq!(fixture.history().pos(&fixture.media()), Some(21.0));
}
#[test]
fn stop_preserves_latest_position_and_playback_error_is_not_eof() {
let fixture = Fixture::new();
let path = fixture.media();
let m = headless();
load(&m, &path);
seek(&m, 16.5);
let mut history = fixture.history();
checkpoint_playback(&m, &mut history, &path, true);
m.command(&["stop"]).unwrap();
assert_eq!(fixture.history().pos(&path), Some(16.0));
m.loadfile(fixture.0.join("missing.wav").to_str().unwrap()).unwrap();
let start = Instant::now();
loop {
let event = m.wait_event(0.01);
assert_ne!(event, mpv::Event::EndFile(true));
if event == mpv::Event::PlaybackError { break; }
assert!(start.elapsed() < Duration::from_secs(5));
}
finish_playback(&m, &mut history, &path, false, false);
assert_eq!(fixture.history().pos(&path), Some(16.0));
}
#[test]
fn pending_natural_eof_clears_resume_on_shutdown() {
let fixture = Fixture::new();
let path = fixture.media();
let m = headless();
load(&m, &path);
seek(&m, 29.9);
let mut history = fixture.history();
history.set_pos(&path, 29.0);
m.set_property("pause", "no").unwrap();
let start = Instant::now();
while m.get_f64("time-pos").is_some() {
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(5));
}
finish_playback(&m, &mut history, &path, true, false);
assert_eq!(fixture.history().pos(&path), None);
}
}
#[cfg(test)]
mod render_tests {
use super::*;
#[test]
#[ignore]
fn thumbnail_layouts_render_offscreen() {
let (w, h) = (1920u32, 1080u32);
let gpu = vk::Gpu::new().unwrap();
let text = gpu.create_text().unwrap();
let target = gpu.offscreen_target(w, h).unwrap();
let fb = text.framebuffer(&gpu, &target).unwrap();
let videos = youtube::innertube::related("V_uq3v6xK-4").unwrap();
assert!(videos.len() >= 8, "{} related videos", videos.len());
let items = yt_video_items(videos);
let mut cache = thumbs::Thumbs::new();
let start = Instant::now();
loop {
cache.begin_frame();
let ready = items.iter().take(8).filter(|it| cache.get(&it.thumb).is_some()).count();
if ready == 8 || start.elapsed() > Duration::from_secs(20) {
break;
}
cache.pump(&text, &gpu);
std::thread::sleep(Duration::from_millis(50));
}
let dir = std::env::var("MEDIAS_SNAPSHOT_DIR").unwrap_or_else(|_| "/tmp".into());
let mut frame = vk::Frame::default();
for (name, layout) in [("list", shell::Layout::List), ("grid", shell::Layout::Grid)] {
cache.begin_frame();
let cards: Vec<shell::Card> = items
.iter()
.map(|it| shell::Card { title: it.name.clone(), sub: it.sub.clone(), meta: it.meta.clone(), thumb: cache.get(&it.thumb), icon: card_icon(it) })
.collect();
assert!(cards.iter().take(8).all(|c| c.thumb.is_some()), "thumbnails not loaded");
let view = shell::BrowseView {
title: "From your history", layout, rows: &[], cards: &cards, first: 0, selected: 1,
matches: cards.len(), total: cards.len(), query: "", searching: false,
search_focused: false, keyboard: None,
};
shell::render_browse(&mut frame, &text, &view, w as f32, h as f32);
text.render(&gpu, &target, fb, &frame).unwrap();
let pixels = gpu.read_target(&target).unwrap();
std::fs::write(format!("{dir}/medias-{name}-{w}x{h}.bgra"), &pixels).unwrap();
}
gpu.destroy_framebuffer(fb);
text.destroy(&gpu);
gpu.destroy_target(target);
}
#[test]
#[ignore]
fn loading_spinner_renders_offscreen() {
let (w, h) = (1920u32, 1080u32);
let gpu = vk::Gpu::new().unwrap();
let text = gpu.create_text().unwrap();
let target = gpu.offscreen_target(w, h).unwrap();
let fb = text.framebuffer(&gpu, &target).unwrap();
let rows: Vec<shell::Row> = ["For you", "Search", "Trending"].iter().map(|s| (s.to_string(), String::new())).collect();
let dir = std::env::var("MEDIAS_SNAPSHOT_DIR").unwrap_or_else(|_| "/tmp".into());
let mut frame = vk::Frame::default();
for step in [0, 4, 8] {
let view = shell::BrowseView {
title: "YouTube", layout: shell::Layout::Text, rows: &rows, cards: &[], first: 0, selected: 0,
matches: rows.len(), total: rows.len(), query: "", searching: false, search_focused: false, keyboard: None,
};
shell::render_browse(&mut frame, &text, &view, w as f32, h as f32);
shell::render_status(&mut frame, &text, "Loading For you…", Some(step), w as f32, h as f32);
text.render(&gpu, &target, fb, &frame).unwrap();
std::fs::write(format!("{dir}/medias-spinner{step}-{w}x{h}.bgra"), gpu.read_target(&target).unwrap()).unwrap();
}
gpu.destroy_framebuffer(fb);
text.destroy(&gpu);
gpu.destroy_target(target);
}
#[test]
#[ignore]
fn local_thumbnails_render_offscreen() {
let root = std::env::temp_dir().join(format!("medias-local-{}", std::process::id()));
let album = root.join("Some Album");
std::fs::create_dir_all(&album).unwrap();
let ffmpeg = |args: &[&str]| {
assert!(std::process::Command::new("ffmpeg").args(["-v", "error", "-y"]).args(args).status().unwrap().success())
};
let path = |p: &std::path::Path| p.to_str().unwrap().to_string();
ffmpeg(&["-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=25:duration=20", "-c:v", "libx264", "-preset", "ultrafast", &path(&root.join("A Movie.mkv"))]);
ffmpeg(&["-f", "lavfi", "-i", "mandelbrot=size=1280x720:rate=25", "-t", "20", "-c:v", "libx264", "-preset", "ultrafast", &path(&root.join("Another Movie.mp4"))]);
ffmpeg(&["-f", "lavfi", "-i", "sine=duration=5", &path(&root.join("No Art.mp3"))]);
let art = std::env::temp_dir().join(format!("medias-art-{}.jpg", std::process::id()));
ffmpeg(&["-f", "lavfi", "-i", "cellauto=s=600x600", "-frames:v", "1", &path(&art)]);
ffmpeg(&["-i", &path(&root.join("No Art.mp3")), "-i", &path(&art), "-map", "0", "-map", "1", "-c", "copy",
"-disposition:v", "attached_pic", &path(&album.join("01 Track.mp3"))]);
std::fs::remove_file(&art).unwrap();
std::fs::create_dir_all(root.join("Empty Folder")).unwrap();
let (w, h) = (1920u32, 1080u32);
let gpu = vk::Gpu::new().unwrap();
let text = gpu.create_text().unwrap();
let target = gpu.offscreen_target(w, h).unwrap();
let fb = text.framebuffer(&gpu, &target).unwrap();
let items = lib_items(library::list(&root).unwrap());
assert_eq!(items.len(), 5);
let mut cache = thumbs::Thumbs::new();
let start = Instant::now();
while start.elapsed() < Duration::from_secs(20) {
cache.begin_frame();
if items.iter().filter(|it| cache.get(&it.thumb).is_some()).count() == 3 {
break; }
cache.pump(&text, &gpu);
std::thread::sleep(Duration::from_millis(50));
}
let dir = std::env::var("MEDIAS_SNAPSHOT_DIR").unwrap_or_else(|_| "/tmp".into());
let mut frame = vk::Frame::default();
for (name, layout) in [("local-list", shell::Layout::List), ("local-grid", shell::Layout::Grid)] {
cache.begin_frame();
let cards: Vec<shell::Card> = items
.iter()
.map(|it| shell::Card { title: it.name.clone(), sub: card_sub(it, None), meta: String::new(), thumb: cache.get(&it.thumb), icon: card_icon(it) })
.collect();
assert_eq!(cards.iter().filter(|c| c.thumb.is_some()).count(), 3, "thumbnails not loaded");
let view = shell::BrowseView {
title: "Videos", layout, rows: &[], cards: &cards, first: 0, selected: 0,
matches: cards.len(), total: cards.len(), query: "", searching: false,
search_focused: false, keyboard: None,
};
shell::render_browse(&mut frame, &text, &view, w as f32, h as f32);
text.render(&gpu, &target, fb, &frame).unwrap();
std::fs::write(format!("{dir}/medias-{name}-{w}x{h}.bgra"), gpu.read_target(&target).unwrap()).unwrap();
}
gpu.destroy_framebuffer(fb);
text.destroy(&gpu);
gpu.destroy_target(target);
std::fs::remove_dir_all(&root).unwrap();
}
}
#[cfg(test)]
mod queue_tests {
use super::*;
fn level() -> Level {
let server = std::sync::Arc::new(upnp::Server {
name: "nas".into(),
udn: "uuid:1".into(),
control: "http://h/ctl".into(),
service_type: String::new(),
icon: String::new(),
});
Level::new("Album", vec![
Item::new("CD2", Node::Dir("/m/CD2".into()), true),
Item::new("01 Intro", Node::Media("/m/01.flac".into()), false),
Item::new("02 Song", Node::Media("/m/02.flac".into()), false),
Item::new("03 Remote", Node::UpnpMedia("http://h/3.flac".into(), upnp::Kind::Audio), false),
Item::video("Clip", "https://www.youtube.com/watch?v=abcdefghijk".into()),
Item::new("Sub", Node::UpnpDir(server, "9".into()), true),
])
}
fn target(loaded: Option<Loaded>) -> Option<String> {
match loaded? {
Loaded::Play { key, target, .. } => {
assert_eq!(key, target);
Some(target)
}
_ => None,
}
}
#[test]
fn queue_steps_through_playable_folder_items_only() {
let mut stack = vec![level()];
let mut q = Queue::new(&stack[0], 0, 1).unwrap();
assert_eq!(q.items.len(), 3); assert_eq!(target(Some(q.play())).as_deref(), Some("/m/01.flac"));
assert_eq!(target(q.step(-1)), None);
assert_eq!(target(q.step(1)).as_deref(), Some("/m/02.flac"));
assert_eq!(target(q.step(1)).as_deref(), Some("http://h/3.flac"));
q.sync_selection(&mut stack);
assert_eq!(stack[0].browse.source_index(stack[0].browse.selected()), Some(3));
assert_eq!(target(q.step(1)), None); assert!(Queue::new(&stack[0], 0, 0).is_none()); }
#[test]
fn info_cycles_bar_hidden_black_bar() {
let (mut bar, mut hidden, mut blank) = (true, false, false); let mut seen = Vec::new();
for _ in 0..4 {
(bar, hidden, blank) = info_step(bar, hidden, blank);
seen.push((bar, blank));
}
assert_eq!(seen, [(false, false), (false, true), (true, false), (false, false)]);
assert_eq!(info_step(false, false, false), (true, false, false)); }
#[test]
fn queue_follows_the_filtered_order() {
let mut l = level();
for key in "song".chars().map(input::Key::Char) {
l.browse.handle(key, 10);
}
let mut q = Queue::new(&l, 0, 2).unwrap();
assert_eq!(q.items.len(), 1);
assert_eq!(target(q.step(1)), None);
}
}