244 lines
8.6 KiB
Python
244 lines
8.6 KiB
Python
from gi.repository import Playerctl, GLib
|
|
from fabric.utils import bulk_connect
|
|
from fabric.core.service import Service, Signal
|
|
from fabric import Fabricator
|
|
|
|
from config.data import ALLOWED_PLAYERS
|
|
|
|
import gi
|
|
|
|
gi.require_version("Playerctl", "2.0")
|
|
|
|
|
|
class PlayerService(Service):
|
|
@Signal
|
|
def shuffle_toggle(self, player: Playerctl.Player, status: bool) -> None: ...
|
|
|
|
@Signal
|
|
def meta_change(self, metadata: GLib.Variant, player: Playerctl.Player) -> None: ...
|
|
|
|
@Signal
|
|
def pause(self) -> None: ...
|
|
|
|
@Signal
|
|
def play(self) -> None: ...
|
|
|
|
@Signal
|
|
def track_position(self, pos: float, dur: float) -> None: ...
|
|
|
|
def __init__(self, player: Playerctl.Player, **kwargs):
|
|
self._player: Playerctl.Player = player
|
|
super().__init__(**kwargs)
|
|
if player.props.player_name in ALLOWED_PLAYERS:
|
|
self._player.connect("playback-status::playing", self.on_play)
|
|
self._player.connect("playback-status::paused", self.on_pause)
|
|
self._player.connect("shuffle", self.on_shuffle)
|
|
self._player.connect("metadata", self.on_metadata)
|
|
self._player.connect("seeked", self.on_seeked)
|
|
|
|
self.status = self._player.props.playback_status
|
|
self.pos_fabricator = Fabricator(
|
|
interval=1000, # 1s
|
|
poll_from=lambda f, *_: self._safe_get_position(),
|
|
on_changed=lambda f, *_: self.fabricating(),
|
|
)
|
|
# Add state tracking to prevent repeated operations
|
|
self._fabricator_running = False
|
|
self._cleanup_done = False
|
|
self.poll_progress()
|
|
|
|
def _safe_get_position(self):
|
|
"""Safely get player position with error handling"""
|
|
try:
|
|
if not self._player or self._cleanup_done:
|
|
return 0
|
|
return self._player.get_position()
|
|
except Exception:
|
|
# Player is no longer available, stop the fabricator
|
|
if hasattr(self, 'pos_fabricator') and self.pos_fabricator and self._fabricator_running:
|
|
try:
|
|
self.pos_fabricator.stop()
|
|
self._fabricator_running = False
|
|
except Exception:
|
|
pass
|
|
return 0
|
|
|
|
def cleanup(self):
|
|
"""Clean up resources when service is no longer needed"""
|
|
if self._cleanup_done:
|
|
return # Already cleaned up
|
|
|
|
try:
|
|
if hasattr(self, 'pos_fabricator') and self.pos_fabricator:
|
|
if self._fabricator_running:
|
|
self.pos_fabricator.stop()
|
|
self._fabricator_running = False
|
|
self.pos_fabricator = None
|
|
self._cleanup_done = True
|
|
except Exception as e:
|
|
print(f"[DEBUG] Error during PlayerService cleanup: {e}")
|
|
|
|
def on_seeked(self, player, position):
|
|
if (not self._cleanup_done and
|
|
self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" and
|
|
not self._fabricator_running):
|
|
try:
|
|
self.pos_fabricator.start()
|
|
self._fabricator_running = True
|
|
except Exception:
|
|
pass # Ignore if already started
|
|
|
|
def set_position(self, pos: float):
|
|
if not self._cleanup_done and self._fabricator_running:
|
|
try:
|
|
self.pos_fabricator.stop()
|
|
self._fabricator_running = False
|
|
except Exception:
|
|
pass # Ignore if already stopped
|
|
|
|
micro_pos = int(pos * 1_000_000)
|
|
try:
|
|
self._player.set_position(micro_pos)
|
|
except GLib.Error as e:
|
|
print(f"Failed to seek: {e}")
|
|
|
|
def poll_progress(self):
|
|
if self._cleanup_done:
|
|
return # Don't operate on cleaned up fabricator
|
|
|
|
if self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING":
|
|
if not self._fabricator_running:
|
|
try:
|
|
self.pos_fabricator.start()
|
|
self._fabricator_running = True
|
|
except Exception:
|
|
pass # Ignore if already started
|
|
else:
|
|
if self._fabricator_running:
|
|
try:
|
|
self.pos_fabricator.stop()
|
|
self._fabricator_running = False
|
|
except Exception:
|
|
pass # Ignore if already stopped
|
|
|
|
def fabricating(self):
|
|
try:
|
|
# Check if cleanup has been done
|
|
if self._cleanup_done:
|
|
return
|
|
|
|
# Validate player object
|
|
if not self._player or not hasattr(self._player, 'get_position'):
|
|
return
|
|
|
|
# Get position safely
|
|
try:
|
|
pos = self._player.get_position() / 1_000_000 # seconds
|
|
except Exception:
|
|
# Player is no longer available, stop fabricator
|
|
if self._fabricator_running:
|
|
try:
|
|
self.pos_fabricator.stop()
|
|
self._fabricator_running = False
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
# Get duration safely from multiple sources
|
|
dur = 0.0 # Default to 0
|
|
|
|
if (hasattr(self._player, 'props') and
|
|
hasattr(self._player.props, 'metadata') and
|
|
self._player.props.metadata):
|
|
|
|
metadata = self._player.props.metadata
|
|
|
|
# Try mpris:length first (most common)
|
|
try:
|
|
raw_dur = metadata["mpris:length"]
|
|
if raw_dur and raw_dur > 0:
|
|
dur = float(raw_dur) / 1_000_000 # seconds
|
|
except (KeyError, Exception):
|
|
pass
|
|
|
|
# Fallback to xesam:duration (alternative field)
|
|
if dur <= 0:
|
|
try:
|
|
raw_dur = metadata["xesam:duration"]
|
|
if raw_dur and raw_dur > 0:
|
|
dur = float(raw_dur) / 1_000_000 # seconds
|
|
except (KeyError, Exception):
|
|
pass
|
|
|
|
# Try to get duration from player directly (some players support this)
|
|
if dur <= 0:
|
|
try:
|
|
if hasattr(self._player, 'get_metadata'):
|
|
player_metadata = self._player.get_metadata()
|
|
if player_metadata:
|
|
raw_dur = player_metadata["mpris:length"]
|
|
if raw_dur and raw_dur > 0:
|
|
dur = float(raw_dur) / 1_000_000
|
|
except (KeyError, Exception):
|
|
pass
|
|
|
|
# Validate values before emitting signal
|
|
# Allow dur=0 for position-only tracking
|
|
if pos is not None and dur is not None and pos >= 0 and dur >= 0:
|
|
self.track_position(pos, dur)
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
def on_play(self, player, status):
|
|
self.status = player.props.playback_status
|
|
self.poll_progress()
|
|
self.play()
|
|
|
|
def on_pause(self, player, status):
|
|
self.poll_progress()
|
|
self.pause()
|
|
|
|
def on_shuffle(self, player, status):
|
|
self.shuffle_toggle(player, status)
|
|
|
|
def on_metadata(self, player, metadata):
|
|
keys = metadata.keys()
|
|
self.meta_change(metadata, player)
|
|
|
|
|
|
class PlayerManager(Service):
|
|
@Signal
|
|
def new_player(self, player: Playerctl.Player) -> Playerctl.Player: ...
|
|
|
|
@Signal
|
|
def player_vanish(self, player: Playerctl.Player) -> None: ...
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self._manager = Playerctl.PlayerManager()
|
|
self._manager.connect("name-appeared", self._on_name_appeared, self._manager)
|
|
self._manager.connect(
|
|
"player-vanished", self._on_player_vanished, self._manager
|
|
)
|
|
|
|
self._players = {}
|
|
|
|
def init_all_players(self):
|
|
# invoked in the UI
|
|
for player_obj in self._manager.props.player_names:
|
|
name_str = player_obj.name
|
|
if name_str in ALLOWED_PLAYERS:
|
|
player = Playerctl.Player.new_from_name(player_obj)
|
|
self._manager.manage_player(player)
|
|
self.new_player(player)
|
|
|
|
def _on_name_appeared(self, sender, name, manager):
|
|
name_str = name.name
|
|
if name_str in ALLOWED_PLAYERS:
|
|
player = Playerctl.Player.new_from_name(name)
|
|
self._manager.manage_player(player)
|
|
self.new_player(player)
|
|
|
|
def _on_player_vanished(self, sender, player, manager):
|
|
self.player_vanish(player) |