commit 855fdfd414763f2513cde18289ba9006cfb89030 Author: klein panic Date: Fri Oct 25 20:10:38 2024 -0400 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66bca12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +venv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bce361a --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) [year] [fullname] + +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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..42baa05 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# Media-TUI + +**Media-TUI** is a terminal-based media player built using Python. It integrates with services such as **Spotify** and provides a simple, curses-based user interface to browse, play, and control music. It also supports local file navigation and playback. + +## Features + +- **Spotify Integration**: Authenticate with Spotify to control your playlists, albums, and songs directly from the terminal. +- **Curses-based UI**: Browse playlists, tracks, albums, and control playback using a keyboard-driven terminal interface. +- **ASCII Art**: Displays album art in the form of ASCII art for currently playing tracks. +- **Device Management**: View and switch between available Spotify devices for playback. +- **Playback Controls**: Control volume, skip tracks, play/pause functionality, and more directly from the terminal. + +## Requirements + +- Python 3.7+ +- Spotipy (Spotify API Python client) +- Pillow (Python Imaging Library for handling images) +- Curses (terminal UI library) +- Requests (for making HTTP requests) +- dotenv (for loading environment variables) + +### Python Libraries + +Install the dependencies using `pip`: + +```bash +pip install spotipy pillow python-dotenv requests +``` + +## Installation + +1. **Clone the Repository**: + + ```bash + git clone https://github.com/your-username/media-tui.git + cd media-tui + ``` + +2. **Set up Spotify Credentials**: + + To authenticate with Spotify, you'll need to set up a Spotify developer application. Follow these steps: + + - Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/applications) and create a new application. + - Add a redirect URI like `http://localhost:8888/callback` to your application settings. + + Create a `.env` file in the project root and add your credentials: + + ```env + SPOTIPY_CLIENT_ID=your-client-id + SPOTIPY_CLIENT_SECRET=your-client-secret + SPOTIPY_REDIRECT_URI=http://localhost:8888/callback + ``` + +3. **Activate Virtual Environment (Optional but recommended)**: + + If you'd like to use a virtual environment: + + ```bash + python3 -m venv venv + source venv/bin/activate # On Windows use `venv\Scripts\activate` + ``` + +4. **Install Dependencies**: + + Inside your virtual environment (if you're using one), install the dependencies: + + ```bash + pip install -r requirements.txt + ``` + +5. **Run the Application**: + + ```bash + python main.py + ``` + +## How to Use + +### Keybindings: + +- **Explorer Mode**: + - `j` / `k`: Navigate up and down in the playlists or albums. + - `a`: Switch to albums view. + - `p`: Switch to playlists view. + - `Enter`: Select a playlist or album and view its tracks. + - `c`: View the currently playing song. + - `Backspace`: Exit the current view. + +- **Tracks View**: + - `j` / `k`: Navigate through tracks. + - `Enter`: Play the selected track. + - `c`: View the currently playing song. + - `d`: Open device management view. + - `Backspace`: Return to the explorer view. + +- **Player View**: + - `p`: Toggle Play/Pause. + - `n`: Next track. + - `b`: Previous track. + - `+` / `-`: Increase or decrease volume. + - `d`: Open device management view. + - `Backspace`: Return to the tracks view. + +- **Device Management**: + - `j` / `k`: Navigate through available devices. + - `Enter`: Switch playback to the selected device. + - `Backspace`: Return to the player view. + +### Features in Detail + +- **Spotify Authentication**: Media-TUI authenticates with Spotify using OAuth and provides access to your personal Spotify playlists, albums, and playback devices. +- **ASCII Art**: Album art for the currently playing track is converted into ASCII art and displayed in the player view. +- **Device Management**: Easily switch between your available Spotify devices such as phones, computers, and smart speakers for playback. + +## Troubleshooting + +### Common Errors + +1. **Spotify Authentication Error**: + - Make sure you've correctly set the environment variables for Spotify credentials in the `.env` file. + - Ensure the redirect URI in the `.env` matches the one in your Spotify Developer Dashboard. + +2. **No Active Device Found**: + - If no active device is found, make sure you have the Spotify app open on one of your devices and logged in with the same account. + +3. **Album Art Not Displaying**: + - If album art does not appear as ASCII, ensure you have a stable internet connection, and the required libraries (`requests`, `Pillow`) are installed. + +## Contributing + +If you'd like to contribute to this project: + +1. Fork the repository. +2. Create a new branch for your feature/bugfix. +3. Submit a pull request. + +## License + +This project is licensed under the MIT License. + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3ca40d6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +psutil +python-mpv +curses-menu +pymediainfo +mutagen +pillow +spotipy +python-dotenv diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..0698f7f --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,4 @@ +backups/ +local_music_debug.log +__pycache__/ +.cache diff --git a/src/local_media.py b/src/local_media.py new file mode 100644 index 0000000..f7b6bec --- /dev/null +++ b/src/local_media.py @@ -0,0 +1,398 @@ +# local_media.py + +import curses +import os +import subprocess +from pathlib import Path +import logging +import time +import socket +import json +import threading +from pymediainfo import MediaInfo + +logging.basicConfig( + filename="local_media_debug.log", + level=logging.DEBUG, + format="%(asctime)s - %(levelname)s - %(message)s" +) + +class LocalMediaPlayer: + def __init__(self, stdscr): + self.stdscr = stdscr + self.media_dir = Path.home() / "Videos" + self.selected_index = 0 + self.file_list = self.get_media_directories() + self.player_process = None + self.current_view = "dashboard" + self.window = None + self.current_media_info = {} + self.playback_start_time = None + self.pause_time = None + self.playlist = [] + self.current_media_index = None + self.ipc_socket = None + self.mpv_event_thread = None + self.monitoring_mpv = False + + def get_media_directories(self): + """Fetch a list of directories in the Videos folder, excluding hidden ones.""" + if not self.media_dir.exists(): + return [] + + directories = sorted([f for f in self.media_dir.iterdir() if f.is_dir() and not f.name.startswith('.')]) + return directories + + def get_directory_content(self): + """Fetch a list of directories and media files in the current folder.""" + if not self.media_dir.exists(): + return [] + + files = sorted([f for f in self.media_dir.iterdir() if (f.is_dir() or f.suffix.lower() in ['.mp4', '.mkv', '.avi', '.mov']) and not f.name.startswith('.')]) + return files + + def render(self, window): + """Render different views based on the current state.""" + self.window = window # Store the current window + if self.current_view == "dashboard": + self.render_dashboard(window) + elif self.current_view == "explorer": + self.render_file_explorer(window) + elif self.current_view == "player": + self.render_player(window) + + def render_dashboard(self, window): + """Render the Videos directories in the dashboard view.""" + window.clear() + window.box() + window.addstr(1, 2, "Video Directories:") + + # Show directories without selection + start_y = 3 + for idx, item in enumerate(self.file_list): + display_text = f"{item.name}" + window.addstr(start_y + idx, 2, display_text) + + window.refresh() + logging.debug(f"Rendered Dashboard with directories: {self.file_list}") + + def render_file_explorer(self, window): + """Render the file explorer view, allowing navigation through the Videos directory.""" + window.clear() + window.box() + window.addstr(1, 2, "File Explorer - Navigate using j/k, Enter to open/play, Backspace to go back") + + max_y, max_x = window.getmaxyx() + start_y = 3 + visible_items = max_y - start_y - 2 # Account for window borders and title + + # Calculate the visible slice of file list based on selected_index + start_index = max(0, self.selected_index - (visible_items // 2)) + end_index = min(len(self.file_list), start_index + visible_items) + + # Render only the visible portion of the file list + for idx in range(start_index, end_index): + item = self.file_list[idx] + display_text = f"{item.name}" + if idx == self.selected_index: + window.addstr(start_y + (idx - start_index), 2, display_text, curses.A_REVERSE) + else: + window.addstr(start_y + (idx - start_index), 2, display_text) + + window.refresh() + + def render_player(self, window): + """Render the media player interface in player mode.""" + window.clear() + window.box() + height, width = window.getmaxyx() + + # Display video information and metadata + title = self.current_media_info.get('title', 'Unknown Video') + file_path = self.current_media_info.get('file_path', '') + general_track = self.current_media_info.get('general_track', {}) + video_track = self.current_media_info.get('video_track', {}) + audio_track = self.current_media_info.get('audio_track', {}) + + window.addstr(2, 2, f"Now Playing: {title}") + window.addstr(3, 2, f"File: {file_path}") + + y = 5 # Starting y position for metadata + + # General metadata + duration = general_track.get('duration') + file_size = general_track.get('file_size') + if duration: + duration_sec = float(duration) / 1000 + window.addstr(y, 2, f"Duration: {duration_sec:.2f} sec") + y += 1 + if file_size: + window.addstr(y, 2, f"File Size: {int(file_size) / (1024 * 1024):.2f} MB") + y += 1 + + # Video metadata + width_v = video_track.get('width') + height_v = video_track.get('height') + frame_rate = video_track.get('frame_rate') + codec = video_track.get('format') + if codec: + window.addstr(y, 2, f"Video Codec: {codec}") + y += 1 + if width_v and height_v: + window.addstr(y, 2, f"Resolution: {width_v}x{height_v}") + y += 1 + if frame_rate: + window.addstr(y, 2, f"Frame Rate: {frame_rate} fps") + y += 1 + + # Audio metadata + audio_codec = audio_track.get('format') + channels = audio_track.get('channel_s') + sample_rate = audio_track.get('sampling_rate') + if audio_codec: + window.addstr(y, 2, f"Audio Codec: {audio_codec}") + y += 1 + if channels: + window.addstr(y, 2, f"Channels: {channels}") + y += 1 + if sample_rate: + window.addstr(y, 2, f"Sample Rate: {sample_rate} Hz") + y += 1 + + window.addstr(height - 3, 2, "Press Backspace to return to File Explorer") + window.refresh() + + def handle_keypress(self, key): + """Handle keypress actions based on current view.""" + if self.current_view == "dashboard" and key == ord('\n'): + # Switch to explorer when Enter is pressed + self.current_view = "explorer" + self.media_dir = Path.home() / "Videos" + self.file_list = self.get_directory_content() + self.selected_index = 0 + return True + elif self.current_view == "explorer": + return self.handle_explorer_keypress(key) + elif self.current_view == "player": + return self.handle_player_keypress(key) + return False + + def handle_explorer_keypress(self, key): + """Handle keypress actions in the file explorer.""" + handled = False + if key == ord('j'): + if self.selected_index < len(self.file_list) - 1: + self.selected_index += 1 + self.render_file_explorer(self.window) + handled = True + elif key == ord('k'): + if self.selected_index > 0: + self.selected_index -= 1 + self.render_file_explorer(self.window) + handled = True + elif key == ord('\n'): # Enter key to open directory or play file + selected_item = self.file_list[self.selected_index] + logging.debug(f"Selected Item: {selected_item}") + if selected_item.is_dir(): + self.media_dir = selected_item + self.file_list = self.get_directory_content() + self.selected_index = 0 + logging.debug(f"Opened directory: {self.media_dir}") + self.render(self.window) + else: + # Build playlist + self.playlist = [f for f in self.file_list if f.is_file() and f.suffix.lower() in ['.mp4', '.mkv', '.avi', '.mov']] + # Find index of selected item in playlist + self.current_media_index = self.playlist.index(selected_item) + self.play_media_file(self.playlist[self.current_media_index]) + self.current_view = "player" + logging.debug(f"Playing file: {selected_item}") + self.render(self.window) + handled = True + elif key in (curses.KEY_BACKSPACE, ord('\b'), 127): + if self.media_dir == Path.home() / "Videos": + self.current_view = "dashboard" + self.file_list = self.get_media_directories() + else: + self.media_dir = self.media_dir.parent + self.file_list = self.get_directory_content() + self.selected_index = 0 + self.render(self.window) + handled = True + return handled + + def handle_player_keypress(self, key): + """Handle keypress actions in the player view.""" + if key in (curses.KEY_BACKSPACE, ord('\b'), 127): + self.current_view = "explorer" + self.render(self.window) + return True + return False + + def play_media_file(self, file_path): + """Play the selected media file using MPV.""" + if self.player_process and self.player_process.poll() is None: + self.player_process.terminate() # Stop any currently playing media + + # Generate a unique IPC socket path + self.ipc_socket = f"/tmp/mpv_socket_{os.getpid()}" + + # Use MPV to play the file with IPC enabled and full-screen mode + self.player_process = subprocess.Popen( + ["mpv", "--fs", "--quiet", f"--input-ipc-server={self.ipc_socket}", str(file_path)], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + # **Ensure media_title is defined here** + media_title = file_path.stem # Extract the file name without extension + + # Extract media info using pymediainfo + try: + media_info = MediaInfo.parse(str(file_path)) + general_track = media_info.general_tracks[0] if media_info.general_tracks else None + video_track = next((t for t in media_info.video_tracks), None) + audio_track = next((t for t in media_info.audio_tracks), None) + except Exception as e: + logging.error(f"Error extracting media info: {e}") + general_track = video_track = audio_track = None + + # Store metadata + self.current_media_info = { + 'title': str(media_title), + 'file_path': str(file_path), + 'general_track': general_track.to_data() if general_track else {}, + 'video_track': video_track.to_data() if video_track else {}, + 'audio_track': audio_track.to_data() if audio_track else {}, + } + + self.playback_start_time = time.time() + self.player_paused = False + + # Start monitoring MPV events + self.monitoring_mpv = True + self.mpv_event_thread = threading.Thread(target=self.monitor_mpv_events) + self.mpv_event_thread.start() + + def monitor_mpv_events(self): + """Monitor MPV events to detect playback completion or user quit.""" + if not self.ipc_socket: + return + + # Wait for the IPC socket to be available and ready + timeout = 10 # Increase timeout to 10 seconds + start_time = time.time() + while time.time() - start_time < timeout: + if os.path.exists(self.ipc_socket): + try: + test_client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + test_client.connect(self.ipc_socket) + test_client.close() + break # Connection successful + except ConnectionRefusedError: + time.sleep(0.1) + else: + time.sleep(0.1) + else: + logging.error("MPV IPC socket not available or connection refused.") + return + + try: + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.connect(self.ipc_socket) + client.settimeout(1.0) + + # Send a request to observe property changes + #command = {'command': ['observe_property', 1, 'eof-reached']} + #client.sendall((json.dumps(command) + '\n').encode('utf-8')) + + buffer = '' + while self.monitoring_mpv: + try: + data = client.recv(4096).decode('utf-8') + if data: + buffer += data + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + if line.strip() == '': + continue + try: + message = json.loads(line.strip()) + except json.JSONDecodeError as e: + logging.error(f"JSON decode error: {e}") + continue + event = message.get('event') + if event == 'idle': + # Playback ended naturally + self.handle_playback_end() + return # Exit the thread + else: + time.sleep(0.1) + except socket.timeout: + continue + except Exception as e: + logging.error(f"Error in MPV event monitoring: {e}") + break + + client.close() + except Exception as e: + logging.error(f"Error connecting to MPV IPC socket: {e}") + + def handle_playback_end(self): + """Handle actions after playback ends naturally.""" + # Clean up current playback + self.stop_media(clean_ipc=False) # We will reuse the IPC socket + + if self.playlist and self.current_media_index is not None: + if self.current_media_index + 1 < len(self.playlist): + self.current_media_index += 1 + self.play_media_file(self.playlist[self.current_media_index]) + else: + # No more media in playlist, return to player view + self.current_view = "player" + self.render(self.window) + else: + self.current_view = "player" + self.render(self.window) + + def check_playback_status(self): + """Check if the media has finished playing or was stopped by the user.""" + if self.player_process and self.player_process.poll() is not None: + # Player exited + self.monitoring_mpv = False + if self.mpv_event_thread and self.mpv_event_thread.is_alive(): + self.mpv_event_thread.join() + return_code = self.player_process.returncode + self.player_process = None + self.playback_start_time = None + self.player_paused = False + + if return_code == 0: + # Assume natural end (since we handle natural end via events) + #self.current_view = "player" + #self.render(self.window) + pass + else: + # User quit MPV + self.current_view = "player" + self.render(self.window) + + def stop_media(self, clean_ipc=True): + """Stop the currently playing media.""" + if self.player_process and self.player_process.poll() is None: + self.player_process.terminate() + self.player_process.wait() + self.player_process = None + if clean_ipc and self.ipc_socket and os.path.exists(self.ipc_socket): + os.remove(self.ipc_socket) + self.playback_start_time = None + self.player_paused = False + self.current_media_info = {} + self.monitoring_mpv = False + if self.mpv_event_thread and self.mpv_event_thread.is_alive(): + self.mpv_event_thread.join() + + def cleanup(self): + """Clean up resources before exiting.""" + self.stop_media() diff --git a/src/local_music.py b/src/local_music.py new file mode 100644 index 0000000..044bf13 --- /dev/null +++ b/src/local_music.py @@ -0,0 +1,428 @@ +# local_music.py + +import curses +import os +import subprocess +from pathlib import Path +import logging +import time +from mutagen import File as MutagenFile +import signal +from mutagen.id3 import ID3, APIC +from mutagen.mp4 import MP4Cover +from PIL import Image +import io + +logging.basicConfig( + filename="local_music_debug.log", + level=logging.DEBUG, + format="%(asctime)s - %(levelname)s - %(message)s" +) + +class LocalMusicPlayer: + def __init__(self, stdscr): + self.stdscr = stdscr + self.music_dir = Path.home() / "Music" + self.selected_index = 0 + self.file_list = self.get_music_directories() # Start with directories only + self.player_process = None + self.current_view = "dashboard" # Options: dashboard, explorer, player + self.button_regions = {} + self.player_paused = False + self.current_track_info = {} + self.playback_start_time = None + self.pause_time = None + self.playlist = [] + self.current_track_index = None + + def get_music_directories(self): + """Fetch a list of directories in the Music folder, excluding hidden ones.""" + if not self.music_dir.exists(): + return [] + + directories = sorted([f for f in self.music_dir.iterdir() if f.is_dir() and not f.name.startswith('.')]) + return directories + + def get_directory_content(self): + """Fetch a list of directories and music files in the current folder.""" + if not self.music_dir.exists(): + return [] + + files = sorted([f for f in self.music_dir.iterdir() if (f.is_dir() or f.suffix in ['.mp3', '.flac', '.wav']) and not f.name.startswith('.')]) + return files + + def render(self, window): + """Render different views based on the current state.""" + self.window = window # Store the current window + if self.current_view == "dashboard": + self.render_dashboard(window) + elif self.current_view == "explorer": + self.render_file_explorer(window) + elif self.current_view == "player": + self.render_player(window) + + def render_dashboard(self, window): + """Render the Music directories in the dashboard view.""" + window.clear() + window.box() + window.addstr(1, 2, "Music Directories:") + + # Show directories without selection + start_y = 3 + for idx, item in enumerate(self.file_list): + display_text = f"{item.name}" + window.addstr(start_y + idx, 2, display_text) + + window.refresh() + logging.debug(f"Rendered Dashboard with directories: {self.file_list}") + + def stop_media(self): + """Stop the currently playing music.""" + if self.player_process and self.player_process.poll() is None: + self.player_process.terminate() + self.player_process.wait() + self.player_process = None + self.playback_start_time = None + self.player_paused = False + self.current_track_index = None + self.current_track_info = {} + + + def render_file_explorer(self, window): + """Render the file explorer view, allowing navigation through the Music directory.""" + window.clear() + window.box() + window.addstr(1, 2, "File Explorer - Navigate using j/k, Enter to open/play, Backspace to go back") + + max_y, max_x = window.getmaxyx() + start_y = 3 + visible_items = max_y - start_y - 2 # Account for window borders and title + + # Calculate the visible slice of file list based on selected_index + start_index = max(0, self.selected_index - (visible_items // 2)) + end_index = min(len(self.file_list), start_index + visible_items) + + # Render only the visible portion of the file list + for idx in range(start_index, end_index): + item = self.file_list[idx] + display_text = f"{item.name}" + if idx == self.selected_index: + window.addstr(start_y + (idx - start_index), 2, display_text, curses.A_REVERSE) + else: + window.addstr(start_y + (idx - start_index), 2, display_text) + + window.refresh() + + def render_player(self, window): + """Render the music player interface in player mode.""" + window.clear() + window.box() + height, width = window.getmaxyx() + + # Get track info + track_title = self.current_track_info.get('title', 'Unknown Track') + album_name = self.current_track_info.get('album', 'Unknown Album') + track_length = self.current_track_info.get('length', 0) + album_art_image = self.current_track_info.get('album_art_image', None) + elapsed_time = time.time() - self.playback_start_time if self.playback_start_time else 0 + if self.player_paused and self.pause_time: + elapsed_time = self.pause_time - self.playback_start_time + + # Format times + def format_time(seconds): + mins = int(seconds) // 60 + secs = int(seconds) % 60 + return f"{mins}:{secs:02d}" + + elapsed_str = format_time(elapsed_time) + total_str = format_time(track_length) + + # Progress Bar + progress_bar_length = width - 4 + progress = elapsed_time / track_length if track_length else 0 + filled_length = int(progress_bar_length * progress) + progress_bar = '[' + '#' * filled_length + ' ' * (progress_bar_length - filled_length) + ']' + + # Album Art Display + album_art_width = min(40, width - 4) # Adjust width as needed + art_x = 2 + art_y = 2 + + if album_art_image: + ascii_art = self.get_ascii_art(album_art_image, album_art_width) + for i, line in enumerate(ascii_art): + if art_y + i < height - 10: + window.addstr(art_y + i, art_x, line) + else: + # Placeholder for no album art + window.addstr(art_y + 5, art_x + album_art_width // 2 - 5, "No Album Art") + + # Now Playing Info + info_x = art_x + info_y = art_y + (len(ascii_art) if album_art_image else 10) + 1 + if info_y + 5 < height - 5: + window.addstr(info_y, info_x, f"Now Playing: {track_title}") + window.addstr(info_y + 1, info_x, f"Album: {album_name}") + # Display progress bar and times + window.addstr(info_y + 3, info_x, progress_bar) + window.addstr(info_y + 4, info_x, f"{elapsed_str} / {total_str}") + + # Display controls + controls_text = " [B] Back [P] Play/Pause [N] Next " + controls_y = height - 3 + controls_x = (width // 2) - (len(controls_text) // 2) + window.addstr(controls_y, controls_x, controls_text, curses.A_BOLD) + + # Store button positions for mouse interaction + self.button_regions.clear() + button_labels = ["[B]", "[P]", "[N]"] + button_actions = ["back", "play_pause", "next"] + for label, action in zip(button_labels, button_actions): + idx = controls_text.find(label) + btn_x = controls_x + idx + btn_y = controls_y + btn_width = len(label) + self.button_regions[action] = (btn_y, btn_x, btn_width) + + window.refresh() + + def handle_keypress(self, key): + """Handle keypress actions based on current view.""" + if self.current_view == "dashboard" and key == ord('\n'): + # Switch to explorer when Enter is pressed + self.current_view = "explorer" + self.music_dir = Path.home() / "Music" + self.file_list = self.get_directory_content() + self.selected_index = 0 + return True + elif self.current_view == "explorer": + return self.handle_explorer_keypress(key) + elif self.current_view == "player": + return self.handle_player_keypress(key) + return False + + def handle_explorer_keypress(self, key): + """Handle keypress actions in the file explorer.""" + logging.debug(f"Key Pressed: {key}, Current View: {self.current_view}, Selected Index: {self.selected_index}, Current Directory: {self.music_dir}") + handled = False + if key == ord('j'): + if self.selected_index < len(self.file_list) - 1: + self.selected_index += 1 + logging.debug(f"Moved down to index: {self.selected_index}") + else: + logging.debug("Reached the end of the list, cannot move down.") + self.render_file_explorer(self.window) + handled = True + + elif key == ord('k'): + if self.selected_index > 0: + self.selected_index -= 1 + logging.debug(f"Moved up to index: {self.selected_index}") + else: + logging.debug("Reached the top of the list, cannot move up.") + self.render_file_explorer(self.window) + handled = True + + elif key == ord('\n'): # Enter key to open directory or play file + selected_item = self.file_list[self.selected_index] + logging.debug(f"Selected Item: {selected_item}") + if selected_item.is_dir(): + self.music_dir = selected_item + self.file_list = self.get_directory_content() + self.selected_index = 0 + logging.debug(f"Opened directory: {self.music_dir}") + else: + # build a fucking playlist + self.playlist = [f for f in self.file_list if f.is_file() and f.suffix in ['.mp3', '.flac', '.wav']] + # Find index of selected item in the fucking playlist + self.current_track_index = self.playlist.index(selected_item) + self.play_music_file(self.playlist[self.current_track_index]) + self.current_view = "player" + logging.debug(f"Playing file: {selected_item}") + self.render(self.window) + handled = True + + #elif key == curses.KEY_BACKSPACE: + # logging.debug(f"Backspace pressed. Current Directory: {self.music_dir}") + # if self.music_dir == Path.home() / "Music": + # self.current_view = "dashboard" + # self.file_list = self.get_music_directories() + # logging.debug("Back to dashboard view (root Music directory).") + # else: + # self.music_dir = self.music_dir.parent + # self.file_list = self.get_directory_content() + # self.selected_index = 0 + # if self.player_process: + # self.stop_music() + # logging.debug(f"Moved up to parent directory: {self.music_dir}") + # self.render_file_explorer(self.stdscr) + elif key in (curses.KEY_BACKSPACE, ord('\b'), 127): + logging.debug(f"Backspace pressed. Current Directory: {self.music_dir}") + if self.music_dir == Path.home() / "Music": + self.current_view = "dashboard" + self.file_list = self.get_music_directories() + logging.debug("Back to dashboard view (root Music directory).") + self.render(self.window) # Render dashboard view + else: + self.music_dir = self.music_dir.parent + self.file_list = self.get_directory_content() + self.selected_index = 0 + if self.player_process: + self.stop_music() + logging.debug(f"Moved up to parent directory: {self.music_dir}") + self.render_file_explorer(self.window) + handled = True + return handled + + def handle_mouse(self, event): + """Handle mouse clicks in the player view.""" + _, x, y, _, button = event + + if button == curses.BUTTON1_CLICKED: + for action, (btn_y, btn_x, btn_width) in self.button_regions.items(): + if y == btn_y and btn_x <= x < btn_x + btn_width: + if action == "back": + self.previous_track() + elif action == "play_pause": + self.toggle_playback() + elif action == "next": + self.next_track() + break + + def handle_player_keypress(self, key): + """Handle keypress actions in the player view.""" + if key == ord('p'): # Play/Pause + self.toggle_playback() + return True + elif key == ord('n'): # Next (Placeholder) + self.next_track() + return True + elif key == ord('b'): # Back (Placeholder) + self.previous_track() + return True + elif key == curses.KEY_BACKSPACE: + self.stop_music() + self.current_view = "explorer" + self.render(self.window) + return True + return False + + def get_ascii_art(self, img, width): + """Convert an image to ASCII art.""" + # Resize image maintaining aspect ratio + aspect_ratio = img.height / img.width + new_height = int(aspect_ratio * width * 0.55) # Adjust for terminal character dimensions + img = img.resize((width, new_height)) + img = img.convert('L') # Convert to grayscale + + pixels = img.getdata() + chars = ["@", "#", "S", "%", "?", "*", "+", ";", ":", ",", "."] + new_pixels = [chars[int(pixel / 255 * (len(chars) - 1))] for pixel in pixels] + ascii_art = [''.join(new_pixels[i:i+width]) for i in range(0, len(new_pixels), width)] + return ascii_art + + def play_music_file(self, file_path): + """Play the selected music file using MPV.""" + if self.player_process and self.player_process.poll() is None: + self.player_process.terminate() # Stop any currently playing music + + # Use MPV to play the file + self.player_process = subprocess.Popen( + ["mpv", "--no-video", "--quiet", str(file_path)], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + # Extract metadata + audio = MutagenFile(str(file_path)) + track_length = audio.info.length if audio.info else None + tags = audio.tags + + album_art_image = None # Initialize album art image + + if tags: + # Get track title and album name + track_title = tags.get('TIT2', tags.get('title', file_path.stem)) + album_name = tags.get('TALB', tags.get('album', file_path.parent.name)) + + # Attempt to extract album art + album_art_data = None + + if file_path.suffix.lower() in ['.mp3']: + # For MP3 files with ID3 tags + if 'APIC:' in tags: + # Get the first APIC frame + apic = tags.getall('APIC')[0] + album_art_data = apic.data + elif any(isinstance(tag, APIC) for tag in tags.values()): + # Alternate way to get APIC frames + for tag in tags.values(): + if isinstance(tag, APIC): + album_art_data = tag.data + break + + elif file_path.suffix.lower() in ['.m4a', '.mp4']: + # For MP4/M4A files + if 'covr' in tags: + album_art_data = tags['covr'][0] + elif file_path.suffix.lower() in ['.flac']: + # For FLAC files + if 'METADATA_BLOCK_PICTURE' in tags: + pic = tags['METADATA_BLOCK_PICTURE'][0] + album_art_data = pic.data + + # Process album art data if available + if album_art_data: + try: + album_art_image = Image.open(io.BytesIO(album_art_data)) + except Exception as e: + logging.error(f"Error processing album art: {e}") + album_art_image = None + else: + track_title = file_path.stem + album_name = file_path.parent.name + album_art_image = None + + self.current_track_info = { + 'title': str(track_title), + 'album': str(album_name), + 'length': track_length, + 'album_art_image': album_art_image, + 'file_path': file_path + } + + self.playback_start_time = time.time() + self.player_paused = False + + def next_track(self): + """Skip to the next track in the playlist.""" + if self.playlist and self.current_track_index is not None: + self.current_track_index = (self.current_track_index + 1) % len(self.playlist) + self.play_music_file(self.playlist[self.current_track_index]) + self.render(self.window) + + def previous_track(self): + """Go back to the previous track in the playlist.""" + if self.playlist and self.current_track_index is not None: + self.current_track_index = (self.current_track_index - 1) % len(self.playlist) + self.play_music_file(self.playlist[self.current_track_index]) + self.render(self.window) + + def toggle_playback(self): + """Toggle play/pause of the current track.""" + if self.player_process and self.player_process.poll() is None: + if self.player_paused: + self.player_process.send_signal(signal.SIGCONT) + self.playback_start_time += time.time() - self.pause_time # Adjust playback time + self.player_paused = False + else: + self.player_process.send_signal(signal.SIGSTOP) + self.pause_time = time.time() + self.player_paused = True + + def stop_music(self): + """Stop the currently playing music.""" + if self.player_process and self.player_process.poll() is None: + self.player_process.terminate() + self.player_process = None diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..5b382e2 --- /dev/null +++ b/src/main.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 + +# main.py + +import curses +import time +from local_music import LocalMusicPlayer +from local_media import LocalMediaPlayer +from spotify_player import SpotifyPlayer +from radio_player import RadioPlayer + +class MediaDashboardApp: + def __init__(self, stdscr): + self.stdscr = stdscr + self.monocle_mode = False + self.active_window = 0 + self.windows = [ + LocalMusicPlayer(self.stdscr), + LocalMediaPlayer(self.stdscr), + SpotifyPlayer(self.stdscr), + RadioPlayer(self.stdscr) + ] + self.window_titles = ["Local Music", "Local Media", "Spotify", "Radio"] + self.setup_curses() + + def setup_curses(self): + curses.curs_set(0) # Hide the cursor + self.stdscr.nodelay(1) + self.stdscr.timeout(100) + curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) + + def draw_tiling(self): + self.stdscr.clear() + height, width = self.stdscr.getmaxyx() + mid_y = height // 2 + mid_x = width // 2 + + # Define windows (quadrants), ensuring they fit within the screen dimensions + for idx, module in enumerate(self.windows): + if not module: + continue + # Calculate positions for quadrants + if idx == 0: + row, col = 0, 0 + win_height = mid_y + win_width = mid_x + elif idx == 1: + row, col = 0, mid_x + win_height = mid_y + win_width = width - mid_x + elif idx == 2: + row, col = mid_y, 0 + win_height = height - mid_y + win_width = mid_x + elif idx == 3: + row, col = mid_y, mid_x + win_height = height - mid_y + win_width = width - mid_x + + # Sub-window for each quadrant + subwin = self.stdscr.subwin(win_height, win_width, row, col) + subwin.box() + subwin.addstr(1, 2, self.window_titles[idx] + ":") + + # Only render Spotify quadrant if it is the active window + if isinstance(module, SpotifyPlayer) and self.active_window != 2: + continue # Skip rendering Spotify unless it’s active in monocle + + module.render(subwin) + + # Draw lines separating the windows + self.stdscr.vline(0, mid_x, curses.ACS_VLINE, height) + self.stdscr.hline(mid_y, 0, curses.ACS_HLINE, width) + + self.stdscr.refresh() + + def draw_monocle(self): + self.stdscr.clear() + width = self.stdscr.getmaxyx()[1] # Only get the width, as height is not needed + + # Draw the active window in monocle mode + module = self.windows[self.active_window] + if not module: + return + + title = self.window_titles[self.active_window] + self.stdscr.addstr(0, (width // 2) - (len(title) // 2), f"{title}:", curses.A_BOLD) + + try: + module.render(self.stdscr) + except Exception as e: + self.stdscr.addstr(1, 1, f"Error loading {title}: {str(e)}") + logging.error(f"Error rendering module {title}: {str(e)}") + self.stdscr.refresh() + + def handle_mouse(self, event): + """Handle mouse clicks and interactions.""" + if self.monocle_mode: #and self.active_window is not None: + module = self.windows[self.active_window] + if module and hasattr(module, 'handle_mouse'): + module.handle_mouse(event) + return + + _, x, y, _, button = event + + # Basic idea - Determine the clicked quadrant based on coordinates + height, width = self.stdscr.getmaxyx() + mid_y = height // 2 + mid_x = width // 2 + + if button == curses.BUTTON1_CLICKED: + if y < mid_y and x < mid_x: + self.active_window = 0 # Local Music + elif y < mid_y and x >= mid_x: + self.active_window = 1 # Local Media + elif y >= mid_y and x < mid_x: + self.active_window = 2 # Spotify + elif y >= mid_y and x >= mid_x: + self.active_window = 3 # Radio + else: + return + + module = self.windows[self.active_window] + if module is not None: + self.monocle_mode = True + # Set current_view to "explorer" for the active windows + module.current_view = "radio" if isinstance(module, RadioPlayer) else "explorer" + # Reset any selection indices if needed + module.selected_index = 0 + if isinstance(module, SpotifyPlayer): + module.current_playlist = None + self.draw_monocle() + else: + # Display a message + self.stdscr.addstr(0, 0, "This quadrant is not implemented yet.", curses.A_BOLD) + self.stdscr.refresh() + time.sleep(1) + #pass + + def cleanup(self): + """Clean up resources before exiting.""" + for module in self.windows: + if module and hasattr(module, 'stop_media'): + module.stop_media() + if module and hasattr(module, 'stop_station'): + module.stop_station() + + def handle_keypress(self, key): + """Handle keypress actions globally and pass them to active modules.""" + module = self.windows[self.active_window] + key_handled = False + if self.monocle_mode and module: + # If module handles the key, skip global handling + key_handled = module.handle_keypress(key) + + # Check if the active window wants to exit monocle mode + if hasattr(module, 'current_view') and module.current_view == "exit": + self.monocle_mode = False + self.draw_tiling() + return True + + if not key_handled: + if key == ord('q') or key == 27: # Quit on 'q' or 'Esc' + self.cleanup() + return True + elif key == ord('m'): # Monocle mode + self.monocle_mode = True + self.draw_monocle() + return True + elif key == ord('t'): # Tiling mode + self.monocle_mode = False + self.draw_tiling() + elif self.monocle_mode and key == ord('j'): + # Only change monocle window if module is in 'dashboard' view + if module.current_view == 'dashboard' and self.active_window < len(self.windows) - 1: + self.active_window += 1 + self.draw_monocle() + elif self.monocle_mode and key == ord('k'): + if module.current_view == 'dashboard' and self.active_window > 0: + self.active_window -= 1 + self.draw_monocle() + elif key == curses.KEY_MOUSE: + self.handle_mouse(curses.getmouse()) + + # Handle back to tiling mode directly if module is in 'dashboard' view + if key in (curses.KEY_BACKSPACE, ord('\b'), 127) and self.monocle_mode: + if module.current_view == 'dashboard': + self.monocle_mode = False + self.draw_tiling() + + def main_loop(self): + # Main loop to keep the screen updated + while True: + key = self.stdscr.getch() + + # Break out of loop if keypress handler requests it + if self.handle_keypress(key): + break + + # Periodically check playback status + if self.monocle_mode and self.active_window is not None: + module = self.windows[self.active_window] + if module and hasattr(module, 'check_playback_status'): + module.check_playback_status() + + # Redraw based on current mode + if self.monocle_mode: + self.draw_monocle() + else: + self.draw_tiling() + + time.sleep(0.1) # Adjust for responsiveness + +def main(stdscr): + app = MediaDashboardApp(stdscr) + app.main_loop() + +if __name__ == "__main__": + curses.wrapper(main) diff --git a/src/radio_player.py b/src/radio_player.py new file mode 100644 index 0000000..47dc766 --- /dev/null +++ b/src/radio_player.py @@ -0,0 +1,290 @@ +# radio_player.py + +import curses +import os +import subprocess +import time +import requests +import threading + +CHANNELS_FILE = os.path.expanduser("~/.local/share/media-dashboard/channels.json") + +class RadioPlayer: + def __init__(self, stdscr): + self.stdscr = stdscr + self.window = None + self.current_view = "radio" # views: radio, favorites, stations + self.volume = self.get_volume() # Get current system volume + self.stations = [] # List of stations fetched from API + self.favorites = self.load_favorites() + self.selected_index = 0 # For navigating lists + self.current_station = None # Currently playing station + self.player_process = None # mpv subprocess + self.update_thread = threading.Thread(target=self.update_volume) + self.update_thread.daemon = True + self.update_thread.start() + + def render(self, window): + self.window = window + if self.current_view == "radio": + self.render_radio(window) + elif self.current_view == "favorites": + self.render_favorites(window) + elif self.current_view == "stations": + self.render_stations(window) + + def render_radio(self, window): + window.clear() + window.box() + height, width = window.getmaxyx() + # Title + title = "Internet Radio" + window.addstr(1, (width - len(title)) // 2, title, curses.A_BOLD) + + # Display current station + if self.current_station: + station_str = f"Station: {self.current_station['name']}" + window.addstr(3, 2, station_str[:width - 4]) + else: + window.addstr(3, 2, "No station selected.") + + # Volume + volume_str = f"Volume: {self.volume}%" + window.addstr(4, 2, volume_str) + + # Instructions + instructions = "[S] Search Stations [F] Favorites [+/-] Volume [Backspace] Exit" + window.addstr(height - 2, 2, instructions[:width - 4]) + + window.refresh() + + def render_stations(self, window): + window.clear() + window.box() + height, width = window.getmaxyx() + title = "Available Stations" + window.addstr(1, (width - len(title)) // 2, title, curses.A_BOLD) + + if not self.stations: + window.addstr(3, 2, "No stations found. Press [S] to search.") + else: + start_y = 3 + visible_items = height - start_y - 3 + start_index = max(0, self.selected_index - visible_items // 2) + end_index = min(len(self.stations), start_index + visible_items) + + for idx in range(start_index, end_index): + station = self.stations[idx] + display_text = station['name'][:width - 4] + if idx == self.selected_index: + window.addstr(start_y + idx - start_index, 2, display_text, curses.A_REVERSE) + else: + window.addstr(start_y + idx - start_index, 2, display_text) + + # Instructions + instructions = "[Enter] Play [F] Add to Favorites [Backspace] Back" + window.addstr(height - 2, 2, instructions[:width - 4]) + + window.refresh() + + def render_favorites(self, window): + window.clear() + window.box() + height, width = window.getmaxyx() + title = "Favorite Stations" + window.addstr(1, (width - len(title)) // 2, title, curses.A_BOLD) + + if not self.favorites: + window.addstr(3, 2, "No favorite stations.") + else: + start_y = 3 + visible_items = height - start_y - 2 + start_index = max(0, self.selected_index - visible_items // 2) + end_index = min(len(self.favorites), start_index + visible_items) + + for idx in range(start_index, end_index): + station = self.favorites[idx] + display_text = station['name'][:width - 4] + if idx == self.selected_index: + window.addstr(start_y + idx - start_index, 2, display_text, curses.A_REVERSE) + else: + window.addstr(start_y + idx - start_index, 2, display_text) + + # Instructions + instructions = "[Enter] Play [D] Delete [Backspace] Back" + window.addstr(height - 2, 2, instructions[:width - 4]) + + window.refresh() + + def handle_keypress(self, key): + if self.current_view == "radio": + return self.handle_radio_keypress(key) + elif self.current_view == "stations": + return self.handle_stations_keypress(key) + elif self.current_view == "favorites": + return self.handle_favorites_keypress(key) + return False + + def handle_radio_keypress(self, key): + handled = False + if key == ord('s') or key == ord('S'): + self.search_stations() + self.current_view = "stations" + self.selected_index = 0 + self.render(self.window) + handled = True + elif key == ord('f') or key == ord('F'): + self.current_view = "favorites" + self.selected_index = 0 + self.render(self.window) + handled = True + elif key == ord('+'): + self.change_volume(5) + handled = True + elif key == ord('-'): + self.change_volume(-5) + handled = True + elif key in (curses.KEY_BACKSPACE, ord('\b'), 127): + # Exit to dashboard + self.stop_station() + self.current_view = "dashboard" + handled = True + return handled + + def handle_stations_keypress(self, key): + handled = False + if key == ord('j') or key == curses.KEY_DOWN: + if self.selected_index < len(self.stations) - 1: + self.selected_index += 1 + self.render_stations(self.window) + handled = True + elif key == ord('k') or key == curses.KEY_UP: + if self.selected_index > 0: + self.selected_index -= 1 + self.render_stations(self.window) + handled = True + elif key == ord('\n'): + # Play selected station + station = self.stations[self.selected_index] + self.current_station = station + self.play_station(station['url_resolved']) + self.current_view = "radio" + self.render(self.window) + handled = True + elif key == ord('f') or key == ord('F'): + # Add to favorites + station = self.stations[self.selected_index] + if station not in self.favorites: + self.favorites.append(station) + self.save_favorites() + # Display confirmation message briefly + height, width = self.window.getmaxyx() + confirmation_message = f"Added {station['name']} to favorites." + self.window.addstr(height - 2, 2, confirmation_message[:width - 4]) + self.window.refresh() + curses.napms(1500) # Pause for 1.5 seconds + self.render_stations(self.window) # Re-render stations to clear message + handled = True + elif key in (curses.KEY_BACKSPACE, ord('\b'), 127): + self.current_view = "radio" + self.render(self.window) + handled = True + return handled + + def handle_favorites_keypress(self, key): + handled = False + if key == ord('j') or key == curses.KEY_DOWN: + if self.selected_index < len(self.favorites) - 1: + self.selected_index += 1 + self.render_favorites(self.window) + handled = True + elif key == ord('k') or key == curses.KEY_UP: + if self.selected_index > 0: + self.selected_index -= 1 + self.render_favorites(self.window) + handled = True + elif key == ord('\n'): + # Play selected favorite station + station = self.favorites[self.selected_index] + self.current_station = station + self.play_station(station['url_resolved']) + self.current_view = "radio" + self.render(self.window) + handled = True + elif key == ord('d') or key == ord('D'): + # Delete favorite + del self.favorites[self.selected_index] + self.save_favorites() + if self.selected_index >= len(self.favorites) and self.selected_index > 0: + self.selected_index -= 1 + self.render_favorites(self.window) + handled = True + elif key in (curses.KEY_BACKSPACE, ord('\b'), 127): + self.current_view = "radio" + self.render(self.window) + handled = True + return handled + + def search_stations(self): + # Fetch top 50 stations from Radio Browser API + try: + response = requests.get("http://de1.api.radio-browser.info/json/stations/topclick/50") + if response.status_code == 200: + self.stations = response.json() + else: + self.stations = [] + except Exception as e: + print(f"Error fetching stations: {e}") + self.stations = [] + + def play_station(self, stream_url): + self.stop_station() + # Start mpv to play the stream + self.player_process = subprocess.Popen(['mpv', '--no-video', stream_url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + def stop_station(self): + if self.player_process: + self.player_process.terminate() + self.player_process = None + + def change_volume(self, delta): + self.volume = max(0, min(100, self.volume + delta)) + # Use amixer to change volume + subprocess.call(["amixer", "set", "Master", f"{self.volume}%"]) + self.render(self.window) + + def get_volume(self): + # Get current system volume using amixer + try: + output = subprocess.check_output(["amixer", "get", "Master"]).decode() + # Parse the output to find the volume percentage + import re + matches = re.findall(r"\[(\d+)%\]", output) + if matches: + return int(matches[0]) + except Exception as e: + print(f"Error getting volume: {e}") + return 50 # Default value if unable to get volume + + def update_volume(self): + while True: + self.volume = self.get_volume() + time.sleep(5) # Update every 5 seconds + + def load_favorites(self): + if not os.path.exists(os.path.dirname(CHANNELS_FILE)): + os.makedirs(os.path.dirname(CHANNELS_FILE)) + if os.path.isfile(CHANNELS_FILE): + import json + with open(CHANNELS_FILE, "r") as f: + return json.load(f) + else: + return [] + + def save_favorites(self): + with open(CHANNELS_FILE, "w") as f: + import json + json.dump(self.favorites, f) + + def handle_mouse(self, event): + pass # Implement mouse handling if desired diff --git a/src/spotify_player.py b/src/spotify_player.py new file mode 100644 index 0000000..fd54653 --- /dev/null +++ b/src/spotify_player.py @@ -0,0 +1,708 @@ +import curses +import os +import time +import logging +from spotipy.oauth2 import SpotifyOAuth +import spotipy +from dotenv import load_dotenv +import threading +import requests +import io +from PIL import Image + +logging.basicConfig( + filename="spotify_player_debug.log", + level=logging.DEBUG, + format="%(asctime)s - %(levelname)s - %(message)s" +) + +class SpotifyPlayer: + def __init__(self, stdscr): + self.stdscr = stdscr + self.sp = self.authenticate() + self.current_view = "explorer" # views: explorer, tracks, player, devices + self.explorer_mode = "playlists" # modes: playlists, albums + self.window = None + self.playlists = [] + self.albums = [] + self.items = [] # will hold either playlists or albums depending on mode + self.tracks = [] + self.selected_index = 0 + self.current_playlist = None + self.current_album = None + self.current_track = None + self.current_track_info = {} + self.playback_start_time = None + self.player_paused = False + self.button_regions = {} + self.volume = 50 # Default volume at 50% + self.devices = [] + self.current_device = None + self.update_playback_thread = threading.Thread(target=self.update_playback_info) + self.update_playback_thread.daemon = True + self.update_playback_thread.start() + + def authenticate(self): + """Authenticate with Spotify using OAuth.""" + load_dotenv() # Load credentials from .env file + client_id = os.getenv('SPOTIPY_CLIENT_ID') + client_secret = os.getenv('SPOTIPY_CLIENT_SECRET') + redirect_uri = os.getenv('SPOTIPY_REDIRECT_URI') + + if not client_id or not client_secret or not redirect_uri: + raise Exception("Spotify client credentials are not set properly.") + + scope = "user-library-read playlist-read-private user-read-playback-state user-modify-playback-state" + try: + sp = spotipy.Spotify(auth_manager=SpotifyOAuth( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + scope=scope + )) + return sp + except Exception as e: + logging.error(f"Error during Spotify OAuth: {e}") + return None + + def get_user_playlists(self): + """Fetch the user's playlists (only those created by the user).""" + playlists = [] + user_id = self.sp.current_user()['id'] + results = self.sp.current_user_playlists() + while results: + for item in results['items']: + if item['owner']['id'] == user_id: + playlists.append(item) + if results['next']: + results = self.sp.next(results) + else: + results = None + self.playlists = playlists + + def get_user_albums(self): + """Fetch the user's saved albums (liked albums).""" + albums = [] + results = self.sp.current_user_saved_albums() + while results: + albums.extend([item['album'] for item in results['items']]) + if results['next']: + results = self.sp.next(results) + else: + results = None + self.albums = albums + + def get_playlist_tracks(self, playlist_id): + """Fetch tracks from a playlist.""" + tracks = [] + results = self.sp.playlist_tracks(playlist_id) + while results: + tracks.extend(results['items']) + if results['next']: + results = self.sp.next(results) + else: + results = None + self.tracks = tracks + + def get_album_tracks(self, album_id): + """Fetch tracks from an album.""" + tracks = [] + results = self.sp.album_tracks(album_id) + while results: + tracks.extend(results['items']) + if results['next']: + results = self.sp.next(results) + else: + results = None + self.tracks = tracks + + def render(self, window): + """Render different views based on the current state.""" + self.window = window + if self.current_view == "explorer": + self.render_explorer(window) + elif self.current_view == "tracks": + self.render_tracks(window) + elif self.current_view == "player": + self.render_player(window) + elif self.current_view == "devices": + self.render_devices(window) + + def render_explorer(self, window): + """Render the explorer view showing playlists or albums based on mode.""" + window.clear() + window.box() + if self.explorer_mode == 'playlists': + header = "Your Playlists (Press 'A' for Albums):" + if not self.playlists: + self.get_user_playlists() + self.items = self.playlists + elif self.explorer_mode == 'albums': + header = "Your Liked Albums (Press 'P' for Playlists):" + if not self.albums: + self.get_user_albums() + self.items = self.albums + window.addstr(1, 2, header) + max_y, max_x = window.getmaxyx() + start_y = 3 + visible_items = max_y - start_y - 2 # Account for window borders and title + start_index = max(0, self.selected_index - (visible_items // 2)) + end_index = min(len(self.items), start_index + visible_items) + for idx in range(start_index, end_index): + item = self.items[idx] + if self.explorer_mode == 'playlists': + display_text = f"{item['name']}" + elif self.explorer_mode == 'albums': + display_text = f"{item['name']} - {item['artists'][0]['name']}" + truncated_text = display_text[:max_x - 4] + if idx == self.selected_index: + window.addstr(start_y + (idx - start_index), 2, truncated_text, curses.A_REVERSE) + else: + window.addstr(start_y + (idx - start_index), 2, truncated_text) + if self.current_track: + prompt = "Press [C] to view the currently playing song" + window.addstr(max_y - 2, 2, prompt[:max_x - 4]) # Truncate if necessary + window.refresh() + + def render_tracks(self, window): + """Render the tracks view showing tracks in a playlist or album.""" + window.clear() + window.box() + if self.explorer_mode == 'playlists': + header = f"Playlist: {self.current_playlist['name']}" + elif self.explorer_mode == 'albums': + header = f"Album: {self.current_album['name']}" + window.addstr(1, 2, header) + max_y, max_x = window.getmaxyx() + start_y = 3 + visible_items = max_y - start_y - 2 + start_index = max(0, self.selected_index - (visible_items // 2)) + end_index = min(len(self.tracks), start_index + visible_items) + for idx in range(start_index, end_index): + if self.explorer_mode == 'playlists': + track = self.tracks[idx]['track'] + elif self.explorer_mode == 'albums': + track = self.tracks[idx] + display_text = f"{track['name']} - {', '.join(artist['name'] for artist in track['artists'])}" + truncated_text = display_text[:max_x - 4] + if idx == self.selected_index: + window.addstr(start_y + (idx - start_index), 2, truncated_text, curses.A_REVERSE) + else: + window.addstr(start_y + (idx - start_index), 2, truncated_text) + if self.current_track: + prompt = "Press [C] to view the currently playing song" + window.addstr(max_y - 2, 2, prompt[:max_x - 4]) # Truncate if necessary + window.refresh() + + def render_player(self, window): + """Render the player view with track info and controls.""" + window.clear() + window.box() + height, width = window.getmaxyx() + # Get track info + track = self.current_track + if not track: + window.addstr(2, 2, "No track is currently playing.") + window.refresh() + return + track_name = track['name'] + artist_names = ', '.join(artist['name'] for artist in track['artists']) + album_name = track['album']['name'] + track_length = track['duration_ms'] / 1000 # Convert to seconds + album_art_url = track['album']['images'][0]['url'] if track['album']['images'] else None + + # Check if volume control is allowed + volume_control_allowed = True + if self.current_device and not self.current_device.get('volume_percent'): + volume_control_allowed = False + + # Download album art + album_art_image = None + if album_art_url: + album_art_image = self.get_album_art_image(album_art_url) + # Format times + def format_time(seconds): + mins = int(seconds) // 60 + secs = int(seconds) % 60 + return f"{mins}:{secs:02d}" + # Get playback position + playback_info = self.sp.current_playback() + elapsed_time = playback_info['progress_ms'] / 1000 if playback_info and playback_info['progress_ms'] else 0 + elapsed_str = format_time(elapsed_time) + total_str = format_time(track_length) + # Progress Bar + progress_bar_length = width - 4 + progress = elapsed_time / track_length if track_length else 0 + filled_length = int(progress_bar_length * progress) + progress_bar = '[' + '#' * filled_length + ' ' * (progress_bar_length - filled_length) + ']' + # Album Art Display + album_art_width = min(40, width - 4) + art_x = 2 + art_y = 2 + ascii_art = [] + if album_art_image: + ascii_art = self.get_ascii_art(album_art_image, album_art_width) + for i, line in enumerate(ascii_art): + if art_y + i < height - 10: + window.addstr(art_y + i, art_x, line) + else: + window.addstr(art_y + 5, art_x + album_art_width // 2 - 5, "No Album Art") + + # Now Playing Info + info_x = art_x + info_y = art_y + (len(ascii_art) if album_art_image else 10) + 1 + if info_y + 7 < height - 5: + window.addstr(info_y, info_x, f"Now Playing: {track_name}") + window.addstr(info_y + 1, info_x, f"Artist(s): {artist_names}") + window.addstr(info_y + 2, info_x, f"Album: {album_name}") + if volume_control_allowed: + window.addstr(info_y + 3, info_x, f"Volume: {self.volume}%") + else: + window.addstr(info_y + 3, info_x, "Volume: N/A (Cannot control device volume)") + window.addstr(info_y + 5, info_x, progress_bar) + window.addstr(info_y + 6, info_x, f"{elapsed_str} / {total_str}") + # Controls + controls = [ + {"label": "[B] Back", "action": "back"}, + {"label": "[P] Play/Pause", "action": "play_pause"}, + {"label": "[N] Next", "action": "next"}, + ] + + if volume_control_allowed: + controls.extend([ + {"label": "[+] Vol Up", "action": "vol_up"}, + {"label": "[-] Vol Down", "action": "vol_down"}, + ]) + + controls.append({"label": "[D] Devices", "action": "devices"}) + + # Build controls_text and store button positions + controls_text = "" + self.button_regions.clear() + controls_y = height -3 + controls_x = 2 + + for idx, control in enumerate(controls): + label = control["label"] + action = control["action"] + + if controls_text: + controls_text += " " # Add spaces between labels + controls_x += 2 # Account for spaces + + window.addstr(controls_y, controls_x, label, curses.A_BOLD) + self.button_regions[action] = (controls_y, controls_x, len(label)) + controls_text += label + controls_x += len(label) + + window.refresh() + + def render_devices(self, window): + """Render the device selection view.""" + window.clear() + window.box() + window.addstr(1, 2, "Available Devices:") + self.get_available_devices() + max_y, max_x = window.getmaxyx() + start_y = 3 + visible_items = max_y - start_y - 2 + start_index = max(0, self.selected_index - (visible_items // 2)) + end_index = min(len(self.devices), start_index + visible_items) + for idx in range(start_index, end_index): + device = self.devices[idx] + display_text = f"{device['name']} ({device['type']})" + truncated_text = display_text[:max_x - 4] + if idx == self.selected_index: + window.addstr(start_y + (idx - start_index), 2, truncated_text, curses.A_REVERSE) + else: + window.addstr(start_y + (idx - start_index), 2, truncated_text) + window.refresh() + + def get_available_devices(self): + """Fetch the list of available devices.""" + devices_info = self.sp.devices() + self.devices = devices_info['devices'] + logging.debug(f"Available devices: {self.devices}") + + def play_track(self, track_uri): + """Play a track using Spotify.""" + try: + devices = self.sp.devices() + active_devices = devices['devices'] + logging.debug(f"Active devices: {active_devices}") + + if not active_devices: + # No active devices, display a message + self.window.clear() + self.window.addstr(2, 2, "No active Spotify devices found.") + self.window.addstr(3, 2, "Please open Spotify on a device to play music.") + self.window.refresh() + time.sleep(3) + self.current_view = "tracks" + self.render(self.window) + return + else: + spotifyd_device = next((device for device in active_devices if device['name'].lower() == 'spotifyd'), None) + if spotifyd_device: + self.current_device = spotifyd_device + elif not self.current_device: + # Default to the first available device + self.current_device = active_devices[0] + + self.sp.start_playback(device_id=self.current_device['id'], uris=[track_uri]) + try: + self.sp.volume(self.volume, device_id=self.current_device['id']) + except spotipy.exceptions.SpotifyException as e: + logging.error(f"Error setting volume: {e}") + # Handle VOLUME_CONTROL_DISALLOW gracefully + if 'VOLUME_CONTROL_DISALLOW' in str(e): + logging.info("Volume control is not allowed on this device.") + else: + # For other exceptions, re-raise the error + raise e + + self.current_track = self.sp.track(track_uri) + self.current_view = "player" + self.playback_start_time = time.time() + self.player_paused = False + self.render(self.window) + + + except spotipy.exceptions.SpotifyException as e: + logging.error(f"Error playing track: {e}") + self.current_track = None + self.window.clear() + self.window.addstr(2, 2, "Error playing track.") + self.window.addstr(3, 2, "Ensure you have an active Spotify device.") + self.window.refresh() + time.sleep(3) + self.current_view = "tracks" + self.render(self.window) + + def toggle_playback(self): + """Toggle play/pause.""" + try: + playback_info = self.sp.current_playback() + if playback_info and playback_info['is_playing']: + self.sp.pause_playback(device_id=self.current_device['id']) + self.player_paused = True + else: + self.sp.start_playback(device_id=self.current_device['id']) + self.player_paused = False + self.render(self.window) + except spotipy.exceptions.SpotifyException as e: + logging.error(f"Error toggling playback: {e}") + self.window.addstr(2, 2, "Error toggling playback.") + self.window.refresh() + time.sleep(1) + + def next_track(self): + """Skip to the next track.""" + try: + self.sp.next_track(device_id=self.current_device['id']) + self.update_current_track_info() + except spotipy.exceptions.SpotifyException as e: + logging.error(f"Error skipping to next track: {e}") + self.window.addstr(2, 2, "Error skipping to next track.") + self.window.refresh() + time.sleep(1) + + def previous_track(self): + """Go back to the previous track.""" + try: + self.sp.previous_track(device_id=self.current_device['id']) + self.update_current_track_info() + except spotipy.exceptions.SpotifyException as e: + logging.error(f"Error going to previous track: {e}") + self.window.addstr(2, 2, "Error going to previous track.") + self.window.refresh() + time.sleep(1) + + def increase_volume(self): + """Increase the playback volume.""" + self.volume = min(100, self.volume + 10) + try: + if self.current_device: + self.sp.volume(self.volume, device_id=self.current_device['id']) + else: + self.sp.volume(self.volume) + except spotipy.exceptions.SpotifyException as e: + logging.error(f"Error setting volume: {e}") + # Handle VOLUME_CONTROL_DISALLOW gracefully + if 'VOLUME_CONTROL_DISALLOW' in str(e): + logging.info("Volume control is not allowed on this device.") + # Inform the user + self.window.addstr(2, 2, "Cannot control device volume.") + self.window.refresh() + time.sleep(1) + else: + # For other exceptions, re-raise the error + raise e + self.render(self.window) + + def decrease_volume(self): + """Decrease the playback volume.""" + self.volume = max(0, self.volume - 10) + try: + if self.current_device: + self.sp.volume(self.volume, device_id=self.current_device['id']) + else: + self.sp.volume(self.volume) + except spotipy.exceptions.SpotifyException as e: + logging.error(f"Error setting volume: {e}") + # Handle VOLUME_CONTROL_DISALLOW gracefully + if 'VOLUME_CONTROL_DISALLOW' in str(e): + logging.info("Volume control is not allowed on this device.") + # Inform the user + self.window.addstr(2, 2, "Cannot control device volume.") + self.window.refresh() + time.sleep(1) + else: + # For other exceptions, re-raise the error + raise e + self.render(self.window) + + def update_current_track_info(self): + """Update the current track information.""" + try: + playback_info = self.sp.current_playback() + if playback_info and playback_info['item']: + self.current_track = playback_info['item'] + if 'device' in playback_info and playback_info['device']: + self.current_device = playback_info['device'] + else: + self.current_track = None + self.current_device = None + except spotipy.exceptions.SpotifyException as e: + logging.error(f"Error updating current track info: {e}") + self.current_track = None + self.current_device = None + self.render(self.window) + + def handle_keypress(self, key): + """Handle keypress actions based on current view.""" + if self.current_view == "explorer": + return self.handle_explorer_keypress(key) + elif self.current_view == "tracks": + return self.handle_tracks_keypress(key) + elif self.current_view == "player": + return self.handle_player_keypress(key) + elif self.current_view == "devices": + return self.handle_devices_keypress(key) + return False + + def handle_explorer_keypress(self, key): + handled = False + if key == ord('j'): + if self.selected_index < len(self.items) - 1: + self.selected_index += 1 + self.render_explorer(self.window) + handled = True + elif key == ord('k'): + if self.selected_index > 0: + self.selected_index -= 1 + self.render_explorer(self.window) + handled = True + elif key == ord('a') or key == ord('A'): + # Switch to albums mode + self.explorer_mode = 'albums' + self.selected_index = 0 + self.render_explorer(self.window) + handled = True + elif key == ord('p') or key == ord('P'): + # Switch to playlists mode + self.explorer_mode = 'playlists' + self.selected_index = 0 + self.render_explorer(self.window) + handled = True + elif key == ord('\n'): + if self.explorer_mode == 'playlists': + self.current_playlist = self.items[self.selected_index] + self.get_playlist_tracks(self.current_playlist['id']) + self.selected_index = 0 + self.current_view = "tracks" + self.render(self.window) + elif self.explorer_mode == 'albums': + self.current_album = self.items[self.selected_index] + self.get_album_tracks(self.current_album['id']) + self.selected_index = 0 + self.current_view = "tracks" + self.render(self.window) + handled = True + elif key == ord('c') or key == ord('C'): + if self.current_track: + self.current_view = "player" + self.render(self.window) + else: + # Optionally display a message + self.window.addstr(2, 2, "No song is currently playing.") + self.window.refresh() + time.sleep(1) + self.render_explorer(self.window) + handled = True + elif key in (curses.KEY_BACKSPACE, ord('\b'), 127): + # Back to quadrants + # Signal to main application to exit monocle mode + self.current_view = "dashboard" + handled = True + return handled + + def handle_tracks_keypress(self, key): + handled = False + if key == ord('j'): + if self.selected_index < len(self.tracks) - 1: + self.selected_index += 1 + self.render_tracks(self.window) + handled = True + elif key == ord('k'): + if self.selected_index > 0: + self.selected_index -= 1 + self.render_tracks(self.window) + handled = True + elif key == ord('\n'): + if self.explorer_mode == 'playlists': + track = self.tracks[self.selected_index]['track'] + elif self.explorer_mode == 'albums': + track = self.tracks[self.selected_index] + track_uri = track['uri'] + self.play_track(track_uri) + handled = True + elif key == ord('d') or key == ord('D'): + self.current_view = "devices" + self.selected_index = 0 + self.render(self.window) + handled = True + elif key == ord('c') or key == ord('C'): + if self.current_track: + self.current_view = "player" + self.render(self.window) + else: + # Optionally display a message + self.window.addstr(2, 2, "No song is currently playing.") + self.window.refresh() + time.sleep(1) + self.render_tracks(self.window) + handled = True + elif key in (curses.KEY_BACKSPACE, ord('\b'), 127): + # Back to explorer (playlists or albums) + self.current_view = "explorer" + self.selected_index = 0 + self.render_explorer(self.window) + handled = True + return handled + + def handle_player_keypress(self, key): + handled = False + if key in (ord('p'), ord('P')): + self.toggle_playback() + handled = True + elif key in (ord('n'), ord('N')): + self.next_track() + handled = True + elif key in (ord('b'), ord('B')): + self.previous_track() + handled = True + elif key == ord('+'): + self.increase_volume() + handled = True + elif key == ord('-'): + self.decrease_volume() + handled = True + elif key in (ord('d'), ord('D')): + self.current_view = "devices" + self.selected_index = 0 + self.render(self.window) + handled = True + elif key in (curses.KEY_BACKSPACE, ord('\b'), 127): + self.sp.pause_playback() + self.current_view = "tracks" + self.render(self.window) + handled = True + return handled + + def handle_devices_keypress(self, key): + handled = False + if key == ord('j'): + if self.selected_index < len(self.devices) - 1: + self.selected_index += 1 + self.render_devices(self.window) + handled = True + elif key == ord('k'): + if self.selected_index > 0: + self.selected_index -= 1 + self.render_devices(self.window) + handled = True + elif key == ord('\n'): + device = self.devices[self.selected_index] + self.sp.transfer_playback(device['id'], force_play=False) + self.current_device = device + # Return to player view + self.current_view = "player" + self.render(self.window) + handled = True + elif key in (curses.KEY_BACKSPACE, ord('\b'), 127): + # Back to player view + self.current_view = "player" + self.render(self.window) + handled = True + return handled + + def handle_mouse(self, event): + _, x, y, _, button = event + if button == curses.BUTTON1_CLICKED: + for action, (btn_y, btn_x, btn_width) in self.button_regions.items(): + if y == btn_y and btn_x <= x < btn_x + btn_width: + if action == "back": + self.previous_track() + elif action == "play_pause": + self.toggle_playback() + elif action == "next": + self.next_track() + elif action == "vol_up": + self.increase_volume() + elif action == "vol_down": + self.decrease_volume() + elif action == "devices": + self.current_view = "devices" + self.selected_index = 0 + self.render(self.window) + break + + def update_playback_info(self): + """Continuously update playback information.""" + while True: + #if self.current_view == "player": + self.update_current_track_info() + time.sleep(1) + + def get_album_art_image(self, url): + """Download and return the album art image.""" + try: + response = requests.get(url) + if response.status_code != 200: + logging.error(f"Failed to download album art, status code: {response.status_code}") + return None + img_data = response.content + image = Image.open(io.BytesIO(img_data)) + logging.debug("Album art image downloaded successfully") + return image + except Exception as e: + logging.error(f"Error downloading album art: {e}") + return None + + def get_ascii_art(self, img, width): + """Convert an image to ASCII art.""" + # Resize image maintaining aspect ratio + aspect_ratio = img.height / img.width + new_height = int(aspect_ratio * width * 0.55) # Adjust for terminal character dimensions + img = img.resize((width, new_height)) + img = img.convert('L') # Convert to grayscale + pixels = img.getdata() + chars = ["@", "#", "S", "%", "?", "*", "+", ";", ":", ",", "."] + new_pixels = [chars[int(pixel / 255 * (len(chars) - 1))] for pixel in pixels] + ascii_art = [''.join(new_pixels[i:i+width]) for i in range(0, len(new_pixels), width)] + return ascii_art + + def cleanup(self): + """Clean up resources before exiting.""" + self.sp.pause_playback()