mirror of
https://github.com/kleinpanic/Media-Tui.git
synced 2025-10-27 15:05:33 -04:00
Initial Commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
venv/
|
||||||
22
LICENSE
Normal file
22
LICENSE
Normal 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
140
README.md
Normal 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
8
requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
psutil
|
||||||
|
python-mpv
|
||||||
|
curses-menu
|
||||||
|
pymediainfo
|
||||||
|
mutagen
|
||||||
|
pillow
|
||||||
|
spotipy
|
||||||
|
python-dotenv
|
||||||
4
src/.gitignore
vendored
Normal file
4
src/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
backups/
|
||||||
|
local_music_debug.log
|
||||||
|
__pycache__/
|
||||||
|
.cache
|
||||||
398
src/local_media.py
Normal file
398
src/local_media.py
Normal 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
428
src/local_music.py
Normal 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
219
src/main.py
Normal 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 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)
|
||||||
290
src/radio_player.py
Normal file
290
src/radio_player.py
Normal 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
708
src/spotify_player.py
Normal 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()
|
||||||
Reference in New Issue
Block a user