Skip to main content

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}