yt_tui/app.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Enzo Costa Fuke
3
4//! Central application state: search results, history, the mpv-backed
5//! playback queue, and the glue between them.
6//!
7//! [`App`] is the state machine the binary's key handler mutates and that
8//! [`crate::ui::draw`] renders on every frame. It owns the single
9//! [`crate::mpv::Mpv`] instance for the lifetime of the process.
10
11use crate::history::{self, History};
12use crate::mpv::{LoadMode, Mpv};
13use crate::yt::{self, Video};
14use anyhow::Result;
15use tokio::sync::mpsc;
16
17/// Whether the user is currently typing a search query or browsing a list.
18#[derive(PartialEq)]
19pub enum Mode {
20 /// The search box is focused; keystrokes are appended to [`App::query`].
21 Searching,
22 /// The main list (search results or history) is focused; keystrokes
23 /// navigate and act on it.
24 Browsing,
25}
26
27/// Which list is currently shown/navigated in the main panel.
28#[derive(PartialEq, Clone, Copy)]
29pub enum ListSource {
30 /// Showing [`App::results`], the most recent search.
31 Search,
32 /// Showing [`App::history`], the persisted watch/queue history.
33 History,
34}
35
36/// Whether selecting a video should replace mpv's playlist or just join
37/// the queue (loadfile ... append-play).
38#[derive(Clone, Copy)]
39enum PlayIntent {
40 Replace,
41 Enqueue,
42}
43
44/// All state needed to drive the TUI: what's on screen, what's playing,
45/// and the handle to the single long-lived `mpv` process.
46pub struct App {
47 /// Whether the search box or the list is currently focused.
48 pub mode: Mode,
49 /// Which list ([`App::results`] or [`App::history`]) is being shown.
50 pub list_source: ListSource,
51 /// Current contents of the search box.
52 pub query: String,
53 /// Results of the most recent search, filled in incrementally as
54 /// `yt-dlp` streams them back.
55 pub results: Vec<Video>,
56 /// Persisted history of played/queued videos, loaded on startup and
57 /// saved back to disk after every selection.
58 pub history: History,
59 /// Index of the highlighted item in the currently active list.
60 pub selected: usize,
61
62 /// Videos sent to mpv so far, in the order mpv's own playlist should
63 /// have them (mirrors mpv's internal playlist).
64 pub queue: Vec<Video>,
65 /// mpv's current `playlist-pos`, polled every tick; `None` before the
66 /// first successful poll.
67 pub playlist_pos: Option<i64>,
68 /// The video mpv is currently on, if any.
69 pub now_playing: Option<Video>,
70
71 /// Current playback position in seconds, polled from mpv every tick.
72 pub position: Option<f64>,
73 /// Duration of the current track in seconds, polled from mpv every tick.
74 pub duration_secs: Option<f64>,
75 /// Whether mpv is currently paused.
76 pub paused: bool,
77 /// Whether the video track is currently enabled (vs. audio-only).
78 pub video_enabled: bool,
79
80 /// Free-form message shown in the status bar.
81 pub status: String,
82 /// Set to `true` to make the main loop exit on the next iteration.
83 pub should_quit: bool,
84
85 /// The single, long-lived mpv instance this app controls.
86 pub mpv: Mpv,
87
88 search_tx: mpsc::UnboundedSender<Video>,
89 /// Receiving end of the channel `yt-dlp` search results stream over;
90 /// drained into [`App::results`] by [`App::drain_search_results`].
91 pub search_rx: mpsc::UnboundedReceiver<Video>,
92}
93
94impl App {
95 /// Loads the saved history and spawns the single mpv instance used
96 /// for the lifetime of the app. Fails only if mpv itself can't start
97 /// (see [`crate::mpv::Mpv::spawn`]).
98 pub async fn new() -> Result<Self> {
99 let (search_tx, search_rx) = mpsc::unbounded_channel();
100 let history = history::load().await;
101
102 Ok(Self {
103 mode: Mode::Searching,
104 list_source: ListSource::Search,
105 query: String::new(),
106 results: Vec::new(),
107 history,
108 selected: 0,
109 queue: Vec::new(),
110 playlist_pos: None,
111 now_playing: None,
112 position: None,
113 duration_secs: None,
114 paused: false,
115 video_enabled: false,
116 status: "Type something and press Enter to search (H to view history)".to_string(),
117 should_quit: false,
118 mpv: Mpv::spawn().await?,
119 search_tx,
120 search_rx,
121 })
122 }
123
124 /// List currently visible for navigation (search results OR history).
125 fn active_list(&self) -> &[Video] {
126 match self.list_source {
127 ListSource::Search => &self.results,
128 ListSource::History => &self.history.entries,
129 }
130 }
131
132 /// Drains the search channel; called on every turn of the main loop.
133 pub fn drain_search_results(&mut self) {
134 while let Ok(video) = self.search_rx.try_recv() {
135 self.results.push(video);
136 }
137 }
138
139 /// Kicks off a new search for [`App::query`] in the background,
140 /// clearing any previous results and switching to
141 /// [`ListSource::Search`]. Does nothing if the query is blank.
142 pub fn start_search(&mut self) {
143 if self.query.trim().is_empty() {
144 return;
145 }
146 self.results.clear();
147 self.list_source = ListSource::Search;
148 self.selected = 0;
149 self.status = format!("Searching \"{}\"...", self.query);
150 self.mode = Mode::Browsing;
151
152 let query = self.query.clone();
153 let tx = self.search_tx.clone();
154 tokio::spawn(async move {
155 if let Err(e) = yt::search_stream(query, 15, tx).await {
156 eprintln!("search error: {e:#}");
157 }
158 });
159 }
160
161 /// Toggles between viewing the last search's results and the saved
162 /// history, without needing to search YouTube again.
163 pub fn toggle_history_view(&mut self) {
164 self.list_source = match self.list_source {
165 ListSource::Search => ListSource::History,
166 ListSource::History => ListSource::Search,
167 };
168 self.selected = 0;
169 self.mode = Mode::Browsing;
170 self.status = match self.list_source {
171 ListSource::Search => "Showing search results".to_string(),
172 ListSource::History => format!("History ({} items)", self.history.entries.len()),
173 };
174 }
175
176 /// Moves the selection cursor in the active list by `delta`,
177 /// clamped to the list's bounds. Negative values move up.
178 pub fn move_selection(&mut self, delta: i32) {
179 let len = self.active_list().len() as i32;
180 if len == 0 {
181 return;
182 }
183 let new_index = (self.selected as i32 + delta).clamp(0, len - 1);
184 self.selected = new_index as usize;
185 }
186
187 /// Enter/'a': joins mpv's queue (append-play) without interrupting
188 /// whatever's already playing; if mpv is idle, starts playing right
189 /// away. This is the default behavior when selecting a video.
190 pub async fn enqueue_selected(&mut self) {
191 self.dispatch_selected(PlayIntent::Enqueue).await;
192 }
193
194 /// 'r': plays now, replacing mpv's entire playlist (clears the queue).
195 pub async fn replace_selected(&mut self) {
196 self.dispatch_selected(PlayIntent::Replace).await;
197 }
198
199 async fn dispatch_selected(&mut self, intent: PlayIntent) {
200 let Some(video) = self.active_list().get(self.selected).cloned() else {
201 return;
202 };
203
204 // Only reset position/"loading" status if this selection is about
205 // to start playing immediately — an 'a' behind something already
206 // playing shouldn't touch the current track's progress.
207 let will_start_now = matches!(intent, PlayIntent::Replace) || self.now_playing.is_none();
208
209 let mode = match intent {
210 PlayIntent::Replace => {
211 self.queue.clear();
212 self.queue.push(video.clone());
213 self.playlist_pos = Some(0);
214 self.now_playing = Some(video.clone());
215 LoadMode::Replace
216 }
217 PlayIntent::Enqueue => {
218 self.queue.push(video.clone());
219 if self.now_playing.is_none() {
220 self.now_playing = Some(video.clone());
221 }
222 LoadMode::AppendPlay
223 }
224 };
225
226 if will_start_now {
227 self.paused = false;
228 self.position = None;
229 self.duration_secs = None;
230 self.status = format!("Loading \"{}\"...", video.title);
231 } else {
232 self.status = format!("Added to queue: \"{}\"", video.title);
233 }
234
235 // Saved sequentially (await, not spawn): if we fired this off as a
236 // detached task, several quick 'a' presses would launch concurrent
237 // writes with no guaranteed order — a more complete one could
238 // finish writing BEFORE an older (incomplete) one, which would
239 // then overwrite the file last and silently drop entries.
240 // Awaiting here serializes the writes in the same order the keys
241 // were pressed.
242 history::push_entry(&mut self.history, video.clone());
243 if let Err(e) = history::save(&self.history).await {
244 self.status = format!("{} (warning: failed to save history: {e})", self.status);
245 }
246
247 // We pass the YouTube URL straight to mpv, which resolves it via
248 // its built-in yt-dlp hook (ytdl_hook). This avoids reimplementing
249 // format selection/combination ourselves — the hook already knows
250 // how to mux video+audio when they come as separate streams and
251 // use the right headers, which manually resolving with
252 // `yt-dlp -g` didn't guarantee.
253 if let Err(e) = self.mpv.load(&video.url(), mode).await {
254 self.status = format!("Error loading into mpv: {e}");
255 }
256 }
257
258 /// Clears the entire queue and stops playback.
259 pub async fn clear_queue(&mut self) -> Result<()> {
260 self.mpv.stop().await?;
261 self.queue.clear();
262 self.playlist_pos = None;
263 self.now_playing = None;
264 self.position = None;
265 self.duration_secs = None;
266 self.status = "Queue cleared".to_string();
267 Ok(())
268 }
269
270 /// Toggles play/pause on the currently loaded track.
271 pub async fn toggle_pause(&mut self) -> Result<()> {
272 self.mpv.toggle_pause().await?;
273 self.paused = !self.paused;
274 Ok(())
275 }
276
277 /// Seeks by `seconds` relative to the current position (negative
278 /// seeks backward).
279 pub async fn seek(&mut self, seconds: f64) -> Result<()> {
280 self.mpv.seek(seconds).await
281 }
282
283 /// Skips to the next track in mpv's playlist, if any.
284 pub async fn next_track(&mut self) -> Result<()> {
285 self.mpv.playlist_next().await
286 }
287
288 /// Skips to the previous track in mpv's playlist, if any.
289 pub async fn prev_track(&mut self) -> Result<()> {
290 self.mpv.playlist_prev().await
291 }
292
293 /// Toggles video on/off at runtime (without reopening mpv or the
294 /// stream) — works because the format resolved by mpv's hook already
295 /// includes the video track by default; this just enables/disables it.
296 pub async fn toggle_video(&mut self) -> Result<()> {
297 self.video_enabled = !self.video_enabled;
298 self.mpv.set_video_enabled(self.video_enabled).await?;
299 self.status = if self.video_enabled {
300 "Video enabled".to_string()
301 } else {
302 "Audio-only mode".to_string()
303 };
304 Ok(())
305 }
306
307 /// Called on every tick of the main loop to keep position, duration,
308 /// playlist index, and pause state synced with the real mpv (via
309 /// get_property over the IPC socket).
310 pub async fn refresh_progress(&mut self) {
311 if self.now_playing.is_none() {
312 return;
313 }
314
315 let had_position = self.position.is_some();
316 if let Ok(pos) = self.mpv.get_property_f64("time-pos").await {
317 if !had_position && pos.is_some() {
318 self.status = "Playing".to_string();
319 }
320 self.position = pos;
321 }
322 if let Ok(dur) = self.mpv.get_property_f64("duration").await {
323 self.duration_secs = dur;
324 }
325 if let Ok(idx) = self.mpv.get_property_i64("playlist-pos").await {
326 self.playlist_pos = idx;
327 // Keeps `now_playing` aligned with the queue's current track.
328 if let Some(i) = idx {
329 if let Some(v) = self.queue.get(i as usize) {
330 if self.now_playing.as_ref() != Some(v) {
331 self.now_playing = Some(v.clone());
332 }
333 }
334 }
335 }
336 if let Ok(Some(paused)) = self.mpv.get_property_bool("pause").await {
337 self.paused = paused;
338 }
339 }
340}