Initial commit: Emby TUI client with libmpv playback
A ratatui-based terminal browser for an Emby server with mpv as the player
backend. Authenticates via /Users/AuthenticateByName, lists libraries and
items, streams via /Videos/{id}/stream, and reports playback progress so
"Continue Watching" stays in sync across clients.
Credentials are read in priority order: CLI flags, env vars
(EMBY_SERVER/EMBY_USER/EMBY_PASSWORD), ./config.toml or
$XDG_CONFIG_HOME/emby-client/config.toml, then an in-TUI login prompt with
an option to persist to config.toml.
Modules: config (CLI + TOML loader), login (TUI prompt), emby (HTTP client
+ progress Reporter), player (libmpv2 wrapper, blocks until file ends),
app (browse state, Continue Watching virtual entry), ui (ratatui frontend).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
commit
af43b21f5d
11 files changed
+3712
No files matched your search
+261
@@ -0,0 +1,261 @@
|
||||
use anyhow::Result;
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
use crate::emby::{Client, Item};
|
||||
use crate::player::Player;
|
||||
|
||||
pub const RESUME_VIRTUAL_ID: &str = "__resume__";
|
||||
pub const RESUME_TITLE: &str = "Continue Watching";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum View {
|
||||
Libraries,
|
||||
Items,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Action {
|
||||
Play {
|
||||
id: String,
|
||||
/// Display title (e.g. "Series — S01E02 — Episode Title").
|
||||
title: String,
|
||||
/// Resume position from server-side UserData, in Emby ticks (100 ns units).
|
||||
start_ticks: i64,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub client: Client,
|
||||
pub view: View,
|
||||
pub libraries: Vec<Item>,
|
||||
pub resume: Vec<Item>,
|
||||
/// Libraries view list = optional virtual "Continue Watching" entry + libraries.
|
||||
pub home_entries: Vec<Item>,
|
||||
pub items: Vec<Item>,
|
||||
pub list_state: ListState,
|
||||
pub current_title: Option<String>,
|
||||
pub breadcrumbs: Vec<(String, String)>, // (id, name)
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub async fn new(client: Client) -> Result<Self> {
|
||||
let libraries = client.views().await?;
|
||||
// Best-effort: don't fail startup if the resume endpoint hiccups.
|
||||
let resume = client.resume_items(20).await.unwrap_or_default();
|
||||
|
||||
let home_entries = build_home_entries(&libraries, &resume);
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
if !home_entries.is_empty() {
|
||||
list_state.select(Some(0));
|
||||
}
|
||||
let status = format!(
|
||||
"Connected as {} · {} libraries · {} in progress",
|
||||
client.user_name,
|
||||
libraries.len(),
|
||||
resume.len()
|
||||
);
|
||||
Ok(Self {
|
||||
client,
|
||||
view: View::Libraries,
|
||||
libraries,
|
||||
resume,
|
||||
home_entries,
|
||||
items: vec![],
|
||||
list_state,
|
||||
current_title: None,
|
||||
breadcrumbs: vec![],
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn current_list(&self) -> &[Item] {
|
||||
match self.view {
|
||||
View::Libraries => &self.home_entries,
|
||||
View::Items => &self.items,
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the Continue Watching list (after playback ends, for example).
|
||||
pub async fn refresh_resume(&mut self) {
|
||||
if let Ok(resume) = self.client.resume_items(20).await {
|
||||
self.resume = resume;
|
||||
self.home_entries = build_home_entries(&self.libraries, &self.resume);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self) {
|
||||
let len = self.current_list().len();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
self.list_state.select(Some((i + 1).min(len - 1)));
|
||||
}
|
||||
|
||||
pub fn move_up(&mut self) {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
self.list_state.select(Some(i.saturating_sub(1)));
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self) {
|
||||
let len = self.current_list().len();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
self.list_state.select(Some((i + 10).min(len - 1)));
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
self.list_state.select(Some(i.saturating_sub(10)));
|
||||
}
|
||||
|
||||
pub async fn activate(&mut self) -> Result<Option<Action>> {
|
||||
let Some(i) = self.list_state.selected() else {
|
||||
return Ok(None);
|
||||
};
|
||||
match self.view {
|
||||
View::Libraries => {
|
||||
let Some(entry) = self.home_entries.get(i).cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if entry.id == RESUME_VIRTUAL_ID {
|
||||
self.items = self.resume.clone();
|
||||
self.view = View::Items;
|
||||
self.breadcrumbs.clear();
|
||||
self.breadcrumbs
|
||||
.push((RESUME_VIRTUAL_ID.into(), RESUME_TITLE.into()));
|
||||
self.current_title = Some(RESUME_TITLE.into());
|
||||
self.list_state
|
||||
.select(if self.items.is_empty() { None } else { Some(0) });
|
||||
self.status = format!("{}: {} items", RESUME_TITLE, self.items.len());
|
||||
return Ok(None);
|
||||
}
|
||||
self.status = format!("Loading {}...", entry.name);
|
||||
self.items = self.client.items(&entry.id).await?;
|
||||
self.view = View::Items;
|
||||
self.breadcrumbs.clear();
|
||||
self.breadcrumbs
|
||||
.push((entry.id.clone(), entry.name.clone()));
|
||||
self.current_title = Some(entry.name.clone());
|
||||
self.list_state
|
||||
.select(if self.items.is_empty() { None } else { Some(0) });
|
||||
self.status = format!("{}: {} items", entry.name, self.items.len());
|
||||
Ok(None)
|
||||
}
|
||||
View::Items => {
|
||||
let Some(item) = self.items.get(i).cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if item.is_folder {
|
||||
self.status = format!("Loading {}...", item.name);
|
||||
self.items = self.client.items(&item.id).await?;
|
||||
self.breadcrumbs.push((item.id.clone(), item.name.clone()));
|
||||
self.current_title = Some(item.name.clone());
|
||||
self.list_state
|
||||
.select(if self.items.is_empty() { None } else { Some(0) });
|
||||
self.status = format!("{}: {} items", item.name, self.items.len());
|
||||
Ok(None)
|
||||
} else {
|
||||
let start_ticks = item
|
||||
.user_data
|
||||
.as_ref()
|
||||
.map(|ud| ud.position_ticks)
|
||||
.unwrap_or(0);
|
||||
let title = item.display_title();
|
||||
Ok(Some(Action::Play {
|
||||
id: item.id,
|
||||
title,
|
||||
start_ticks,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn back(&mut self) -> Result<()> {
|
||||
if self.view != View::Items {
|
||||
return Ok(());
|
||||
}
|
||||
// Pop the current breadcrumb (current folder)
|
||||
self.breadcrumbs.pop();
|
||||
if let Some((parent_id, parent_name)) = self.breadcrumbs.last().cloned() {
|
||||
if parent_id == RESUME_VIRTUAL_ID {
|
||||
self.items = self.resume.clone();
|
||||
} else {
|
||||
self.items = self.client.items(&parent_id).await?;
|
||||
}
|
||||
self.current_title = Some(parent_name.clone());
|
||||
self.list_state
|
||||
.select(if self.items.is_empty() { None } else { Some(0) });
|
||||
self.status = format!("{}: {} items", parent_name, self.items.len());
|
||||
} else {
|
||||
self.view = View::Libraries;
|
||||
self.current_title = None;
|
||||
self.items.clear();
|
||||
self.list_state.select(if self.home_entries.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
});
|
||||
self.status = format!(
|
||||
"{} libraries · {} in progress",
|
||||
self.libraries.len(),
|
||||
self.resume.len()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn play(&mut self, action: &Action) -> Result<()> {
|
||||
match action {
|
||||
Action::Play {
|
||||
id,
|
||||
title,
|
||||
start_ticks,
|
||||
} => {
|
||||
let url = self.client.stream_url(id);
|
||||
self.status = format!("Playing {title}...");
|
||||
let start_seconds = (*start_ticks as f64) / 10_000_000.0;
|
||||
let reporter = self.client.reporter(id.clone());
|
||||
let mut player = Player::new()?;
|
||||
let r = player.play_blocking(&url, title, start_seconds, Some(&reporter));
|
||||
self.status = match &r {
|
||||
Ok(_) => format!("Finished: {title}"),
|
||||
Err(e) => format!("Playback error: {e}"),
|
||||
};
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn breadcrumb_path(&self) -> String {
|
||||
if self.breadcrumbs.is_empty() {
|
||||
"Libraries".into()
|
||||
} else {
|
||||
self.breadcrumbs
|
||||
.iter()
|
||||
.map(|(_, n)| n.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec<Item> {
|
||||
let mut entries: Vec<Item> = Vec::with_capacity(libraries.len() + 1);
|
||||
if !resume.is_empty() {
|
||||
entries.push(Item {
|
||||
id: RESUME_VIRTUAL_ID.into(),
|
||||
name: format!("{RESUME_TITLE} ({})", resume.len()),
|
||||
item_type: "Collection".into(),
|
||||
is_folder: true,
|
||||
..Item::default()
|
||||
});
|
||||
}
|
||||
entries.extend(libraries.iter().cloned());
|
||||
entries
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "emby-client", about = "TUI Emby client with libmpv playback")]
|
||||
pub struct CliArgs {
|
||||
/// Emby server URL (e.g. http://emby.home.local)
|
||||
#[arg(long, env = "EMBY_SERVER")]
|
||||
pub server: Option<String>,
|
||||
|
||||
/// Emby username
|
||||
#[arg(short, long, env = "EMBY_USER")]
|
||||
pub user: Option<String>,
|
||||
|
||||
/// Emby password
|
||||
#[arg(short, long, env = "EMBY_PASSWORD")]
|
||||
pub password: Option<String>,
|
||||
|
||||
/// Path to config file. Defaults: ./config.toml then $XDG_CONFIG_HOME/emby-client/config.toml
|
||||
#[arg(short, long)]
|
||||
pub config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FileConfig {
|
||||
pub server: Option<String>,
|
||||
pub user: Option<String>,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub server: String,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Returns `(Some(config), save_target)` when all credentials are available,
|
||||
/// or `(None, save_target)` when at least one is missing — the caller can then
|
||||
/// prompt the user. `save_target` is the path where new credentials should
|
||||
/// be persisted if the user chooses to save them.
|
||||
pub fn load(args: &CliArgs) -> Result<(Option<Config>, Option<PathBuf>)> {
|
||||
let (file_cfg, save_target) = read_file_config(args.config.as_deref())?;
|
||||
|
||||
let server = args.server.clone().or(file_cfg.server);
|
||||
let user = args.user.clone().or(file_cfg.user);
|
||||
let password = args.password.clone().or(file_cfg.password);
|
||||
|
||||
let cfg = match (server, user, password) {
|
||||
(Some(server), Some(user), Some(password)) => Some(Config {
|
||||
server: server.trim_end_matches('/').to_string(),
|
||||
user,
|
||||
password,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok((cfg, Some(save_target)))
|
||||
}
|
||||
|
||||
fn read_file_config(explicit: Option<&Path>) -> Result<(FileConfig, PathBuf)> {
|
||||
let default_target = default_save_path();
|
||||
let candidates: Vec<PathBuf> = if let Some(p) = explicit {
|
||||
vec![p.to_path_buf()]
|
||||
} else {
|
||||
let mut v = vec![PathBuf::from("config.toml")];
|
||||
if let Some(dirs) = directories::ProjectDirs::from("", "", "emby-client") {
|
||||
v.push(dirs.config_dir().join("config.toml"));
|
||||
}
|
||||
v
|
||||
};
|
||||
let mut save_target = explicit
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or(default_target);
|
||||
for path in &candidates {
|
||||
if path.exists() {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("reading config {}", path.display()))?;
|
||||
let cfg: FileConfig = toml::from_str(&text)
|
||||
.with_context(|| format!("parsing config {}", path.display()))?;
|
||||
save_target = path.clone();
|
||||
return Ok((cfg, save_target));
|
||||
}
|
||||
}
|
||||
Ok((FileConfig::default(), save_target))
|
||||
}
|
||||
|
||||
fn default_save_path() -> PathBuf {
|
||||
if let Some(dirs) = directories::ProjectDirs::from("", "", "emby-client") {
|
||||
dirs.config_dir().join("config.toml")
|
||||
} else {
|
||||
PathBuf::from("config.toml")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(path: &Path, cfg: &Config) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
let file_cfg = FileConfig {
|
||||
server: Some(cfg.server.clone()),
|
||||
user: Some(cfg.user.clone()),
|
||||
password: Some(cfg.password.clone()),
|
||||
};
|
||||
let text = toml::to_string_pretty(&file_cfg).context("serializing config")?;
|
||||
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
const CLIENT_NAME: &str = "EmbyTuiClient";
|
||||
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const DEVICE_NAME: &str = "emby-client-tui";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Client {
|
||||
http: reqwest::Client,
|
||||
pub server: String,
|
||||
pub user_id: String,
|
||||
pub user_name: String,
|
||||
pub token: String,
|
||||
pub device_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthRequest<'a> {
|
||||
#[serde(rename = "Username")]
|
||||
username: &'a str,
|
||||
#[serde(rename = "Pw")]
|
||||
pw: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponse {
|
||||
#[serde(rename = "AccessToken")]
|
||||
access_token: String,
|
||||
#[serde(rename = "User")]
|
||||
user: AuthUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthUser {
|
||||
#[serde(rename = "Id")]
|
||||
id: String,
|
||||
#[serde(rename = "Name")]
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct Item {
|
||||
#[serde(rename = "Id")]
|
||||
pub id: String,
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "Type", default)]
|
||||
pub item_type: String,
|
||||
#[serde(rename = "IsFolder", default)]
|
||||
pub is_folder: bool,
|
||||
#[serde(rename = "ProductionYear", default)]
|
||||
pub year: Option<i32>,
|
||||
#[serde(rename = "RunTimeTicks", default)]
|
||||
pub runtime_ticks: Option<i64>,
|
||||
#[serde(rename = "SeriesName", default)]
|
||||
pub series_name: Option<String>,
|
||||
#[serde(rename = "IndexNumber", default)]
|
||||
pub index_number: Option<i32>,
|
||||
#[serde(rename = "ParentIndexNumber", default)]
|
||||
pub parent_index_number: Option<i32>,
|
||||
#[serde(rename = "UserData", default)]
|
||||
pub user_data: Option<UserData>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Human-readable label suitable for status lines and mpv's force-media-title.
|
||||
/// Episodes get "Series — SxxEyy — Title"; movies get "Name (Year)".
|
||||
pub fn display_title(&self) -> String {
|
||||
if self.item_type == "Episode" {
|
||||
let series = self.series_name.as_deref().unwrap_or("");
|
||||
let code = match (self.parent_index_number, self.index_number) {
|
||||
(Some(s), Some(e)) => format!("S{s:02}E{e:02}"),
|
||||
(None, Some(e)) => format!("E{e:02}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !series.is_empty() {
|
||||
parts.push(series.to_string());
|
||||
}
|
||||
if !code.is_empty() {
|
||||
parts.push(code);
|
||||
}
|
||||
parts.push(self.name.clone());
|
||||
parts.join(" — ")
|
||||
} else if let Some(year) = self.year {
|
||||
format!("{} ({year})", self.name)
|
||||
} else {
|
||||
self.name.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct UserData {
|
||||
#[serde(rename = "PlaybackPositionTicks", default)]
|
||||
pub position_ticks: i64,
|
||||
#[serde(rename = "PlayedPercentage", default)]
|
||||
pub played_percentage: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ItemsResponse {
|
||||
#[serde(rename = "Items", default)]
|
||||
items: Vec<Item>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub async fn new(cfg: &Config) -> Result<Self> {
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent(format!("{CLIENT_NAME}/{CLIENT_VERSION}"))
|
||||
.build()?;
|
||||
let device_id = Uuid::new_v4().to_string();
|
||||
|
||||
let auth_header = format!(
|
||||
"MediaBrowser Client=\"{CLIENT_NAME}\", Device=\"{DEVICE_NAME}\", DeviceId=\"{device_id}\", Version=\"{CLIENT_VERSION}\""
|
||||
);
|
||||
|
||||
let url = format!("{}/Users/AuthenticateByName", cfg.server);
|
||||
let resp = http
|
||||
.post(&url)
|
||||
.header("X-Emby-Authorization", &auth_header)
|
||||
.json(&AuthRequest {
|
||||
username: &cfg.user,
|
||||
pw: &cfg.password,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("POST {url}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("authentication failed: HTTP {status}: {body}"));
|
||||
}
|
||||
let parsed: AuthResponse = resp.json().await.context("parsing auth response")?;
|
||||
|
||||
Ok(Self {
|
||||
http,
|
||||
server: cfg.server.clone(),
|
||||
user_id: parsed.user.id,
|
||||
user_name: parsed.user.name,
|
||||
token: parsed.access_token,
|
||||
device_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn headers(&self) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
HeaderName::from_static("x-emby-token"),
|
||||
HeaderValue::from_str(&self.token).expect("token is ascii"),
|
||||
);
|
||||
h
|
||||
}
|
||||
|
||||
pub async fn views(&self) -> Result<Vec<Item>> {
|
||||
let url = format!("{}/Users/{}/Views", self.server, self.user_id);
|
||||
let resp: ItemsResponse = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.headers())
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(resp.items)
|
||||
}
|
||||
|
||||
pub async fn items(&self, parent_id: &str) -> Result<Vec<Item>> {
|
||||
let url = format!("{}/Users/{}/Items", self.server, self.user_id);
|
||||
let resp: ItemsResponse = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.headers())
|
||||
.query(&[
|
||||
("ParentId", parent_id),
|
||||
// ParentIndexNumber+IndexNumber gives correct season/episode order
|
||||
// (and season order inside a series); SortName is the fallback for
|
||||
// movies, libraries, etc. where index numbers are absent.
|
||||
("SortBy", "ParentIndexNumber,IndexNumber,SortName"),
|
||||
("SortOrder", "Ascending"),
|
||||
(
|
||||
"Fields",
|
||||
"ProductionYear,RunTimeTicks,SeriesName,UserData",
|
||||
),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(resp.items)
|
||||
}
|
||||
|
||||
/// In-progress items across all libraries (Emby "Continue Watching").
|
||||
pub async fn resume_items(&self, limit: u32) -> Result<Vec<Item>> {
|
||||
let url = format!("{}/Users/{}/Items/Resume", self.server, self.user_id);
|
||||
let limit_s = limit.to_string();
|
||||
let resp: ItemsResponse = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.headers())
|
||||
.query(&[
|
||||
("Limit", limit_s.as_str()),
|
||||
("Recursive", "true"),
|
||||
("MediaTypes", "Video"),
|
||||
(
|
||||
"Fields",
|
||||
"ProductionYear,RunTimeTicks,SeriesName,UserData",
|
||||
),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(resp.items)
|
||||
}
|
||||
|
||||
pub fn stream_url(&self, item_id: &str) -> String {
|
||||
format!(
|
||||
"{}/Videos/{}/stream?Static=true&api_key={}&DeviceId={}",
|
||||
self.server, item_id, self.token, self.device_id
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a playback reporter for a specific item. Cheap; reuses the
|
||||
/// async HTTP client. Methods are sync — they bridge into the tokio
|
||||
/// runtime internally so they can be called from the libmpv event loop.
|
||||
pub fn reporter(&self, item_id: String) -> Reporter {
|
||||
Reporter {
|
||||
http: self.http.clone(),
|
||||
server: self.server.clone(),
|
||||
token: self.token.clone(),
|
||||
item_id,
|
||||
play_session_id: Uuid::new_v4().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports playback progress back to Emby so Continue Watching reflects
|
||||
/// what was played in this TUI. Cheap to construct per playback session.
|
||||
pub struct Reporter {
|
||||
http: reqwest::Client,
|
||||
server: String,
|
||||
token: String,
|
||||
item_id: String,
|
||||
play_session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct StartBody<'a> {
|
||||
#[serde(rename = "ItemId")]
|
||||
item_id: &'a str,
|
||||
#[serde(rename = "MediaSourceId")]
|
||||
media_source_id: &'a str,
|
||||
#[serde(rename = "PlaySessionId")]
|
||||
play_session_id: &'a str,
|
||||
#[serde(rename = "PlayMethod")]
|
||||
play_method: &'static str,
|
||||
#[serde(rename = "CanSeek")]
|
||||
can_seek: bool,
|
||||
#[serde(rename = "PositionTicks")]
|
||||
position_ticks: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ProgressBody<'a> {
|
||||
#[serde(rename = "ItemId")]
|
||||
item_id: &'a str,
|
||||
#[serde(rename = "MediaSourceId")]
|
||||
media_source_id: &'a str,
|
||||
#[serde(rename = "PlaySessionId")]
|
||||
play_session_id: &'a str,
|
||||
#[serde(rename = "PlayMethod")]
|
||||
play_method: &'static str,
|
||||
#[serde(rename = "PositionTicks")]
|
||||
position_ticks: i64,
|
||||
#[serde(rename = "IsPaused")]
|
||||
is_paused: bool,
|
||||
#[serde(rename = "EventName")]
|
||||
event_name: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct StoppedBody<'a> {
|
||||
#[serde(rename = "ItemId")]
|
||||
item_id: &'a str,
|
||||
#[serde(rename = "MediaSourceId")]
|
||||
media_source_id: &'a str,
|
||||
#[serde(rename = "PlaySessionId")]
|
||||
play_session_id: &'a str,
|
||||
#[serde(rename = "PositionTicks")]
|
||||
position_ticks: i64,
|
||||
}
|
||||
|
||||
impl Reporter {
|
||||
async fn post_async<T: Serialize>(&self, path: &str, body: &T) -> Result<()> {
|
||||
let url = format!("{}{path}", self.server);
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("X-Emby-Token", &self.token)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("POST {url}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Emby reporting failed: HTTP {}", resp.status()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bridge from the sync libmpv event loop into the async runtime.
|
||||
/// Requires being called from a thread that participates in a
|
||||
/// multi-thread tokio runtime (true when invoked from `#[tokio::main]`).
|
||||
fn post<T: Serialize>(&self, path: &str, body: &T) -> Result<()> {
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.post_async(path, body))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn started(&self, position_ticks: i64) -> Result<()> {
|
||||
self.post(
|
||||
"/Sessions/Playing",
|
||||
&StartBody {
|
||||
item_id: &self.item_id,
|
||||
media_source_id: &self.item_id,
|
||||
play_session_id: &self.play_session_id,
|
||||
play_method: "DirectStream",
|
||||
can_seek: true,
|
||||
position_ticks,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn progress(&self, position_ticks: i64, is_paused: bool) -> Result<()> {
|
||||
self.post(
|
||||
"/Sessions/Playing/Progress",
|
||||
&ProgressBody {
|
||||
item_id: &self.item_id,
|
||||
media_source_id: &self.item_id,
|
||||
play_session_id: &self.play_session_id,
|
||||
play_method: "DirectStream",
|
||||
position_ticks,
|
||||
is_paused,
|
||||
event_name: "TimeUpdate",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn stopped(&self, position_ticks: i64) -> Result<()> {
|
||||
self.post(
|
||||
"/Sessions/Playing/Stopped",
|
||||
&StoppedBody {
|
||||
item_id: &self.item_id,
|
||||
media_source_id: &self.item_id,
|
||||
play_session_id: &self.play_session_id,
|
||||
position_ticks,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use std::io::stdout;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::{self, Config};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Field {
|
||||
Server = 0,
|
||||
User = 1,
|
||||
Password = 2,
|
||||
SaveToggle = 3,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Field::Server => Field::User,
|
||||
Field::User => Field::Password,
|
||||
Field::Password => Field::SaveToggle,
|
||||
Field::SaveToggle => Field::Server,
|
||||
}
|
||||
}
|
||||
fn prev(self) -> Self {
|
||||
match self {
|
||||
Field::Server => Field::SaveToggle,
|
||||
Field::User => Field::Server,
|
||||
Field::Password => Field::User,
|
||||
Field::SaveToggle => Field::Password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Form {
|
||||
server: String,
|
||||
user: String,
|
||||
password: String,
|
||||
save_to_disk: bool,
|
||||
focus: Field,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub fn prompt(save_target: Option<&Path>) -> Result<Config> {
|
||||
enable_raw_mode()?;
|
||||
let mut out = stdout();
|
||||
execute!(out, EnterAlternateScreen)?;
|
||||
let mut term = Terminal::new(CrosstermBackend::new(out))?;
|
||||
|
||||
let mut form = Form {
|
||||
server: String::new(),
|
||||
user: String::new(),
|
||||
password: String::new(),
|
||||
save_to_disk: save_target.is_some(),
|
||||
focus: Field::Server,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let result = run(&mut term, &mut form);
|
||||
|
||||
disable_raw_mode().ok();
|
||||
execute!(term.backend_mut(), LeaveAlternateScreen).ok();
|
||||
term.show_cursor().ok();
|
||||
|
||||
let cfg = result?;
|
||||
if form.save_to_disk
|
||||
&& let Some(path) = save_target
|
||||
{
|
||||
if let Err(e) = config::save(path, &cfg) {
|
||||
eprintln!("warning: failed to save config to {}: {e}", path.display());
|
||||
} else {
|
||||
eprintln!("saved credentials to {}", path.display());
|
||||
}
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
fn run<B: ratatui::backend::Backend>(
|
||||
term: &mut Terminal<B>,
|
||||
form: &mut Form,
|
||||
) -> Result<Config> {
|
||||
loop {
|
||||
term.draw(|f| draw(f, form))?;
|
||||
if !event::poll(Duration::from_millis(200))? {
|
||||
continue;
|
||||
}
|
||||
let Event::Key(key) = event::read()? else {
|
||||
continue;
|
||||
};
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
|
||||
{
|
||||
return Err(anyhow!("login canceled by user"));
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => return Err(anyhow!("login canceled by user")),
|
||||
KeyCode::Tab | KeyCode::Down => form.focus = form.focus.next(),
|
||||
KeyCode::BackTab | KeyCode::Up => form.focus = form.focus.prev(),
|
||||
KeyCode::Enter => {
|
||||
if form.focus == Field::SaveToggle {
|
||||
form.save_to_disk = !form.save_to_disk;
|
||||
continue;
|
||||
}
|
||||
// Validate
|
||||
let server = form.server.trim().trim_end_matches('/').to_string();
|
||||
let user = form.user.trim().to_string();
|
||||
if server.is_empty() {
|
||||
form.error = Some("Server URL required".into());
|
||||
form.focus = Field::Server;
|
||||
continue;
|
||||
}
|
||||
if !(server.starts_with("http://") || server.starts_with("https://")) {
|
||||
form.error = Some("Server URL must start with http:// or https://".into());
|
||||
form.focus = Field::Server;
|
||||
continue;
|
||||
}
|
||||
if user.is_empty() {
|
||||
form.error = Some("Username required".into());
|
||||
form.focus = Field::User;
|
||||
continue;
|
||||
}
|
||||
return Ok(Config {
|
||||
server,
|
||||
user,
|
||||
password: form.password.clone(),
|
||||
});
|
||||
}
|
||||
KeyCode::Char(' ') if form.focus == Field::SaveToggle => {
|
||||
form.save_to_disk = !form.save_to_disk;
|
||||
}
|
||||
KeyCode::Char(c) => match form.focus {
|
||||
Field::Server => form.server.push(c),
|
||||
Field::User => form.user.push(c),
|
||||
Field::Password => form.password.push(c),
|
||||
Field::SaveToggle => {}
|
||||
},
|
||||
KeyCode::Backspace => match form.focus {
|
||||
Field::Server => {
|
||||
form.server.pop();
|
||||
}
|
||||
Field::User => {
|
||||
form.user.pop();
|
||||
}
|
||||
Field::Password => {
|
||||
form.password.pop();
|
||||
}
|
||||
Field::SaveToggle => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(f: &mut ratatui::Frame, form: &Form) {
|
||||
let area = centered_rect(70, 60, f.area());
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Emby Login ")
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(block.clone(), area);
|
||||
|
||||
let inner = block.inner(area);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(1)
|
||||
.constraints([
|
||||
Constraint::Length(1), // hint
|
||||
Constraint::Length(3), // server
|
||||
Constraint::Length(3), // user
|
||||
Constraint::Length(3), // password
|
||||
Constraint::Length(2), // save toggle
|
||||
Constraint::Min(1), // error / help
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let hint = Paragraph::new(Span::styled(
|
||||
"No credentials found — enter them below.",
|
||||
Style::default().fg(Color::Gray),
|
||||
));
|
||||
f.render_widget(hint, rows[0]);
|
||||
|
||||
f.render_widget(field_widget("Server", &form.server, form.focus == Field::Server, false), rows[1]);
|
||||
f.render_widget(field_widget("User", &form.user, form.focus == Field::User, false), rows[2]);
|
||||
f.render_widget(field_widget("Password", &form.password, form.focus == Field::Password, true), rows[3]);
|
||||
|
||||
let toggle_marker = if form.save_to_disk { "[x]" } else { "[ ]" };
|
||||
let toggle_style = if form.focus == Field::SaveToggle {
|
||||
Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
let toggle = Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {toggle_marker} "), toggle_style),
|
||||
Span::raw("Save to config.toml (Enter/Space to toggle)"),
|
||||
]));
|
||||
f.render_widget(toggle, rows[4]);
|
||||
|
||||
let footer = if let Some(err) = &form.error {
|
||||
Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red)))
|
||||
} else {
|
||||
Line::from(Span::styled(
|
||||
"Tab/↓ next · Shift+Tab/↑ prev · Enter submit · Esc cancel",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
))
|
||||
};
|
||||
f.render_widget(Paragraph::new(footer), rows[5]);
|
||||
}
|
||||
|
||||
fn field_widget<'a>(label: &'a str, value: &'a str, focused: bool, mask: bool) -> Paragraph<'a> {
|
||||
let display: String = if mask {
|
||||
"*".repeat(value.chars().count())
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
let cursor = if focused { "_" } else { "" };
|
||||
let border_style = if focused {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
Paragraph::new(format!("{display}{cursor}"))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.title(format!(" {label} ")),
|
||||
)
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
Constraint::Percentage(percent_y),
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
])
|
||||
.split(r);
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
Constraint::Percentage(percent_x),
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
])
|
||||
.split(popup_layout[1])[1]
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod emby;
|
||||
mod login;
|
||||
mod player;
|
||||
mod ui;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = config::CliArgs::parse();
|
||||
let (cfg, save_target) = config::load(&args)?;
|
||||
|
||||
let cfg = match cfg {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
// Interactive credential prompt in the TUI
|
||||
login::prompt(save_target.as_deref())?
|
||||
}
|
||||
};
|
||||
|
||||
let client = emby::Client::new(&cfg).await?;
|
||||
let mut app = app::App::new(client).await?;
|
||||
ui::run(&mut app).await?;
|
||||
Ok(())
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use libmpv2::{Mpv, events::Event};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::emby::Reporter;
|
||||
|
||||
pub struct Player {
|
||||
mpv: Mpv,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn new() -> Result<Self> {
|
||||
let mpv = Mpv::new().map_err(|e| anyhow!("mpv init failed: {e}"))?;
|
||||
for (k, v) in [
|
||||
("force-window", "yes"),
|
||||
("input-default-bindings", "yes"),
|
||||
("input-vo-keyboard", "yes"),
|
||||
("osc", "yes"),
|
||||
("keep-open", "no"),
|
||||
("terminal", "no"),
|
||||
] {
|
||||
let _ = mpv.set_property(k, v);
|
||||
}
|
||||
Ok(Self { mpv })
|
||||
}
|
||||
|
||||
/// Loads `url` and blocks until playback ends or the player shuts down.
|
||||
/// `title` is displayed in mpv's OSC/window title (instead of the raw URL).
|
||||
/// `start_seconds` resumes from a saved position when > 0.
|
||||
/// `reporter`, if present, posts Start/Progress/Stopped events to Emby.
|
||||
pub fn play_blocking(
|
||||
&mut self,
|
||||
url: &str,
|
||||
title: &str,
|
||||
start_seconds: f64,
|
||||
reporter: Option<&Reporter>,
|
||||
) -> Result<()> {
|
||||
let seek_target = format!("{:.3}", start_seconds.max(0.0));
|
||||
// mpv's `start` option is consumed by the next loadfile call. It's a time
|
||||
// spec parsed as a string — passing an f64 fails with PROPERTY_FORMAT and
|
||||
// is silently dropped, so always go through the string path.
|
||||
if start_seconds > 0.5 {
|
||||
let _ = self.mpv.set_property("start", seek_target.as_str());
|
||||
}
|
||||
// Overrides the title mpv would otherwise compute from the URL.
|
||||
let _ = self.mpv.set_property("force-media-title", title);
|
||||
|
||||
self.mpv
|
||||
.command("loadfile", &[url])
|
||||
.map_err(|e| anyhow!("mpv loadfile failed: {e}"))?;
|
||||
|
||||
let start_ticks = seconds_to_ticks(start_seconds);
|
||||
if let Some(r) = reporter {
|
||||
let _ = r.started(start_ticks);
|
||||
}
|
||||
|
||||
// Fallback: if mpv's `start` was somehow not honored, seek once on
|
||||
// FileLoaded. Some libmpv versions / option types reject the early
|
||||
// set_property and we want the resume to be reliable.
|
||||
let mut needs_seek = start_seconds > 0.5;
|
||||
|
||||
let mut last_pos_ticks = start_ticks;
|
||||
let mut last_report = Instant::now();
|
||||
let report_every = Duration::from_secs(10);
|
||||
|
||||
loop {
|
||||
// Borrow event_context only for the wait, so we can re-borrow `self.mpv`
|
||||
// for property reads / commands after each tick.
|
||||
let mut do_seek = false;
|
||||
let break_now;
|
||||
{
|
||||
let ctx = self.mpv.event_context_mut();
|
||||
match ctx.wait_event(1.0) {
|
||||
Some(Ok(Event::EndFile(_))) | Some(Ok(Event::Shutdown)) => {
|
||||
break_now = true;
|
||||
}
|
||||
Some(Ok(Event::FileLoaded)) => {
|
||||
break_now = false;
|
||||
if needs_seek {
|
||||
do_seek = true;
|
||||
needs_seek = false;
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
if let Some(r) = reporter {
|
||||
let _ = r.stopped(last_pos_ticks);
|
||||
}
|
||||
return Err(anyhow!("mpv event error: {e}"));
|
||||
}
|
||||
_ => {
|
||||
break_now = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if break_now {
|
||||
break;
|
||||
}
|
||||
if do_seek {
|
||||
let _ = self
|
||||
.mpv
|
||||
.command("seek", &[seek_target.as_str(), "absolute"]);
|
||||
}
|
||||
|
||||
// Update position from mpv. time-pos is only valid once playback started.
|
||||
if let Ok(pos) = self.mpv.get_property::<f64>("time-pos") {
|
||||
last_pos_ticks = seconds_to_ticks(pos);
|
||||
}
|
||||
|
||||
if let Some(r) = reporter
|
||||
&& last_report.elapsed() >= report_every
|
||||
{
|
||||
let paused = self.mpv.get_property::<bool>("pause").unwrap_or(false);
|
||||
let _ = r.progress(last_pos_ticks, paused);
|
||||
last_report = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(r) = reporter {
|
||||
let _ = r.stopped(last_pos_ticks);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn seconds_to_ticks(seconds: f64) -> i64 {
|
||||
if seconds <= 0.0 || !seconds.is_finite() {
|
||||
0
|
||||
} else {
|
||||
(seconds * 10_000_000.0) as i64
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
|
||||
use std::io::{Stdout, stdout};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app::{App, RESUME_VIRTUAL_ID, View};
|
||||
use crate::emby::Item;
|
||||
|
||||
type Term = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
pub async fn run(app: &mut App) -> Result<()> {
|
||||
let mut term = setup_terminal()?;
|
||||
let res = run_loop(&mut term, app).await;
|
||||
let _ = restore_terminal(&mut term);
|
||||
res
|
||||
}
|
||||
|
||||
fn setup_terminal() -> Result<Term> {
|
||||
enable_raw_mode()?;
|
||||
let mut out = stdout();
|
||||
execute!(out, EnterAlternateScreen)?;
|
||||
Ok(Terminal::new(CrosstermBackend::new(out))?)
|
||||
}
|
||||
|
||||
fn restore_terminal(term: &mut Term) -> Result<()> {
|
||||
disable_raw_mode()?;
|
||||
execute!(term.backend_mut(), LeaveAlternateScreen)?;
|
||||
term.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_loop(term: &mut Term, app: &mut App) -> Result<()> {
|
||||
loop {
|
||||
term.draw(|f| draw(f, app))?;
|
||||
if !event::poll(Duration::from_millis(200))? {
|
||||
continue;
|
||||
}
|
||||
let Event::Key(key) = event::read()? else {
|
||||
continue;
|
||||
};
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') => match app.view {
|
||||
View::Items => app.back().await?,
|
||||
View::Libraries => break,
|
||||
},
|
||||
KeyCode::Esc => match app.view {
|
||||
View::Items => app.back().await?,
|
||||
View::Libraries => break,
|
||||
},
|
||||
KeyCode::Char('Q') => break,
|
||||
KeyCode::Down | KeyCode::Char('j') => app.move_down(),
|
||||
KeyCode::Up | KeyCode::Char('k') => app.move_up(),
|
||||
KeyCode::PageDown => app.page_down(),
|
||||
KeyCode::PageUp => app.page_up(),
|
||||
KeyCode::Home => app.list_state.select(Some(0)),
|
||||
KeyCode::End => {
|
||||
let len = app.current_list().len();
|
||||
if len > 0 {
|
||||
app.list_state.select(Some(len - 1));
|
||||
}
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
|
||||
let action = app.activate().await?;
|
||||
if let Some(action) = action {
|
||||
// Tear down TUI before mpv takes over (it spawns its own window
|
||||
// but we still want a clean terminal in case of -vo tct etc.).
|
||||
restore_terminal(term)?;
|
||||
let r = app.play(&action);
|
||||
*term = setup_terminal()?;
|
||||
term.clear()?;
|
||||
if let Err(e) = r {
|
||||
app.status = format!("playback error: {e}");
|
||||
}
|
||||
// Resume positions likely changed; refresh in the background view.
|
||||
app.refresh_resume().await;
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace | KeyCode::Char('h') | KeyCode::Left => app.back().await?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw(f: &mut ratatui::Frame, app: &mut App) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
// Header
|
||||
let header_text = format!(
|
||||
" {} @ {} ",
|
||||
app.client.user_name,
|
||||
app.client.server.trim_start_matches("http://").trim_start_matches("https://"),
|
||||
);
|
||||
let header = Paragraph::new(Line::from(vec![
|
||||
Span::styled(header_text, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" "),
|
||||
Span::styled(app.breadcrumb_path(), Style::default().fg(Color::Gray)),
|
||||
]))
|
||||
.block(Block::default().borders(Borders::ALL).title(" Emby "));
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// List
|
||||
let title = match app.view {
|
||||
View::Libraries => "Libraries".to_string(),
|
||||
View::Items => app
|
||||
.current_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Items".into()),
|
||||
};
|
||||
|
||||
let items: Vec<ListItem> = app
|
||||
.current_list()
|
||||
.iter()
|
||||
.map(format_item)
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(format!(" {title} ")))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol(">> ");
|
||||
|
||||
f.render_stateful_widget(list, chunks[1], &mut app.list_state);
|
||||
|
||||
// Status bar
|
||||
let help = "↑/↓ move · Enter open/play · Backspace back · q quit (Q force)";
|
||||
let status = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
app.status.clone(),
|
||||
Style::default().fg(Color::Green),
|
||||
)),
|
||||
Line::from(Span::styled(help, Style::default().fg(Color::DarkGray))),
|
||||
])
|
||||
.block(Block::default().borders(Borders::ALL));
|
||||
f.render_widget(status, chunks[2]);
|
||||
}
|
||||
|
||||
fn format_item(it: &Item) -> ListItem<'static> {
|
||||
if it.id == RESUME_VIRTUAL_ID {
|
||||
let style = Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
return ListItem::new(Line::from(vec![
|
||||
Span::styled("[>>] ", style),
|
||||
Span::styled(it.name.clone(), style),
|
||||
]));
|
||||
}
|
||||
|
||||
let icon = if it.is_folder {
|
||||
"[D]"
|
||||
} else {
|
||||
match it.item_type.as_str() {
|
||||
"Movie" => "[M]",
|
||||
"Episode" => "[E]",
|
||||
"Series" => "[S]",
|
||||
"Season" => "[s]",
|
||||
"Audio" => "[A]",
|
||||
_ => "[*]",
|
||||
}
|
||||
};
|
||||
|
||||
// Episode lines: "Series — SxxEyy — Title"; movies include "(Year)".
|
||||
let mut line = format!("{icon} {}", it.display_title());
|
||||
if let Some(ticks) = it.runtime_ticks {
|
||||
let mins = ticks / 10_000_000 / 60;
|
||||
if mins > 0 {
|
||||
line.push_str(&format!(" [{mins}m]"));
|
||||
}
|
||||
}
|
||||
|
||||
// Progress badge for in-progress items.
|
||||
if let Some(ud) = &it.user_data {
|
||||
if let Some(pct) = ud.played_percentage {
|
||||
if pct > 0.0 && pct < 100.0 {
|
||||
line.push_str(&format!(" ({:.0}%)", pct));
|
||||
}
|
||||
} else if ud.position_ticks > 0
|
||||
&& let Some(rt) = it.runtime_ticks
|
||||
&& rt > 0
|
||||
{
|
||||
let pct = (ud.position_ticks as f64 / rt as f64) * 100.0;
|
||||
if pct > 0.0 && pct < 100.0 {
|
||||
line.push_str(&format!(" ({:.0}%)", pct));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListItem::new(line)
|
||||
}
|
||||
Reference in new issue
Block a user