Initial Commit

This commit is contained in:
klein panic
2024-10-25 20:10:38 -04:00
commit 855fdfd414
10 changed files with 2219 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
venv/

22
LICENSE Normal file
View File

@@ -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.

140
README.md Normal file
View File

@@ -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.

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
psutil
python-mpv
curses-menu
pymediainfo
mutagen
pillow
spotipy
python-dotenv

4
src/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
backups/
local_music_debug.log
__pycache__/
.cache

398
src/local_media.py Normal file
View File

@@ -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()

428
src/local_music.py Normal file
View File

@@ -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

219
src/main.py Normal file
View File

@@ -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 its 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)

290
src/radio_player.py Normal file
View File

@@ -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

708
src/spotify_player.py Normal file
View File

@@ -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()