474 lines
16 KiB
Python
474 lines
16 KiB
Python
"""
|
|
EU-Utility - Spotify Controller Plugin
|
|
|
|
Control Spotify playback and display current track info.
|
|
"""
|
|
|
|
import subprocess
|
|
import platform
|
|
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout,
|
|
QLabel, QPushButton, QSlider, QProgressBar
|
|
)
|
|
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal
|
|
|
|
from plugins.base_plugin import BasePlugin
|
|
|
|
|
|
class SpotifyInfoThread(QThread):
|
|
"""Background thread to fetch Spotify info."""
|
|
info_ready = pyqtSignal(dict)
|
|
error = pyqtSignal(str)
|
|
|
|
def __init__(self, system):
|
|
super().__init__()
|
|
self.system = system
|
|
|
|
def run(self):
|
|
"""Fetch Spotify info."""
|
|
try:
|
|
if self.system == "Linux":
|
|
result = subprocess.run(
|
|
['playerctl', '--player=spotify', 'metadata', '--format',
|
|
'{{title}}|{{artist}}|{{album}}|{{position}}|{{mpris:length}}|{{status}}'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
parts = result.stdout.strip().split('|')
|
|
if len(parts) >= 6:
|
|
self.info_ready.emit({
|
|
'title': parts[0] or 'Unknown',
|
|
'artist': parts[1] or 'Unknown Artist',
|
|
'album': parts[2] or '',
|
|
'position': self._parse_time(parts[3]),
|
|
'duration': self._parse_time(parts[4]),
|
|
'is_playing': parts[5] == 'Playing'
|
|
})
|
|
return
|
|
|
|
elif self.system == "Darwin":
|
|
script = '''
|
|
tell application "Spotify"
|
|
if player state is playing then
|
|
return (name of current track) & "|" & (artist of current track) & "|" & (album of current track) & "|" & (player position) & "|" & (duration of current track / 1000) & "|Playing"
|
|
else
|
|
return (name of current track) & "|" & (artist of current track) & "|" & (album of current track) & "|" & (player position) & "|" & (duration of current track / 1000) & "|Paused"
|
|
end if
|
|
end tell
|
|
'''
|
|
result = subprocess.run(['osascript', '-e', script], capture_output=True, text=True, timeout=5)
|
|
if result.returncode == 0:
|
|
parts = result.stdout.strip().split('|')
|
|
if len(parts) >= 6:
|
|
self.info_ready.emit({
|
|
'title': parts[0] or 'Unknown',
|
|
'artist': parts[1] or 'Unknown Artist',
|
|
'album': parts[2] or '',
|
|
'position': float(parts[3]) if parts[3] else 0,
|
|
'duration': float(parts[4]) if parts[4] else 0,
|
|
'is_playing': parts[5] == 'Playing'
|
|
})
|
|
return
|
|
|
|
# Default/empty response
|
|
self.info_ready.emit({
|
|
'title': 'Not playing',
|
|
'artist': '',
|
|
'album': '',
|
|
'position': 0,
|
|
'duration': 0,
|
|
'is_playing': False
|
|
})
|
|
|
|
except Exception as e:
|
|
self.error.emit(str(e))
|
|
self.info_ready.emit({
|
|
'title': 'Not playing',
|
|
'artist': '',
|
|
'album': '',
|
|
'position': 0,
|
|
'duration': 0,
|
|
'is_playing': False
|
|
})
|
|
|
|
def _parse_time(self, time_str):
|
|
"""Parse time string to seconds."""
|
|
try:
|
|
return int(time_str) / 1000000
|
|
except:
|
|
return 0
|
|
|
|
|
|
class SpotifyControllerPlugin(BasePlugin):
|
|
"""Control Spotify playback and display current track."""
|
|
|
|
name = "Spotify"
|
|
version = "1.1.0"
|
|
author = "ImpulsiveFPS"
|
|
description = "Control Spotify and view current track info"
|
|
hotkey = "ctrl+shift+m"
|
|
|
|
def initialize(self):
|
|
"""Setup Spotify controller."""
|
|
self.system = platform.system()
|
|
self.update_timer = None
|
|
self.info_thread = None
|
|
self.current_info = {
|
|
'title': 'Not playing',
|
|
'artist': '',
|
|
'album': '',
|
|
'position': 0,
|
|
'duration': 0,
|
|
'is_playing': False
|
|
}
|
|
|
|
def get_ui(self):
|
|
"""Create Spotify controller UI."""
|
|
widget = QWidget()
|
|
layout = QVBoxLayout(widget)
|
|
layout.setSpacing(12)
|
|
|
|
# Title
|
|
title = QLabel("🎵 Spotify")
|
|
title.setStyleSheet("color: #1DB954; font-size: 18px; font-weight: bold;")
|
|
layout.addWidget(title)
|
|
|
|
# Album Art Placeholder
|
|
self.album_art = QLabel("💿")
|
|
self.album_art.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.album_art.setStyleSheet("""
|
|
QLabel {
|
|
background-color: #282828;
|
|
border-radius: 8px;
|
|
font-size: 64px;
|
|
padding: 20px;
|
|
min-height: 120px;
|
|
}
|
|
""")
|
|
layout.addWidget(self.album_art)
|
|
|
|
# Track info container
|
|
info_container = QWidget()
|
|
info_container.setStyleSheet("""
|
|
QWidget {
|
|
background-color: #1a1a1a;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
}
|
|
""")
|
|
info_layout = QVBoxLayout(info_container)
|
|
info_layout.setSpacing(4)
|
|
|
|
# Track title
|
|
self.track_label = QLabel("Not playing")
|
|
self.track_label.setStyleSheet("""
|
|
color: white;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
""")
|
|
self.track_label.setWordWrap(True)
|
|
info_layout.addWidget(self.track_label)
|
|
|
|
# Artist
|
|
self.artist_label = QLabel("")
|
|
self.artist_label.setStyleSheet("""
|
|
color: #b3b3b3;
|
|
font-size: 13px;
|
|
""")
|
|
info_layout.addWidget(self.artist_label)
|
|
|
|
# Album
|
|
self.album_label = QLabel("")
|
|
self.album_label.setStyleSheet("""
|
|
color: #666;
|
|
font-size: 11px;
|
|
""")
|
|
info_layout.addWidget(self.album_label)
|
|
|
|
layout.addWidget(info_container)
|
|
|
|
# Time info
|
|
time_layout = QHBoxLayout()
|
|
self.position_label = QLabel("0:00")
|
|
self.position_label.setStyleSheet("color: #888; font-size: 11px;")
|
|
time_layout.addWidget(self.position_label)
|
|
|
|
time_layout.addStretch()
|
|
|
|
self.duration_label = QLabel("0:00")
|
|
self.duration_label.setStyleSheet("color: #888; font-size: 11px;")
|
|
time_layout.addWidget(self.duration_label)
|
|
|
|
layout.addLayout(time_layout)
|
|
|
|
# Progress bar
|
|
self.progress = QProgressBar()
|
|
self.progress.setRange(0, 100)
|
|
self.progress.setValue(0)
|
|
self.progress.setTextVisible(False)
|
|
self.progress.setStyleSheet("""
|
|
QProgressBar {
|
|
background-color: #404040;
|
|
border: none;
|
|
height: 4px;
|
|
border-radius: 2px;
|
|
}
|
|
QProgressBar::chunk {
|
|
background-color: #1DB954;
|
|
border-radius: 2px;
|
|
}
|
|
""")
|
|
layout.addWidget(self.progress)
|
|
|
|
# Control buttons
|
|
btn_layout = QHBoxLayout()
|
|
btn_layout.setSpacing(15)
|
|
btn_layout.addStretch()
|
|
|
|
# Previous
|
|
prev_btn = QPushButton("⏮")
|
|
prev_btn.setFixedSize(50, 50)
|
|
prev_btn.setStyleSheet(self._get_button_style("#404040"))
|
|
prev_btn.clicked.connect(self._previous_track)
|
|
btn_layout.addWidget(prev_btn)
|
|
|
|
# Play/Pause
|
|
self.play_btn = QPushButton("▶")
|
|
self.play_btn.setFixedSize(60, 60)
|
|
self.play_btn.setStyleSheet(self._get_play_button_style())
|
|
self.play_btn.clicked.connect(self._toggle_playback)
|
|
btn_layout.addWidget(self.play_btn)
|
|
|
|
# Next
|
|
next_btn = QPushButton("⏭")
|
|
next_btn.setFixedSize(50, 50)
|
|
next_btn.setStyleSheet(self._get_button_style("#404040"))
|
|
next_btn.clicked.connect(self._next_track)
|
|
btn_layout.addWidget(next_btn)
|
|
|
|
btn_layout.addStretch()
|
|
layout.addLayout(btn_layout)
|
|
|
|
# Volume
|
|
volume_layout = QHBoxLayout()
|
|
volume_layout.addWidget(QLabel("🔈"))
|
|
|
|
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
|
|
self.volume_slider.setRange(0, 100)
|
|
self.volume_slider.setValue(50)
|
|
self.volume_slider.setStyleSheet("""
|
|
QSlider::groove:horizontal {
|
|
background: #404040;
|
|
height: 4px;
|
|
border-radius: 2px;
|
|
}
|
|
QSlider::handle:horizontal {
|
|
background: #fff;
|
|
width: 12px;
|
|
margin: -4px 0;
|
|
border-radius: 6px;
|
|
}
|
|
QSlider::sub-page:horizontal {
|
|
background: #1DB954;
|
|
border-radius: 2px;
|
|
}
|
|
""")
|
|
self.volume_slider.valueChanged.connect(self._set_volume)
|
|
volume_layout.addWidget(self.volume_slider)
|
|
|
|
volume_layout.addWidget(QLabel("🔊"))
|
|
layout.addLayout(volume_layout)
|
|
|
|
# Status
|
|
self.status_label = QLabel("Click play to control Spotify")
|
|
self.status_label.setStyleSheet("color: #666; font-size: 10px;")
|
|
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(self.status_label)
|
|
|
|
layout.addStretch()
|
|
|
|
# Start update timer
|
|
self._start_timer()
|
|
|
|
return widget
|
|
|
|
def _get_button_style(self, color):
|
|
"""Get button stylesheet."""
|
|
return f"""
|
|
QPushButton {{
|
|
background-color: {color};
|
|
color: white;
|
|
font-size: 18px;
|
|
border: none;
|
|
border-radius: 25px;
|
|
}}
|
|
QPushButton:hover {{
|
|
background-color: #505050;
|
|
}}
|
|
QPushButton:pressed {{
|
|
background-color: #303030;
|
|
}}
|
|
"""
|
|
|
|
def _get_play_button_style(self):
|
|
"""Get play button style (green)."""
|
|
return """
|
|
QPushButton {
|
|
background-color: #1DB954;
|
|
color: white;
|
|
font-size: 22px;
|
|
border: none;
|
|
border-radius: 30px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #1ed760;
|
|
}
|
|
QPushButton:pressed {
|
|
background-color: #1aa34a;
|
|
}
|
|
"""
|
|
|
|
def _start_timer(self):
|
|
"""Start status update timer."""
|
|
self.update_timer = QTimer()
|
|
self.update_timer.timeout.connect(self._fetch_spotify_info)
|
|
self.update_timer.start(1000)
|
|
|
|
def _fetch_spotify_info(self):
|
|
"""Fetch Spotify info in background."""
|
|
if self.info_thread and self.info_thread.isRunning():
|
|
return
|
|
|
|
self.info_thread = SpotifyInfoThread(self.system)
|
|
self.info_thread.info_ready.connect(self._update_ui)
|
|
self.info_thread.start()
|
|
|
|
def _update_ui(self, info):
|
|
"""Update UI with Spotify info."""
|
|
self.current_info = info
|
|
|
|
# Update track info
|
|
self.track_label.setText(info.get('title', 'Unknown'))
|
|
self.artist_label.setText(info.get('artist', ''))
|
|
self.album_label.setText(info.get('album', ''))
|
|
|
|
# Update play button
|
|
is_playing = info.get('is_playing', False)
|
|
self.play_btn.setText("⏸" if is_playing else "▶")
|
|
|
|
# Update time
|
|
position = info.get('position', 0)
|
|
duration = info.get('duration', 0)
|
|
|
|
self.position_label.setText(self._format_time(position))
|
|
self.duration_label.setText(self._format_time(duration))
|
|
|
|
# Update progress bar
|
|
if duration > 0:
|
|
progress = int((position / duration) * 100)
|
|
self.progress.setValue(progress)
|
|
else:
|
|
self.progress.setValue(0)
|
|
|
|
def _format_time(self, seconds):
|
|
"""Format seconds to mm:ss."""
|
|
try:
|
|
minutes = int(seconds) // 60
|
|
secs = int(seconds) % 60
|
|
return f"{minutes}:{secs:02d}"
|
|
except:
|
|
return "0:00"
|
|
|
|
def _send_media_key(self, key):
|
|
"""Send media key press to system."""
|
|
try:
|
|
if self.system == "Windows":
|
|
import ctypes
|
|
key_codes = {
|
|
'play': 0xB3,
|
|
'next': 0xB0,
|
|
'prev': 0xB1,
|
|
}
|
|
if key in key_codes:
|
|
ctypes.windll.user32.keybd_event(key_codes[key], 0, 0, 0)
|
|
ctypes.windll.user32.keybd_event(key_codes[key], 0, 2, 0)
|
|
return True
|
|
|
|
elif self.system == "Linux":
|
|
cmd_map = {
|
|
'play': ['playerctl', '--player=spotify', 'play-pause'],
|
|
'next': ['playerctl', '--player=spotify', 'next'],
|
|
'prev': ['playerctl', '--player=spotify', 'previous'],
|
|
}
|
|
if key in cmd_map:
|
|
subprocess.run(cmd_map[key], capture_output=True)
|
|
return True
|
|
|
|
elif self.system == "Darwin":
|
|
cmd_map = {
|
|
'play': ['osascript', '-e', 'tell application "Spotify" to playpause'],
|
|
'next': ['osascript', '-e', 'tell application "Spotify" to next track'],
|
|
'prev': ['osascript', '-e', 'tell application "Spotify" to previous track'],
|
|
}
|
|
if key in cmd_map:
|
|
subprocess.run(cmd_map[key], capture_output=True)
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"Error sending media key: {e}")
|
|
|
|
return False
|
|
|
|
def _toggle_playback(self):
|
|
"""Toggle play/pause."""
|
|
if self._send_media_key('play'):
|
|
self.current_info['is_playing'] = not self.current_info.get('is_playing', False)
|
|
self.play_btn.setText("⏸" if self.current_info['is_playing'] else "▶")
|
|
self.status_label.setText("Command sent to Spotify")
|
|
else:
|
|
self.status_label.setText("❌ Could not control Spotify")
|
|
|
|
def _next_track(self):
|
|
"""Next track."""
|
|
if self._send_media_key('next'):
|
|
self.status_label.setText("⏭ Next track")
|
|
else:
|
|
self.status_label.setText("❌ Could not skip")
|
|
|
|
def _previous_track(self):
|
|
"""Previous track."""
|
|
if self._send_media_key('prev'):
|
|
self.status_label.setText("⏮ Previous track")
|
|
else:
|
|
self.status_label.setText("❌ Could not go back")
|
|
|
|
def _set_volume(self, value):
|
|
"""Set volume (0-100)."""
|
|
try:
|
|
if self.system == "Linux":
|
|
subprocess.run(['playerctl', '--player=spotify', 'volume', str(value / 100)], capture_output=True)
|
|
except:
|
|
pass
|
|
|
|
def on_hotkey(self):
|
|
"""Toggle play/pause with hotkey."""
|
|
self._toggle_playback()
|
|
|
|
def on_hide(self):
|
|
"""Stop timer when overlay hidden."""
|
|
if self.update_timer:
|
|
self.update_timer.stop()
|
|
|
|
def on_show(self):
|
|
"""Restart timer when overlay shown."""
|
|
if self.update_timer:
|
|
self.update_timer.start()
|
|
self._fetch_spotify_info()
|
|
|
|
def shutdown(self):
|
|
"""Cleanup."""
|
|
if self.update_timer:
|
|
self.update_timer.stop()
|
|
if self.info_thread and self.info_thread.isRunning():
|
|
self.info_thread.wait()
|