Repository: 13unk0wn/Feather Branch: main Commit: 1f1d7be2b146 Files: 19 Total size: 69.2 KB Directory structure: gitextract__by_cgd2/ ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── feather/ │ ├── .gitignore │ ├── Cargo.toml │ ├── build.rs │ ├── src/ │ │ ├── database.rs │ │ ├── lib.rs │ │ ├── player.rs │ │ └── yt.rs │ └── structure.txt └── feather_frontend/ ├── .gitignore ├── Cargo.toml └── src/ ├── backend.rs ├── history.rs ├── lib.rs ├── main.rs ├── player.rs └── search.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to FEATHER Thank you for considering contributing to FEATHER! 🚀 We welcome all contributions, whether it's fixing bugs, improving documentation, or adding new features. ## 📌 Contribution Guidelines ### ❗ Important Notice Feather v0.1 (`main` branch) **is only accepting bug fix pull requests**. - ✅ **Bug Fixes** → v0.1 (`main`) - ❌ **New Features, Enhancements, or Keybinding Changes** → v0.2 (`0.2` branch) ### 1. Use the `dev` Branch for Pull Requests Made to the `main` Branch All contributions (except critical bug fixes for `main`) should be made to the `dev` branch. Before starting, make sure your local `dev` branch is up to date: ### 2. You Can Directly Commit to the `v0.2` Branch for Additional Features ```bash git checkout dev git pull origin dev ``` Always create a new feature branch for your changes: ```bash git checkout -b feature-branch ``` After making changes, commit and push: ```bash git add . git commit -m "Describe your changes" git push origin feature-branch ``` ### 2. Submitting a Pull Request (PR) 1. Go to the [GitHub repository](https://github.com/13unk0wn/Feather). 2. Click **"New Pull Request"**. 3. Ensure you are merging **your branch into the correct branch**: - Bug fixes → `dev` (v0.1) - New features, enhancements → `0.2` (v0.2) 4. Provide a clear PR description explaining the changes. 5. Request a review from maintainers. Once approved, your PR will be merged into `dev` and later into `main` for a stable release. ### 3. Code Style & Best Practices - ✅ Follow the existing code style and formatting. - ✅ Write meaningful commit messages. - ✅ Keep PRs small and focused on a single feature/fix. - ✅ Test your code before submitting. ### 4. Issues & Discussions - **Bug Reports:** If you find a bug, check if an issue already exists. Otherwise, create a new issue. - **Feature Requests:** If you have an idea, discuss it in an issue before implementing it. ### 5. Need Help? If you're unsure about anything, feel free to open a discussion or ask in an issue. We're happy to help! 🙌 **Happy Coding!** 🚀 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 13unk0wn Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Feather 🎵 Feather is a lightweight, efficient, and locally hosted YouTube Music TUI built with Rust. It is designed to provide a minimalistic yet powerful music streaming experience directly from YouTube, using `yt-dlp` and `mpv`. ## 🎯 Aim A lightweight, ad-less player with only essential features. ## ✨ Features - 🎶 **Stream YouTube Music** without downloading files. - ⚡ **Minimal Memory Usage**, targeting **60MB - 80MB RAM**. - 🚀 **Fast Playback**, with loading times around 3 seconds. - 🖥️ **Terminal User Interface (TUI)** built using Ratatui. - 🔄 **Self-Update Feature** (planned). ## 🛠️ Installation ### 📌 Prerequisites Ensure you have the following installed: - 🦀 **Rust** (latest stable version) - 📥 **yt-dlp** (for fetching YouTube data) - 🎵 **mpv** (for playback) ### 🔧 Build from Source ```sh git clone https://github.com/13unk0wn/Feather.git cd Feather/feather_frontend cargo build --release ``` ### ▶️ Run Feather ```sh ./target/release/feather_frontend ``` ## 🎮 Usage Navigate through the TUI to search and play music. Additional controls and keyboard shortcuts will be documented soon. ### 🛠️ Handling YouTube Restrictions If a song fails to play due to YouTube restrictions, you can bypass them by adding your cookies to the environment: ```sh export FEATHER_COOKIES="paste your cookies here" ``` - This is **optional** and should only be used if playback errors occur. - Feather can play songs without cookies, but adding them may help `mpv` bypass certain restrictions. ## 🌄 Screenshot ![Feather TUI Screenshot](screenshots/preview.png) ## 🛠️ Compatibility Feather has been tested on **Linux Mint (Debian Edition)**, but all libraries used are compatible with other Linux distributions. Windows and Macos are not officially supported. ## 🛣️ Roadmap ### 🚀 Current Version: v0.1.0 - 🎶 Implement player - 🔍 Implement search - �햐 Implement history ### 🔥 Upcoming: v0.2.0 - ⚡ Improve performance - 🎨 Improve UI - 🌜 Add support for playing playlists - 🎼 Add support for creating user playlists - ⚙️ Add user configuration support ## 🤝 Contributing Check out [CONTRIBUTION.md](https://github.com/13unk0wn/Feather/blob/main/CONTRIBUTING.md) If you have any doubts regarding contribution, feel free to reach out via: - GitHub Issues - @x: [13unk0wn](https://x.com/13unk0wn) - Email: [13unk0wn.proton.me](mailto:13unk0wn@proton.me) ## 🌟 Special Thanks A big thank you to the maintainers and contributors of: - [RustyPipe](https://codeberg.org/ThetaDev/rustypipe) — for providing essential tools for YouTube playback. - [mpv](https://github.com/mpv-player/mpv) — for making a great media player that powers Feather's playback. - [Ratatui](https://github.com/tui-rs-revival/ratatui) — for enabling the terminal-based UI experience. - [Sled](https://github.com/spacejam/sled) - database ## 🌟 License Feather is licensed under the MIT License. --- ### 📝 Notes This project is still in early development. Expect rapid iterations and improvements. Suggestions and feedback are always appreciated! ================================================ FILE: feather/.gitignore ================================================ /target rustypipe_cache.json Cargo.lock ================================================ FILE: feather/Cargo.toml ================================================ [package] name = "feather" version = "0.1.0" edition = "2024" build = "build.rs" [dependencies] rustypipe = "0.9.0" tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } bincode = "1.3.3" sled = { version = "0.34.7",features = ["compression"] } thiserror = "1.0" tempfile = "3.16.0" libmpv2 = "4.1.0" dirs = "6.0.0" [build-dependencies] pkg-config = "0.3" [lib] name = "feather" path = "src/lib.rs" ================================================ FILE: feather/build.rs ================================================ fn main() { if cfg!(target_os = "macos") && pkg_config::probe_library("mpv").is_err() { println!("cargo:warning=Could not find mpv via pkg-config. Make sure it is installed"); } } ================================================ FILE: feather/src/database.rs ================================================ // This file manages the history database and contains all necessary functions related to history management use crate::{ArtistName, SongId, SongName}; use serde::{Deserialize, Serialize}; use sled::Db; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; /// Represents a history entry for a song that has been played. #[derive(Serialize, Deserialize, Debug)] pub struct HistoryEntry { pub song_name: SongName, // Name of the song pub song_id: SongId, // Unique identifier for the song pub artist_name: Vec, // List of artists associated with the song time_stamp: u64, // Timestamp when the song was played } impl HistoryEntry { /// Creates a new history entry with the current timestamp. pub fn new( song_name: SongName, song_id: SongId, artist_name: Vec, ) -> Result> { let time_stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); Ok(Self { song_name, song_id, artist_name, time_stamp, }) } } /// Database handler for managing song history. pub struct HistoryDB { db: Db, // Sled database instance } /// Represents possible errors that can occur in history operations. #[derive(Error, Debug)] pub enum HistoryError { #[error("Database error: {0}")] DbError(#[from] sled::Error), // Errors related to the sled database #[error("Serialization error: {0}")] SerializationError(#[from] bincode::Error), // Errors during serialization/deserialization #[error("Basic error: {0}")] Error(Box), // Generic error wrapper } impl HistoryDB { pub fn new() -> Result { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")); path.push("Feather/history_db"); let db = sled::Config::new() .path(path) .cache_capacity(256 * 1024) .use_compression(true) .open()?; Ok(HistoryDB { db }) } /// Adds a new entry to the history database. /// Limits the total stored entries to 50. pub fn add_entry(&self, entry: &HistoryEntry) -> Result<(), HistoryError> { let key = entry.song_id.as_bytes(); let value = bincode::serialize(entry)?; self.db.insert(key, value)?; self.limit_history_size(50)?; Ok(()) } /// Ensures the history database does not exceed `max_size` entries. /// Removes the oldest entries if necessary. pub fn limit_history_size(&self, max_size: usize) -> Result<(), HistoryError> { while self.db.len() > max_size { if let Some((key, _)) = self.db.first()? { self.db.remove(key)?; } } Ok(()) } /// Retrieves up to 50 history entries, sorted by most recent first. pub fn get_history(&self) -> Result, HistoryError> { let mut history = Vec::with_capacity(self.db.len().min(50)); // Pre-allocate vector for item in self.db.iter().take(50) { let (_, value) = item?; if let Ok(entry) = bincode::deserialize::(&value) { history.push(entry); } } history.sort_unstable_by(|e1, e2| e2.time_stamp.cmp(&e1.time_stamp)); // Sort by timestamp descending Ok(history) } /// Deletes a specific history entry by song ID. pub fn delete_entry(&self, song_id: &str) -> Result<(), HistoryError> { self.db.remove(song_id.as_bytes())?; // Convert song ID to bytes Ok(()) } /// Clears all history entries from the database. pub fn clear_history(&self) -> Result<(), HistoryError> { self.db.clear()?; Ok(()) } /// Retrieves the most recently played song's ID, if available. pub fn get_last_played_song(&self) -> Result, HistoryError> { if let Some((_, last_entry)) = self.db.last()? { let entry: HistoryEntry = bincode::deserialize(&last_entry)?; Ok(Some(entry.song_id)) } else { Ok(None) } } } // Unchanged UserPlaylist and PlaylistManager sections... // #[derive(Serialize, Deserialize, Debug, Clone)] // struct UserPlaylist { // playlist_name: PlaylistName, // songs: Vec, // } // #[derive(Error, Debug)] // pub enum PlaylistManagerError { // #[error("Database error: {0}")] // DbError(#[from] sled::Error), // #[error("Serialization error: {0}")] // SerializationError(#[from] bincode::Error), // #[error("Playlist '{0}' not found")] // PlaylistNotFound(String), // #[error("Song '{0}' not found in playlist '{1}'")] // SongNotFound(String, String), // #[error("Duplicate playlist name: '{0}'")] // DuplicatePlaylist(String), // #[error("Failed to add song '{0}' to playlist '{1}'")] // AddSongError(String, String), // #[error("Failed to remove song '{0}' from playlist '{1}'")] // RemoveSongError(String, String), // #[error("Unknown error: {0}")] // Other(String), // } // #[derive(Serialize, Deserialize, Debug, Clone)] // struct Song { // song_name: SongName, // song_id: SongId, // artist: Vec, // } // struct PlaylistManager { // db: sled::Db, // } // impl PlaylistManager { // pub fn new(path: &str) -> Result { // let db = sled::open(path)?; // Ok(Self { db }) // } // fn create_playlist(&self, name: &str) -> Result<(), PlaylistManagerError> { // if self.db.get(name)?.is_some() { // return Err(PlaylistManagerError::DuplicatePlaylist(name.to_string())); // } // let playlist = UserPlaylist { // playlist_name: name.to_string(), // songs: Vec::new(), // }; // let value = bincode::serialize(&playlist)?; // self.db.insert(name, value)?; // self.db.flush()?; // Ok(()) // } // fn add_song_to_playlist( // &self, // playlist_name: &str, // song: Song, // ) -> Result<(), PlaylistManagerError> { // let raw_data = self // .db // .get(playlist_name)? // .ok_or_else(|| PlaylistManagerError::Other("Error: In Opening Playlist".to_string()))? // .to_vec(); // let mut playlist: UserPlaylist = bincode::deserialize(&raw_data)?; // playlist.songs.retain(|s| s.song_id != song.song_id); // playlist.songs.push(song); // let serialized_data = bincode::serialize(&playlist)?; // self.db.insert(playlist_name, serialized_data)?; // self.db.flush()?; // Ok(()) // } // fn remove_song_from_playlist( // &self, // playlist_name: &str, // song_id: &str, // ) -> Result<(), PlaylistManagerError> { // let raw_data = self // .db // .get(playlist_name)? // .ok_or_else(|| PlaylistManagerError::Other("Error: In Opening Playlist".to_string()))? // .to_vec(); // let mut playlist: UserPlaylist = bincode::deserialize(&raw_data)?; // playlist.songs.retain(|s| s.song_id != song_id); // let serialized_data = bincode::serialize(&playlist)?; // self.db.insert(playlist_name, serialized_data)?; // self.db.flush()?; // Ok(()) // } // fn get_playlist(&self, playlist_name: &str) -> Result { // let data = self // .db // .get(playlist_name)? // .ok_or_else(|| PlaylistManagerError::PlaylistNotFound(playlist_name.to_string()))? // .to_vec(); // let playlist: UserPlaylist = bincode::deserialize(&data)?; // Ok(playlist) // } // fn delete_playlist(&self, playlist_name: &str) -> Result<(), PlaylistManagerError> { // self.db // .remove(&playlist_name)? // .ok_or_else(|| PlaylistManagerError::PlaylistNotFound(playlist_name.to_string())); // self.db.flush()?; // Ok(()) // } // } // // Tests unchanged... // #[cfg(test)] // mod tests { // use super::*; // use tempfile::tempdir; // fn sample_song(name: &str, id: &str) -> Song { // Song { // song_name: name.to_string(), // song_id: id.to_string(), // artist: vec!["Artist One".to_string(), "Artist Two".to_string()], // } // } // #[test] // fn test_playlist_manager() { // let temp_dir = tempdir().unwrap(); // let db_path = temp_dir.path().to_str().unwrap(); // let manager = PlaylistManager::new(db_path).unwrap(); // let playlist_name = "MyPlaylist"; // assert!(manager.create_playlist(playlist_name).is_ok()); // let song1 = sample_song("Song A", "123"); // let song2 = sample_song("Song B", "456"); // assert!(manager // .add_song_to_playlist(playlist_name, song1.clone()) // .is_ok()); // assert!(manager // .add_song_to_playlist(playlist_name, song2.clone()) // .is_ok()); // let playlist = manager.get_playlist(playlist_name).unwrap(); // assert_eq!(playlist.songs.len(), 2); // assert!(playlist.songs.iter().any(|s| s.song_id == "123")); // assert!(playlist.songs.iter().any(|s| s.song_id == "456")); // assert!(manager // .remove_song_from_playlist(playlist_name, "123") // .is_ok()); // let playlist = manager.get_playlist(playlist_name).unwrap(); // assert_eq!(playlist.songs.len(), 1); // assert!(playlist.songs.iter().all(|s| s.song_id != "123")); // assert!(manager.delete_playlist(playlist_name).is_ok()); // let result = manager.get_playlist(playlist_name); // assert!(matches!( // result, // Err(PlaylistManagerError::PlaylistNotFound(_)) // )); // } // } ================================================ FILE: feather/src/lib.rs ================================================ pub mod database; pub mod player; pub mod yt; /// Input/Return Types pub type ArtistName = String; pub type SongName = String; pub type SongId = String; pub type SongUrl = String; pub type PlaylistName = String; pub type PlaylistId = String; pub type ChannelName = String; ================================================ FILE: feather/src/player.rs ================================================ use libmpv2::Mpv; // We are not using libmpv library because it was requiring user to install an old version which was not available in many distros so we decided to opt for libmpv2 which is a fork of it use std::sync::Arc; /// The `Player` struct represents a media player using the MPV library. /// It provides functionalities to control playback, retrieve metadata, /// and manage audio optimizations. pub struct Player { /// An instance of the MPV player wrapped in an `Arc` for thread safety. pub player: Arc, } /// Enum representing possible errors when interacting with the MPV player. #[derive(Debug, thiserror::Error)] pub enum MpvError { #[error("Mpv error: {0}")] Mpv(#[from] libmpv2::Error), #[error("Failed to initialize MPV")] InitializationError, #[error("Command execution failed: {0}")] CommandError(String), #[error("Failed to load file: {0}")] LoadFileError(String), #[error("Property retrieval failed: {0}")] PropertyError(String), #[error("Unknown error: {0}")] Other(String), } impl Player { /// Creates a new `Player` instance and configures MPV settings for optimized audio playback. pub fn new(cookies: Option) -> Result { let mpv = Mpv::new()?; if cookies.is_some() { // setting cookies if given by user mpv.set_property("cookies-file", cookies.unwrap())?; } // Disable video to save memory mpv.set_property("video", "no")?; // Optimize caching for lower memory usage //mpv.set_property("cache-secs", 2)?; // Reduced to 2 seconds // mpv.set_property("demuxer-readahead-secs", 1)?; // Reduced to 1 second //mpv.set_property("demuxer-max-bytes", 512 * 1024)?; // 512 KB max buffer // Configure network request headers for YouTube playback mpv.set_property("ytdl-raw-options", "no-check-certificate=")?; mpv.set_property("loop", "inf")?; // Looping enabled (to be removed with autoplay) mpv.set_property( "http-header-fields", "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)", )?; // Audio optimization mpv.set_property("audio-buffer", 0.1)?; // 100ms audio buffer mpv.set_property("audio-channels", "stereo")?; // Force stereo audio let mpv = Arc::new(mpv); Ok(Self { player: mpv }) } /// Loads and plays a media file from a given URL. pub fn play(&self, url: &str) -> Result<(), MpvError> { if let Ok(true) = self.player.get_property("pause") { self.unpause()?; } // Quick fix will improve self.player.command("loadfile", &[url])?; // Replace the current playback Ok(()) } /// Pauses playback. pub fn pause(&self) -> Result<(), MpvError> { self.player.command("set", &["pause", "yes"])?; Ok(()) } /// Resumes playback. pub fn unpause(&self) -> Result<(), MpvError> { self.player.command("set", &["pause", "no"])?; Ok(()) } /// Toggles between play and pause states. pub fn play_pause(&self) -> Result<(), MpvError> { match self.player.get_property::("pause") { Ok(true) => self.unpause()?, Ok(false) => self.pause()?, Err(_) => todo!(), } Ok(()) } /// Seeks forward by 5 seconds in the current track. pub fn seek_forward(&self) -> Result<(), MpvError> { self.player.command("seek", &["5", "relative"])?; Ok(()) } /// Seeks backward by 5 seconds in the current track. pub fn seek_backword(&self) -> Result<(), MpvError> { self.player.command("seek", &["-5", "relative"])?; Ok(()) } /// Retrieves the current playback time as a string. pub fn get_current_time(&self) -> String { self.player .get_property("time-pos") .unwrap_or(0.0) .to_string() } /// Retrieves the duration of the currently playing media. pub fn duration(&self) -> String { self.player .get_property("duration") .unwrap_or(0.0) .to_string() } /// Returns whether a media file is currently playing. pub fn is_playing(&self) -> Result { let pause: bool = self.player.get_property("pause")?; Ok(!pause) } } ================================================ FILE: feather/src/yt.rs ================================================ use crate::{ArtistName, ChannelName, PlaylistId, PlaylistName, SongId, SongName, SongUrl}; use std::path::PathBuf; use rustypipe::{ client::{RustyPipe, RustyPipeQuery}, model::MusicItem, param::StreamFilter, }; use std::collections::HashMap; /// A client for interacting with YouTube music using RustyPipe. pub struct YoutubeClient { client: RustyPipeQuery, } impl YoutubeClient { /// Creates a new instance of `YoutubeClient`. pub fn new() -> Self { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")); path.push("Feather"); let rp = RustyPipe::builder().storage_dir(path).build().unwrap(); let client = rp.query(); YoutubeClient { client } } /// Searches for music based on the given query. /// Returns a vector of tuples where each entry contains a song name and ID, /// along with a list of associated artist names. pub async fn search( &self, query: &str, ) -> Result)>, String> { match self.client.music_search_main(query).await { Ok(results) => { let mut search_result = vec![]; for item in results.items.items { if let MusicItem::Track(data) = item { let song_id_pair = (data.name, data.id); let artist_names: Vec = data.artists.into_iter().map(|id| id.name).collect(); search_result.push((song_id_pair, artist_names)); } } Ok(search_result) } Err(_) => Err("Error in Search Result".to_string()), } } /// Fetches the audio stream URL for a given song ID. pub async fn fetch_song_url(&self, id: &SongId) -> Result { match self.client.player(&id).await { Ok(player) => match player.select_audio_stream(&StreamFilter::default()) { Some(stream) => return Ok(stream.url.clone()), None => return Err("Audio Stream not Found".to_string()), }, Err(_) => return Err("Link cannot be Found".to_string()), } } /// Searches for playlists based on a given query. /// Returns a hashmap where the key is the playlist name and the value is a tuple /// containing the playlist ID and a list of associated channel names. pub async fn fetch_playlist( &self, search_query: &str, ) -> Result)>, String> { match self.client.music_search_playlists(search_query, true).await { Ok(playlists) => { let mut result = HashMap::new(); for playlist in playlists.items.items { let playlist_id = playlist.id; let channel_names: Vec = playlist .channel .into_iter() .map(|channel| channel.name) .collect(); result.insert(playlist.name, (playlist_id, channel_names)); } Ok(result) } Err(e) => Err(format!("Error in fetching playlists: {}", e)), } } /// Fetches songs from a given playlist ID. /// Returns a hashmap where each key is a tuple of (song name, song ID), and /// the value is a list of associated artist names. pub async fn fetch_playlist_songs( &self, playlist_id: PlaylistId, ) -> Result>, String> { match self.client.playlist(playlist_id).await { Ok(playlist_data) => { let mut song_map = HashMap::new(); for video in playlist_data.videos.items { let song_key = (video.name, video.id); let artist_names: Vec = video .channel .into_iter() .map(|channel| channel.name) .collect(); song_map.insert(song_key, artist_names); } Ok(song_map) } Err(e) => Err(format!("Error fetching playlist songs: {}", e)), } } /// Fetches related songs for a given song ID. /// Returns a hashmap where each key is a tuple of (song name, song ID), and /// the value is a list of associated artist names. pub async fn fetch_related_song( &self, song_id: SongId, ) -> Result>, String> { match self.client.music_related(song_id).await { Ok(music_list) => { let tracks = music_list.tracks; let mut results = HashMap::new(); for track in tracks { let song_id_name = (track.name, track.id); let artist_names = track .artists .into_iter() .map(|artist| artist.name) .collect::>(); results.insert(song_id_name, artist_names); } Ok(results) } Err(_) => Err("Error finding related songs".to_string()), } } } // #[tokio::test] // async fn test_search() { // let client = YoutubeClient::new(); // match client.search("Beanie").await { // Ok(results) => { // for ((song, id), artists) in results { // println!("Song: {}", song); // println!("Id : {}", id); // println!("{}", client.fetch_song_url(id.clone()).await.unwrap()); // for artist in artists { // println!(" - Artist: {}", artist); // } // test_fetch_related_song(id).await; // break; // } // } // Err(e) => println!("Search failed: {}", e), // } // } // #[tokio::test] // async fn test_fetch_playlist() { // let client = YoutubeClient::new(); // let query = "lofi beats"; // match client.fetch_playlist(query).await { // Ok(playlists) => { // for (playlist_name, (playlist_id, channel_names)) in playlists { // println!("Playlist: {} (ID: {})", playlist_name, playlist_id); // test_fetch_playlist_songs(playlist_id).await; // // for channel in channel_names { // // println!(" - Channel: {}", channel); // // } // break; // } // } // Err(e) => eprintln!("Test failed: {}", e), // } // } // async fn test_fetch_playlist_songs(playlist_id : String) { // let client = YoutubeClient::new(); // match client.fetch_playlist_songs(playlist_id).await { // Ok(songs) => { // for ((song_name, song_id), artist_names) in songs { // println!("Song: {} (ID: {})", song_name, song_id); // let url = client.fetch_song_url(&song_id).await.unwrap(); // println!("{url:?}"); // for artist in artist_names { // println!(" - Artist: {}", artist); // } // } // } // Err(e) => eprintln!("Test failed: {}", e), // } // } // async fn test_fetch_related_song(song_id : String) { // let client = YoutubeClient::new(); // match client.fetch_related_song(song_id).await { // Ok(related_songs) => { // for ((song_name, song_id), artist_names) in related_songs { // println!("Related Song: {} (ID: {})", song_name, song_id); // for artist in artist_names { // println!(" - Artist: {}", artist); // } // } // } // Err(e) => eprintln!("Test failed: {}", e), // } // } ================================================ FILE: feather/structure.txt ================================================ Feather │── backend │ │── yt.rs │ │ │── search(query: &str) -> Vec # Search YouTube and return video URLs. [done] │ │ │── fetch_url(video_id: &str) -> String # Get the direct link for mpv to play. [done] │ │ │── fetch_playlist(playlist_id: &str) -> Vec # Fetch all songs in a playlist. [done] │ │ │── fetch_related(video_id: &str) -> Vec # Get related songs (for autoplay).[unresolved] │ │ │ │── mpv.rs │ │ │── play(url: &str) # Play a song using mpv. │ │ │── pause() # Pause or resume playback. │ │ │── stop() # Stop the current playback. │ │ │── skip(seconds: i64) # Skip forward/backward by seconds. │ │ │── volume(level: u8) # Adjust the volume level. │ │ │── next() # Play the next song in the queue. │ │ │── on_song_end() # Triggered when a song ends (handles autoplay). │ │ │ │── database.rs │ │ │── add_to_history(song: &str) # Save song to history. [done] │ │ │── get_history() -> Vec # Retrieve history list. [done] │ │ │── delete_from_history(index: usize) # Remove a song from history. [done] │ │ │── save_playlist(name: &str, songs: Vec) # Create or update a playlist.[done] │ │ │── load_playlist(name: &str) -> Vec # Load songs from a playlist.[done] │ │ │── delete_playlist(name: &str) # Delete a playlist.[done] │ │ │── get_last_played() -> Option # Get the last played song.[done] │ │ │── add_to_queue(song: &str) # Add song to queue. │ │ │── remove_from_queue(index: usize) # Remove song from queue. │ │ │── get_next_song() -> Option # Get next song from queue. │ │ │── clear_queue() # Clear the queue. │ │ │ │── config.rs │ │ │── load_config() -> Config # Load settings from `config.lua`. │ │ │── save_config(config: &Config) # Save updated settings. │ │ │── watch_config() # Optional: Reload config if changed. │ │ │── config.lua # User settings (autoplay, volume, history limit, storage path). ================================================ FILE: feather_frontend/.gitignore ================================================ target/ rustypipe_cache.json ================================================ FILE: feather_frontend/Cargo.toml ================================================ [package] name = "feather_frontend" version = "0.1.0" edition = "2024" authors = ["13unk0wn 13unk0wn@proton.me"] description = "A lightweight YouTube Music TUI in Rust." license = "MIT" categories = ["command-line-utilities", "multimedia"] keywords = ["music", "youtube", "tui", "rust"] [dependencies] color-eyre = "0.6.3" crossterm = "0.28.1" ratatui = "0.29.0" tui-textarea = "0.7.0" feather = {path = "../feather"} tokio = "1.43.0" tui-scrollview = "0.3" thiserror ="1.0" wee_alloc = "0.4" [profile.release] opt-level = 3 # Maximum optimization lto = true # Link Time Optimization codegen-units = 1 # Optimize for binary size strip = true # Remove debug symbols panic = 'abort' # Reduce unwinding overhead # [replace-with] # global_allocator = "wee_alloc" ================================================ FILE: feather_frontend/src/backend.rs ================================================ use feather::{ ArtistName, SongId, SongName, database::{HistoryDB, HistoryEntry}, player::{MpvError, Player}, yt::YoutubeClient, }; use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; use thiserror::Error; /// The `Backend` struct manages the YouTube client, music player, and history database. /// It also tracks the currently playing song. pub struct Backend { pub yt: YoutubeClient, // YouTube client for fetching song URLs pub player: Player, // Music player instance pub history: Arc, // Shared history database pub song: Mutex>, // Mutex-protected optional current song } /// Represents a song with its name, ID, and artist(s). #[derive(Clone)] pub struct Song { pub song_name: SongName, // Name of the song pub song_id: SongId, // Unique identifier for the song artist_name: Vec, // List of artists performing the song } /// Implements conversion from `Song` to `HistoryEntry`, ensuring valid history records. impl From for HistoryEntry { fn from(value: Song) -> Self { HistoryEntry::new(value.song_name, value.song_id, value.artist_name) .expect("Cannot Form History Entry") } } impl Song { /// Creates a new `Song` instance. pub fn new(song_name: SongName, song_id: SongId, artist_name: Vec) -> Self { Self { song_name, song_id, artist_name, } } } /// Defines possible errors that can occur in the `Backend`. #[derive(Error, Debug)] pub enum BackendError { #[error("Player error: {0}")] Mpv(#[from] MpvError), // Error related to the music player #[error("Failed to fetch YouTube URL")] YoutubeFetch(String), // Error when fetching a song URL from YouTube #[error("Mutex poisoned: {0}")] MutexPoisoned(String), // Error when accessing a poisoned mutex #[error("History database error: {0}")] HistoryError(String), // Error related to history database operations #[error("Playback error: {0}")] PlaybackError(String), // Error related to playback issues } impl Backend { /// Creates a new `Backend` instance. /// /// # Arguments /// * `history` - Shared reference to the history database. /// * `cookies` - Optional cookie string for authentication. /// /// # Returns /// * `Result` - Returns `Backend` on success or an error on failure. pub fn new(history: Arc, cookies: Option) -> Result { Ok(Self { yt: YoutubeClient::new(), player: Player::new(cookies).map_err(BackendError::Mpv)?, history, song: Mutex::new(None), }) } /// Plays a song by fetching its URL from YouTube and passing it to the player. /// /// # Arguments /// * `song` - The song to be played. /// /// # Returns /// * `Result<(), BackendError>` - Returns `Ok(())` on success or an error on failure. pub async fn play_music(&self, song: Song) -> Result<(), BackendError> { const MAX_RETRIES: i32 = 8; let id = song.song_id.to_string(); // Fetch song URL with retry mechanism let url = { let mut attempts = 0; loop { match self.yt.fetch_song_url(&id).await { Ok(url) => break url, Err(_) if attempts < MAX_RETRIES => { attempts += 1; tokio::time::sleep(Duration::from_millis(100)).await; continue; } Err(e) => { return Err(BackendError::YoutubeFetch(format!( "Failed to fetch URL after {} attempts: {:?}", MAX_RETRIES, e ))); } } } }; // Update the currently playing song in a mutex-protected section { let mut current_song = self .song .lock() .map_err(|e| BackendError::MutexPoisoned(e.to_string()))?; *current_song = Some(song.clone()); } // Play the song self.player.play(&url).map_err(BackendError::Mpv)?; // Add the song to history self.history .add_entry(&HistoryEntry::from(song)) .map_err(|e| BackendError::HistoryError(e.to_string()))?; Ok(()) } } ================================================ FILE: feather_frontend/src/history.rs ================================================ use crate::backend::{Backend, Song}; use crossterm::event::{KeyCode, KeyEvent}; use feather::database::HistoryDB; use ratatui::prelude::{Buffer, Color, Constraint, Layout, Rect}; use ratatui::style::Style; use ratatui::text::Span; use ratatui::widgets::{ Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarState, StatefulWidget, Widget, }; use std::sync::Arc; use tokio::sync::mpsc; // Defines a struct to manage playback history UI pub struct History { history: Arc, // Database connection for history selected: usize, // Index of currently selected item vertical_scroll_state: ScrollbarState, // State for vertical scrollbar max_len: usize, // Total number of history items selected_song: Option, // Currently selected song details backend: Arc, // Audio backend for playback tx_player: mpsc::Sender, // Channel to communicate with player } impl History { // Constructor initializing the History struct pub fn new( history: Arc, backend: Arc, tx_player: mpsc::Sender, ) -> Self { Self { history, selected: 0, vertical_scroll_state: ScrollbarState::default(), max_len: 0, selected_song: None, backend, tx_player, } } // Handles keyboard input for navigation and actions pub fn handle_keystrokes(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('j') | KeyCode::Down => { // Move selection down self.select_next(); } KeyCode::Char('k') | KeyCode::Up => { // Move selection up self.select_previous(); } KeyCode::Char('d') => { // Delete selected entry if let Some(song) = &self.selected_song { let _ = self.history.delete_entry(&song.song_id); } } KeyCode::Enter => { // Play selected song if let Some(song) = self.selected_song.clone() { let backend = Arc::clone(&self.backend); let tx_player = self.tx_player.clone(); tokio::spawn(async move { // Spawn async task for playback if backend.play_music(song).await.is_ok() { let _ = tx_player.send(true).await; } }); } } _ => (), // Ignore other keys } } // Moves selection to next item, respecting bounds fn select_next(&mut self) { if self.max_len > 0 { self.selected = (self.selected + 1).min(self.max_len - 1); self.vertical_scroll_state = self.vertical_scroll_state.position(self.selected); } } // Moves selection to previous item, preventing underflow fn select_previous(&mut self) { self.selected = self.selected.saturating_sub(1); self.vertical_scroll_state = self.vertical_scroll_state.position(self.selected); } // Renders the history UI component pub fn render(&mut self, area: Rect, buf: &mut Buffer) { let chunks = Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(0)]) // Split layout .split(area); // Render title bar Paragraph::new("History") .style(Style::default().fg(Color::White)) .block(Block::default().borders(Borders::ALL)) .render(chunks[0], buf); // Setup history list area with scrollbar let history_area = chunks[1]; let scrollbar = Scrollbar::new(ratatui::widgets::ScrollbarOrientation::VerticalRight) .begin_symbol(Some("↑")) .end_symbol(Some("↓")); scrollbar.render(history_area, buf, &mut self.vertical_scroll_state); // Fetch and render history items if let Ok(items) = self.history.get_history() { self.max_len = items.len(); self.vertical_scroll_state = self.vertical_scroll_state.content_length(self.max_len); let view_items: Vec = items .into_iter() .enumerate() .map(|(i, item)| { // Format each item for display let is_selected = i == self.selected; if is_selected { self.selected_song = Some(Song::new( item.song_name.clone(), item.song_id.clone(), item.artist_name.clone(), )); } let style = if is_selected { // Highlight selected item Style::default().fg(Color::Yellow).bg(Color::Blue) } else { Style::default() }; let text = format!("{} - {}", item.song_name, item.artist_name.join(", ")); ListItem::new(Span::styled(text, style)) }) .collect(); let mut list_state = ListState::default(); list_state.select(Some(self.selected)); StatefulWidget::render( // Render the list List::new(view_items) .block(Block::default().borders(Borders::ALL)) .highlight_symbol("▶"), history_area, buf, &mut list_state, ); } else { // Handle history loading failure self.max_len = 0; self.selected = 0; Paragraph::new("Failed to load history").render(history_area, buf); } } } ================================================ FILE: feather_frontend/src/lib.rs ================================================ pub mod backend; pub mod history; pub mod player; pub mod search; ================================================ FILE: feather_frontend/src/main.rs ================================================ use color_eyre::eyre::Result; use crossterm::event::{Event, KeyCode, KeyEvent, poll, read}; use feather::database::HistoryDB; use feather_frontend::{backend::Backend, history::History, player::SongPlayer, search::Search}; use ratatui::{ DefaultTerminal, buffer::Buffer, layout::{Constraint, Layout, Rect}, widgets::{Block, Borders, Cell, Paragraph, Row, Table, Widget}, }; use std::{env, sync::Arc}; use tokio::{ sync::mpsc, time::{Duration, interval}, }; /// Entry point for the async runtime. #[tokio::main] async fn main() -> Result<()> { color_eyre::install().unwrap(); let terminal = ratatui::init(); let _app = App::new().render(terminal).await; ratatui::restore(); Ok(()) } /// Enum representing different states of the application. #[derive(Debug)] enum State { HelpMode, Global, Search, History, // UserPlaylist, // CurrentPlayingPlaylist, SongPlayer, } /// Main application struct managing the state and UI components. struct App<'a> { state: State, search: Search<'a>, history: History, // user_playlist: UserPlaylist, // current_playling_playlist: CurrentPlayingPlaylist, top_bar: TopBar, player: SongPlayer, // backend: Arc, help_mode: bool, exit: bool, } impl App<'_> { /// Creates a new instance of the application. fn new() -> Self { let history = Arc::new(HistoryDB::new().unwrap()); let get_cookies = env::var("FEATHER_COOKIES").ok(); // Fetch cookies from environment variables if available. let backend = Arc::new(Backend::new(history.clone(), get_cookies).unwrap()); let (tx, rx) = mpsc::channel(32); App { state: State::Global, search: Search::new(backend.clone(), tx.clone()), history: History::new(history, backend.clone(), tx.clone()), // user_playlist: UserPlaylist {}, // current_playling_playlist: CurrentPlayingPlaylist {}, top_bar: TopBar::new(), player: SongPlayer::new(backend.clone(), rx), // backend, help_mode: false, exit: false, } } /// Handles global keystrokes and state transitions. fn handle_global_keystrokes(&mut self, key: KeyEvent) { match self.state { State::Global => match key.code { KeyCode::Char('s') => self.state = State::Search, KeyCode::Char('h') => self.state = State::History, KeyCode::Char('p') => self.state = State::SongPlayer, KeyCode::Char('?') => { self.help_mode = true; self.state = State::HelpMode; } KeyCode::Esc => { self.exit = true; } _ => (), }, State::Search => match key.code { KeyCode::Esc => self.state = State::Global, _ => self.search.handle_keystrokes(key), }, State::HelpMode => match key.code { KeyCode::Esc => { self.state = State::Global; self.help_mode = false; } _ => (), }, State::History => match key.code { KeyCode::Esc => self.state = State::Global, _ => self.history.handle_keystrokes(key), }, State::SongPlayer => match key.code { KeyCode::Esc => self.state = State::Global, _ => self.player.handle_keystrokes(key), }, } } /// Main render loop for updating the UI. async fn render(mut self, mut terminal: DefaultTerminal) { let mut redraw_interval = interval(Duration::from_millis(250)); // Redraw every 250ms while !self.exit { terminal .draw(|frame| { let area = frame.area(); let layout = Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([ Constraint::Percentage(10), Constraint::Percentage(75), Constraint::Percentage(15), ]) .split(area); let middle_layout = Layout::default() .direction(ratatui::layout::Direction::Horizontal) .constraints(vec![Constraint::Percentage(50), Constraint::Percentage(50)]) .split(layout[1]); if !self.help_mode { self.top_bar .render(layout[0], frame.buffer_mut(), &self.state); self.search.render(middle_layout[0], frame.buffer_mut()); self.history.render(middle_layout[1], frame.buffer_mut()); self.player.render(layout[2], frame.buffer_mut()); } else { let rows = vec![ Row::new(vec![Cell::from("s"), Cell::from("Search")]), Row::new(vec![Cell::from("h"), Cell::from("History")]), Row::new(vec![Cell::from("p"), Cell::from("Player")]), Row::new(vec![Cell::from("?"), Cell::from("Toggle Help Mode")]), Row::new(vec![ Cell::from("TAB (Search)"), Cell::from("Toggle between search input and results"), ]), Row::new(vec![ Cell::from("Esc (Global)"), Cell::from("Quit application"), ]), Row::new(vec![ Cell::from("Esc (Non-Global)"), Cell::from("Switch to Global Mode"), ]), Row::new(vec![ Cell::from("↑ / k(History/Search)"), Cell::from("Navigate up in list"), ]), Row::new(vec![ Cell::from("↓ / j(History/Search)"), Cell::from("Navigate down in list"), ]), Row::new(vec![ Cell::from("Space / ; (Player)"), Cell::from("Pause current song"), ]), Row::new(vec![ Cell::from("→ (Player)"), Cell::from("Skip forward 5 seconds"), ]), Row::new(vec![ Cell::from("← (Player)"), Cell::from("Rewind 5 seconds"), ]), ]; let help_table = Table::new( rows, [Constraint::Percentage(20), Constraint::Percentage(80)], ) .block(Block::default().borders(Borders::ALL).title("Help")) .header(Row::new(vec![Cell::from("Key"), Cell::from("Action")])); help_table.render(area, frame.buffer_mut()); } }) .unwrap(); tokio::select! { _ = redraw_interval.tick() => {} _ = async { if poll(Duration::from_millis(100)).unwrap() { if let Event::Key(key) = read().unwrap() { self.handle_global_keystrokes(key); } } } => {} } } } } /// Represents the top bar UI component. struct TopBar; impl TopBar { fn new() -> Self { Self } fn render(&mut self, area: Rect, buf: &mut Buffer, state: &State) { let s = format!("Feather | Current Mode : {:?}", state); Paragraph::new(s) .block(Block::default().borders(Borders::ALL)) .render(area, buf); } } #[allow(unused)] /// Placeholder struct for user playlists. struct UserPlaylist {} #[allow(unused)] /// Placeholder struct for currently playing playlist. struct CurrentPlayingPlaylist {} ================================================ FILE: feather_frontend/src/player.rs ================================================ use crate::backend::{Backend, Song}; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::prelude::{Alignment, Buffer, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Widget}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::mpsc; use tokio::task; #[derive(PartialEq, PartialOrd, Debug)] enum SongState { Idle, // No song is playing Playing, // A song is currently playing Loading, // Song is loading ErrorPlayingoSong, // An error occurred while playing the song } #[derive(Clone)] pub struct SongDetails { song: Song, // Information about the song current_time: String, // Current playback time (formatted as MM:SS) total_duration: String, // Total duration of the song } pub struct SongPlayer { backend: Arc, // Backend reference for controlling playback songstate: Arc>, // Current state of the player (Idle, Playing, etc.) song_playing: Arc>>, // Details of the currently playing song rx: mpsc::Receiver, // Receiver to listen for playback events } impl SongPlayer { pub fn new(backend: Arc, rx: mpsc::Receiver) -> Self { let player = Self { backend, songstate: Arc::new(Mutex::new(SongState::Idle)), song_playing: Arc::new(Mutex::new(None)), rx, }; player.observe_time(); // Start observing playback time player } // Function to continuously update the current playback time fn observe_time(&self) { let backend = Arc::clone(&self.backend); let song_playing = Arc::clone(&self.song_playing); tokio::task::spawn(async move { loop { // Try to get the current playback position from MPV match backend.player.player.get_property::("time-pos") { Ok(time) => { // Lock the song_playing mutex and update the current playback time if let Ok(mut song_lock) = song_playing.lock() { if let Some(song) = song_lock.as_mut() { song.current_time = format!("{:.0}", time); } } } Err(_) => (), // Ignore errors (e.g., if MPV is not running) } tokio::time::sleep(Duration::from_millis(500)).await; // Update every 500ms } }); } // Handle key presses for playback control pub fn handle_keystrokes(&mut self, key: KeyEvent) { if let Ok(state) = self.songstate.lock() { if *state == SongState::Playing { match key.code { KeyCode::Char(' ') | KeyCode::Char(';') => { // Toggle play/pause if let Ok(_) = self.backend.player.play_pause() {}; } KeyCode::Right | KeyCode::Char('l') => { // Seek forward self.backend.player.seek_forward().ok(); } KeyCode::Left | KeyCode::Char('j') => { // Seek backward self.backend.player.seek_backword().ok(); } _ => (), }; } } } // Function to check whether a song is playing fn check_playing(&mut self) { let songstate = Arc::clone(&self.songstate); let backend = Arc::clone(&self.backend); let song_playing = Arc::clone(&self.song_playing); task::spawn(async move { const MAX_IDLE_COUNT: i32 = 5; // Max checks before considering it an error let mut idle_count = 0; // Initial delay before checking playback status tokio::time::sleep(Duration::from_secs(1)).await; loop { match backend.player.is_playing() { Ok(true) => { if let Ok(mut state) = songstate.lock() { if let Ok(mut song_lock) = song_playing.lock() { if let Ok(song) = backend.song.lock() { if let Some(value) = song.as_ref() { let total_duration = backend .player .duration() .parse::() .map(|d| { let total = d as i64; format!("{:02}:{:02}", total / 60, total % 60) }) .unwrap_or_default(); *song_lock = Some(SongDetails { song: value.clone(), current_time: backend.player.get_current_time(), total_duration, }); *state = SongState::Playing; return; // Exit once playing is confirmed } } } } idle_count = 0; // Reset idle count since the song is playing } Ok(false) => { // Song is not playing, set state to Idle if let Ok(mut state) = songstate.lock() { *state = SongState::Idle; } idle_count += 1; } Err(_) => idle_count += 1, // Increase idle count if an error occurs } // If too many idle checks, assume an error occurred if idle_count >= MAX_IDLE_COUNT { if let Ok(mut state) = songstate.lock() { if *state == SongState::Loading { *state = SongState::ErrorPlayingoSong; } } } tokio::time::sleep(Duration::from_secs(2)).await; // Check every 2 seconds } }); } // Render the player UI pub fn render(&mut self, area: Rect, buf: &mut Buffer) { // Check for playback event signals if self.rx.try_recv().is_ok() { if let Ok(mut state) = self.songstate.lock() { *state = SongState::Loading; } self.check_playing(); // Start checking for playback status } let block = Block::default().borders(Borders::ALL); let inner = block.inner(area); block.render(area, buf); if let Ok(state) = self.songstate.lock() { let text = match *state { SongState::Idle => vec![Line::from("No song is playing")], SongState::Playing => { if let Ok(song_playing) = self.song_playing.lock() { song_playing.as_ref().map_or_else( || vec![Line::from("Loading...")], |song| { let current_time = song .current_time .parse::() .map(|t| format!("{:02}:{:02}", t / 60, t % 60)) .unwrap_or_default(); vec![ Line::from(Span::styled( song.song.song_name.clone(), Style::default().add_modifier(Modifier::BOLD), )), Line::from(format!("{}/{}", current_time, song.total_duration)), ] }, ) } else { vec![Line::from("Error accessing song details")] } } SongState::Loading => { vec![Line::from("Loading Song")] } SongState::ErrorPlayingoSong => { vec![Line::from("Error Playing Song")] } }; Paragraph::new(text) .alignment(Alignment::Center) .render(inner, buf); } } } ================================================ FILE: feather_frontend/src/search.rs ================================================ use crate::backend::{Backend, Song}; use crossterm::event::{KeyCode, KeyEvent}; use feather::{ArtistName, SongId, SongName}; use ratatui::{ buffer::Buffer, layout::{Constraint, Layout, Rect}, style::{Color, Style}, text::Span, widgets::{ Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarState, StatefulWidget, Widget, }, }; use std::sync::Arc; use tokio::{ sync::mpsc, time::{Duration, sleep}, }; use tui_textarea::TextArea; // Defines possible states for the search interface enum SearchState { SearchBar, // When focused on input field SearchResults, // When browsing search results } pub struct Search<'a> { textarea: TextArea<'a>, // Text input widget for search queries state: SearchState, // Current UI state query: String, // Current search query text tx: mpsc::Sender)>, String>>, // Sender for search results rx: mpsc::Receiver)>, String>>, // Receiver for search results tx_player: mpsc::Sender, // Channel to communicate with player backend: Arc, // Audio backend for search and playback vertical_scroll_state: ScrollbarState, // Vertical scrollbar state display_content: bool, // Flag to show search results results: Result)>>, String>, // Search results or error selected: usize, // Index of selected result selected_song: Option, // Currently selected song details max_len: Option, // Total number of search results } impl Search<'_> { // Constructor initializing the Search struct pub fn new(backend: Arc, tx_player: mpsc::Sender) -> Self { let (tx, rx) = mpsc::channel(32); // Create channel for async search results Self { query: String::new(), state: SearchState::SearchBar, textarea: TextArea::default(), tx, rx, tx_player, backend, vertical_scroll_state: ScrollbarState::default(), display_content: false, results: Ok(None), selected: 0, selected_song: None, max_len: None, } } // Handles keyboard input based on current state pub fn handle_keystrokes(&mut self, key: KeyEvent) { if let SearchState::SearchBar = self.state { match key.code { KeyCode::Tab => { // Switch to results state self.change_state(); } KeyCode::Enter => { // Execute search self.display_content = false; self.selected = 0; let text = self.textarea.lines(); if !text.is_empty() { self.query = text[0].trim().to_string(); let tx = self.tx.clone(); let query = self.query.clone(); let backend = self.backend.clone(); tokio::spawn(async move { // Async task for search sleep(Duration::from_millis(500)).await; // Debounce match backend.yt.search(&query).await { Ok(songs) => { let _ = tx.send(Ok(songs)).await; } Err(e) => { let _ = tx.send(Err(e)).await; } } }); } } _ => { self.textarea.input(key); } // Handle text input } } else { // SearchResults state match key.code { KeyCode::Tab => { self.change_state(); } // Switch to search bar KeyCode::Char('j') | KeyCode::Down => { // Move selection down self.selected = self.selected.saturating_add(1); if let Some(len) = self.max_len { self.selected = self.selected.min(len - 1); } self.vertical_scroll_state = self.vertical_scroll_state.position(self.selected); } KeyCode::Char('k') | KeyCode::Up => { // Move selection up self.selected = self.selected.saturating_sub(1); self.vertical_scroll_state = self.vertical_scroll_state.position(self.selected); } KeyCode::Enter => { // Play selected song if let Some(song) = self.selected_song.clone() { let backend = self.backend.clone(); let tx_player = self.tx_player.clone(); tokio::spawn(async move { let _ = backend.play_music(song).await.is_ok(); let _ = tx_player.send(true).await; }); } } _ => {} } } } // Toggles between search bar and results view pub fn change_state(&mut self) { match self.state { SearchState::SearchResults => self.state = SearchState::SearchBar, _ => self.state = SearchState::SearchResults, } } // Renders the search UI pub fn render(&mut self, area: Rect, buf: &mut Buffer) { let chunks = Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([ Constraint::Length(3), // Search bar height Constraint::Min(0), // Results area Constraint::Length(3), // Bottom bar ]) .split(area); let searchbar_area = chunks[0]; let results_area = chunks[1]; let bottom_area = chunks[2]; // Check for new search results if let Ok(response) = self.rx.try_recv() { if let Ok(result) = response { self.results = Ok(Some(result)); } else if let Err(e) = response { self.results = Err(e); } self.display_content = true; } // Render search bar let search_block = Block::default().title("Search Music").borders(Borders::ALL); self.textarea.set_cursor_line_style(Style::default()); self.textarea .set_placeholder_text("Search Song or Playlist"); self.textarea.set_style(Style::default().fg(Color::White)); self.textarea.set_block(search_block); self.textarea.render(searchbar_area, buf); // Render vertical scrollbar let vertical_scrollbar = Scrollbar::new(ratatui::widgets::ScrollbarOrientation::VerticalRight) .begin_symbol(Some("↑")) .end_symbol(Some("↓")); vertical_scrollbar.render(results_area, buf, &mut self.vertical_scroll_state); // Render search results if available if self.display_content { if let Ok(result) = self.results.clone() { if let Some(r) = result { self.max_len = Some(r.len()); let items: Vec = r .into_iter() .enumerate() .map(|(i, ((song, songid), artists))| { // Format results let style = if i == self.selected { self.selected_song = Some(Song::new(song.clone(), songid.clone(), artists.clone())); Style::default().fg(Color::Yellow).bg(Color::Blue) } else { Style::default() }; let text = format!("{} - {}", song, artists.join(", ")); ListItem::new(Span::styled(text, style)) }) .collect(); let mut list_state = ListState::default(); list_state.select(Some(self.selected)); StatefulWidget::render( // Render results list List::new(items) .block(Block::default().title("Results").borders(Borders::ALL)) .highlight_symbol("▶"), results_area, buf, &mut list_state, ); } } } // Render bottom help bar let bottom_bar = Paragraph::new("Press '?' for Help in Global Mode") .style(Style::default().fg(Color::White)) .block(Block::default().borders(Borders::ALL)); bottom_bar.render(bottom_area, buf); // Note: custom_area undefined, likely should be bottom_area // Render outer border let outer_block = Block::default().borders(Borders::ALL); outer_block.render(area, buf); } }