// yt - A fully featured command line YouTube client // // Copyright (C) 2024 Benedikt Peetz // SPDX-License-Identifier: GPL-3.0-or-later // // This file is part of Yt. // // You should have received a copy of the License along with this program. // If not, see . use std::{env::current_exe, mem, time::Duration, usize}; use anyhow::{bail, Result}; use libmpv2::{events::Event, EndFileReason, Mpv}; use log::{debug, error, info, warn}; use tokio::{process::Command, time}; use crate::{ app::App, comments::get_comments, storage::video_database::{ extractor_hash::ExtractorHash, getters::{get_video_by_hash, get_video_mpv_opts, get_videos}, setters::{set_state_change, set_video_watched}, VideoStatus, }, }; pub struct MpvEventHandler { currently_playing_index: Option, current_playlist_position: usize, current_playlist: Vec, } impl MpvEventHandler { pub fn from_playlist(playlist: Vec) -> Self { Self { currently_playing_index: None, current_playlist: playlist, current_playlist_position: 0, } } /// Checks, whether new videos are ready to be played pub async fn possibly_add_new_videos( &mut self, app: &App, mpv: &Mpv, force_message: bool, ) -> Result { let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?; // There is nothing to watch if play_things.len() == 0 { if force_message { Self::message(&mpv, "No new videos available to add", "3000")?; } return Ok(0); } let play_things = play_things .into_iter() .filter(|val| !self.current_playlist.contains(&val.extractor_hash)) .collect::>(); info!( "{} videos are cached and will be added to the list to be played", play_things.len() ); self.current_playlist.reserve(play_things.len()); let num = play_things.len(); for play_thing in play_things { debug!("Adding '{}' to playlist.", play_thing.title); let orig_cache_path = play_thing.cache_path.expect("Is cached and thus some"); let cache_path = orig_cache_path.to_str().expect("Should be vaild utf8"); let cache_path = format!("\"{}\"", cache_path); let args = &[&cache_path, "append-play"]; mpv.execute("loadfile", args)?; self.current_playlist.push(play_thing.extractor_hash); } if force_message || num > 0 { Self::message(&mpv, format!("Added {} videos", num).as_str(), "3000")?; } Ok(num) } fn message(mpv: &Mpv, message: &str, time: &str) -> Result<()> { mpv.execute("show-text", &[format!("\"{}\"", message).as_str(), time])?; Ok(()) } async fn mark_video_watched(&mut self, app: &App, hash: &ExtractorHash) -> Result<()> { let video = get_video_by_hash(app, hash).await?; debug!("MPV handler will mark video '{}' watched.", video.title); set_video_watched(&app, &video).await?; Ok(()) } async fn mark_cvideo_watched(&mut self, app: &App) -> Result<()> { if let Some(index) = self.currently_playing_index { let video_hash = self.current_playlist[(index) as usize].clone(); self.mark_video_watched(app, &video_hash).await?; } error!("Expected a current video, but found none (while trying to mark it watched)"); Ok(()) } async fn mark_cvideo_inactive(&mut self, app: &App) -> Result<()> { if let Some(index) = self.currently_playing_index { let video_hash = &self.current_playlist[(index) as usize]; self.currently_playing_index = None; set_state_change(&app, video_hash, false).await?; } error!("Expected a current video, but found none (while trying to mark it inactive)"); Ok(()) } async fn mark_video_active(&mut self, app: &App, playlist_index: usize) -> Result<()> { let video_hash = &self.current_playlist[(playlist_index) as usize]; self.currently_playing_index = Some(playlist_index); set_state_change(&app, video_hash, true).await?; Ok(()) } /// Apply the options set with e.g. `watch --speed=` async fn apply_options(&self, app: &App, mpv: &Mpv, hash: &ExtractorHash) -> Result<()> { let options = get_video_mpv_opts(app, hash).await?; mpv.set_property("speed", options.playback_speed)?; Ok(()) } /// Check if the playback queue is empty pub async fn check_idle(&mut self, app: &App, mpv: &Mpv) -> Result { if self.current_playlist.is_empty() { warn!("There is nothing to watch yet. Will idle, until something is available"); self.possibly_add_new_videos(app, mpv, false).await?; time::sleep(Duration::from_secs(10)).await; Ok(true) } else { Ok(false) } } /// This will return [`true`], if the event handling should be stopped pub async fn handle_mpv_event<'a>( &mut self, app: &App, mpv: &Mpv, event: Event<'a>, ) -> Result { match event { Event::EndFile(r) => match r { EndFileReason::Eof => { info!("Mpv reached eof of current video. Marking it inactive."); self.mark_cvideo_inactive(app).await?; } EndFileReason::Stop => { info!("Mpv stopped current video. Marking it inactive."); // TODO: Should we also mark the video watched? <2024-08-21> self.mark_cvideo_inactive(app).await?; } EndFileReason::Quit => { info!("Mpv quit. Exiting playback"); // draining the playlist is okay, as mpv is done playing let videos = mem::take(&mut self.current_playlist); for video in videos { self.mark_video_watched(app, &video).await?; set_state_change(&app, &video, false).await?; } return Ok(true); } EndFileReason::Error => { unreachable!("This will be raised as a separate error") } EndFileReason::Redirect => { todo!("We probably need to handle this somehow"); } }, Event::StartFile(playlist_index) => { self.possibly_add_new_videos(app, &mpv, false).await?; self.mark_video_active(app, (playlist_index - 1) as usize) .await?; self.current_playlist_position = (playlist_index - 1) as usize; self.apply_options( app, mpv, &self.current_playlist[self.current_playlist_position], ) .await?; } Event::FileLoaded => {} Event::ClientMessage(a) => { debug!("Got Client Message event: '{}'", a.join(" ")); match a.as_slice() { &["yt-comments-external"] => { let binary = current_exe().expect("A current exe should exist"); let status = Command::new("riverctl") .args(["focus-output", "next"]) .status() .await?; if !status.success() { bail!("focusing the next output failed!"); } let status = Command::new("alacritty") .args(&[ "--title", "floating please", "--command", binary.to_str().expect("Should be valid unicode"), "comments", ]) .status() .await?; if !status.success() { bail!("Falied to start `yt comments`"); } let status = Command::new("riverctl") .args(["focus-output", "next"]) .status() .await?; if !status.success() { bail!("focusing the next output failed!"); } } &["yt-comments-local"] => { let comments: String = get_comments(app) .await? .render(false) .replace("\"", "") .replace("'", "") .chars() .take(app.config.watch.local_comments_length) .collect(); Self::message(mpv, &comments, "6000")?; } &["yt-description"] => { // let description = description(app).await?; Self::message(&mpv, "", "6000")?; } &["yt-mark-watch-later"] => { self.mark_cvideo_inactive(app).await?; mpv.execute("write-watch-later-config", &[])?; mpv.execute("playlist-remove", &["current"])?; Self::message(&mpv, "Marked the video to be watched later", "3000")?; } &["yt-mark-done-and-go-next"] => { self.mark_cvideo_watched(app).await?; self.mark_cvideo_inactive(app).await?; mpv.execute("playlist-remove", &["current"])?; Self::message(&mpv, "Marked the video watched", "3000")?; } &["yt-check-new-videos"] => { self.possibly_add_new_videos(app, mpv, true).await?; } other => { debug!("Unknown message: {}", other.join(" ")) } } } _ => {} } Ok(false) } }