mirror of
https://git.srhtnin.garden/sarah/SaraTune.git
synced 2026-10-15 10:23:17 +02:00
Media controls (MPRIS, Windows SMTC); stop old tracks when switching
This commit is contained in:
1 parent
e2d7515e08
commit
d7f5eba1c2
14 files changed
+799
-26
No files matched your search
+9
-2
@@ -11,7 +11,7 @@ import flet as ft
|
|||||||
|
|
||||||
from Core import AudioExtensions, DisplayPath, Home, IsAudioFile, Note, Palette
|
from Core import AudioExtensions, DisplayPath, Home, IsAudioFile, Note, Palette
|
||||||
from Covers import CoverFor
|
from Covers import CoverFor
|
||||||
from Metadata import ReadDetails, ReadTrack
|
from Metadata import ReadDetails, ReadTrack, Track
|
||||||
from Player import Player
|
from Player import Player
|
||||||
from Transport import CoverImage, Transport
|
from Transport import CoverImage, Transport
|
||||||
from Widgets import App, EmptyState, InfoTable
|
from Widgets import App, EmptyState, InfoTable
|
||||||
@@ -36,6 +36,7 @@ class AudioMode(ft.Column):
|
|||||||
self.Rows: dict[str, ft.Container] = {}
|
self.Rows: dict[str, ft.Container] = {}
|
||||||
self.Current: Optional[str] = None
|
self.Current: Optional[str] = None
|
||||||
self.AutoAdvance = TheApp.Config.AudioAutoAdvance
|
self.AutoAdvance = TheApp.Config.AudioAutoAdvance
|
||||||
|
self.NowPlaying: Optional[Track] = None # for the system media controls
|
||||||
|
|
||||||
ThePlayer.OnEnded = lambda: TheApp.Page.run_task(self._Ended)
|
ThePlayer.OnEnded = lambda: TheApp.Page.run_task(self._Ended)
|
||||||
ThePlayer.OnChanged = lambda: self.Bar.Refresh()
|
ThePlayer.OnChanged = lambda: self.Bar.Refresh()
|
||||||
@@ -193,7 +194,9 @@ class AudioMode(ft.Column):
|
|||||||
self.TheApp.Notify(f"{Path(FilePath).name} is gone.", "error")
|
self.TheApp.Notify(f"{Path(FilePath).name} is gone.", "error")
|
||||||
return
|
return
|
||||||
self.Current = FilePath
|
self.Current = FilePath
|
||||||
|
self.NowPlaying = Info
|
||||||
await self.ThePlayer.Load(FilePath, Duration=Info.Duration) # errors arrive through OnError
|
await self.ThePlayer.Load(FilePath, Duration=Info.Duration) # errors arrive through OnError
|
||||||
|
self.TheApp.MediaChanged()
|
||||||
self._Highlight()
|
self._Highlight()
|
||||||
self._Show(self.List)
|
self._Show(self.List)
|
||||||
self._ScrollTo(FilePath)
|
self._ScrollTo(FilePath)
|
||||||
@@ -265,9 +268,13 @@ class AudioMode(ft.Column):
|
|||||||
self.Bar.Refresh()
|
self.Bar.Refresh()
|
||||||
|
|
||||||
def _ToggleLoop(self, e) -> None:
|
def _ToggleLoop(self, e) -> None:
|
||||||
self.ThePlayer.Loop = not self.ThePlayer.Loop
|
self.SetLoop(not self.ThePlayer.Loop)
|
||||||
|
|
||||||
|
def SetLoop(self, Enabled: bool) -> None:
|
||||||
|
self.ThePlayer.Loop = Enabled
|
||||||
self.LoopButton.icon_color = Palette.Mauve if self.ThePlayer.Loop else Palette.Overlay0
|
self.LoopButton.icon_color = Palette.Mauve if self.ThePlayer.Loop else Palette.Overlay0
|
||||||
self._Show(self.LoopButton)
|
self._Show(self.LoopButton)
|
||||||
|
self.TheApp.MediaModesChanged()
|
||||||
|
|
||||||
def _ToggleAdvance(self, e) -> None:
|
def _ToggleAdvance(self, e) -> None:
|
||||||
self.AutoAdvance = not self.AutoAdvance
|
self.AutoAdvance = not self.AutoAdvance
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ class AppConfig:
|
|||||||
AudioAutoAdvance: bool = False
|
AudioAutoAdvance: bool = False
|
||||||
# Music mode: keep the queue and position between runs.
|
# Music mode: keep the queue and position between runs.
|
||||||
RememberSession: bool = True
|
RememberSession: bool = True
|
||||||
|
# Show up in the system's media controls (MPRIS on Linux, the media
|
||||||
|
# overlay on Windows) and answer media keys.
|
||||||
|
MediaControls: bool = True
|
||||||
|
|
||||||
def Save(self) -> None:
|
def Save(self) -> None:
|
||||||
ConfigDir.mkdir(parents=True, exist_ok=True)
|
ConfigDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -31,18 +31,33 @@ def Thumbnail(Data: bytes, Size: int) -> Optional[bytes]:
|
|||||||
_Memory: dict[tuple[str, int], bytes] = {}
|
_Memory: dict[tuple[str, int], bytes] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _CachePath(FilePath: str, Size: int) -> Optional[Path]:
|
||||||
|
try:
|
||||||
|
Stamp = Path(FilePath).stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
Name = hashlib.sha1(f"{FilePath}|{Size}|{Stamp}".encode("utf-8", "surrogateescape")).hexdigest()
|
||||||
|
return CoversDir / f"{Name}.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def CoverPath(FilePath: str, Size: int) -> Optional[Path]:
|
||||||
|
"""The cached thumbnail's file (made if needed), for the system media
|
||||||
|
controls, which want a picture file rather than bytes. Blocking."""
|
||||||
|
if not CoverFor(FilePath, Size):
|
||||||
|
return None
|
||||||
|
CachePath = _CachePath(FilePath, Size)
|
||||||
|
return CachePath if CachePath and CachePath.exists() else None
|
||||||
|
|
||||||
|
|
||||||
def CoverFor(FilePath: str, Size: int) -> Optional[bytes]:
|
def CoverFor(FilePath: str, Size: int) -> Optional[bytes]:
|
||||||
"""FilePath's cover at most Size pixels wide. Blocking (run it in a
|
"""FilePath's cover at most Size pixels wide. Blocking (run it in a
|
||||||
thread). Cached on disk by path, size and the file's mtime."""
|
thread). Cached on disk by path, size and the file's mtime."""
|
||||||
Key = (FilePath, Size)
|
Key = (FilePath, Size)
|
||||||
if Key in _Memory:
|
if Key in _Memory:
|
||||||
return _Memory[Key] or None
|
return _Memory[Key] or None
|
||||||
try:
|
CachePath = _CachePath(FilePath, Size)
|
||||||
Stamp = Path(FilePath).stat().st_mtime_ns
|
if CachePath is None:
|
||||||
except OSError:
|
|
||||||
return None
|
return None
|
||||||
Name = hashlib.sha1(f"{FilePath}|{Size}|{Stamp}".encode("utf-8", "surrogateescape")).hexdigest()
|
|
||||||
CachePath = CoversDir / f"{Name}.jpg"
|
|
||||||
if CachePath.exists():
|
if CachePath.exists():
|
||||||
Data = CachePath.read_bytes()
|
Data = CachePath.read_bytes()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import sys
|
|||||||
import flet as ft
|
import flet as ft
|
||||||
|
|
||||||
from AudioMode import AudioMode
|
from AudioMode import AudioMode
|
||||||
|
from MediaControls import Remote
|
||||||
from Core import (
|
from Core import (
|
||||||
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataHome, EffectiveSettings,
|
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataHome, EffectiveSettings,
|
||||||
IsWindows, LoadConfig, Palette,
|
IsWindows, LoadConfig, Palette,
|
||||||
@@ -200,11 +201,20 @@ def Main(Page: ft.Page) -> None:
|
|||||||
|
|
||||||
Page.on_keyboard_event = OnKey
|
Page.on_keyboard_event = OnKey
|
||||||
|
|
||||||
|
async def Quit() -> None:
|
||||||
|
"""The window's close button and the media controls' Quit. Only
|
||||||
|
once: destroying the window sends another close event."""
|
||||||
|
if State.get("Quitting"):
|
||||||
|
return
|
||||||
|
State["Quitting"] = True
|
||||||
|
ModeViews["Music"].SaveSession()
|
||||||
|
Config.Save() # the volume
|
||||||
|
await Controls.Stop()
|
||||||
|
await Page.window.destroy()
|
||||||
|
|
||||||
async def OnWindowEvent(e: ft.WindowEvent) -> None:
|
async def OnWindowEvent(e: ft.WindowEvent) -> None:
|
||||||
if e.type == ft.WindowEventType.CLOSE:
|
if e.type == ft.WindowEventType.CLOSE:
|
||||||
ModeViews["Music"].SaveSession()
|
await Quit()
|
||||||
Config.Save() # the volume
|
|
||||||
await Page.window.destroy()
|
|
||||||
|
|
||||||
Page.window.prevent_close = True
|
Page.window.prevent_close = True
|
||||||
Page.window.on_event = OnWindowEvent
|
Page.window.on_event = OnWindowEvent
|
||||||
@@ -232,6 +242,13 @@ def Main(Page: ft.Page) -> None:
|
|||||||
Page.run_task(ModeViews["Audio"].Open, Files)
|
Page.run_task(ModeViews["Audio"].Open, Files)
|
||||||
else:
|
else:
|
||||||
SwitchMode(Config.StartMode if Config.StartMode in ModeViews else "Music")
|
SwitchMode(Config.StartMode if Config.StartMode in ModeViews else "Music")
|
||||||
|
|
||||||
|
# The system's media controls follow whichever mode played last.
|
||||||
|
Controls = Remote(Page, Players, ModeViews, State["Mode"], SetVolume, Quit)
|
||||||
|
if Config.MediaControls:
|
||||||
|
TheApp.MediaChanged = Controls.MediaChanged
|
||||||
|
TheApp.MediaModesChanged = Controls.ModesChanged
|
||||||
|
Page.run_task(Controls.Start)
|
||||||
Page.run_task(ModeViews["Music"].Started)
|
Page.run_task(ModeViews["Music"].Started)
|
||||||
Page.run_task(Ticker, [Mode.Bar for Mode in ModeViews.values()])
|
Page.run_task(Ticker, [Mode.Bar for Mode in ModeViews.values()])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""SaraTune as a media player the system knows about: the desktop's media
|
||||||
|
controls, media keys and lock screen show what's playing and control it.
|
||||||
|
|
||||||
|
Remote is the platform-neutral part: it follows whichever mode played last
|
||||||
|
and turns system commands into calls on that mode. The backends only talk to
|
||||||
|
Remote: Mpris.py on Linux (D-Bus), Smtc.py on Windows (System Media
|
||||||
|
Transport Controls). Neither is required: if one can't start, SaraTune just
|
||||||
|
plays without it."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Awaitable, Callable, Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from Covers import CoverPath
|
||||||
|
from Metadata import Track
|
||||||
|
from Player import Player
|
||||||
|
|
||||||
|
ArtSize = 512 # pixels; the cover thumbnail handed to the system
|
||||||
|
SeekStep = 10_000 # ms, for Windows' fast-forward / rewind buttons
|
||||||
|
|
||||||
|
|
||||||
|
class Remote:
|
||||||
|
def __init__(self, Page: ft.Page, Players: dict[str, Player], Modes: dict, Start: str,
|
||||||
|
SetVolume: Callable[[float], None], Quit: Callable[[], Awaitable[None]]):
|
||||||
|
self.Page = Page
|
||||||
|
self.Players = Players
|
||||||
|
self.Modes = Modes
|
||||||
|
self.ActiveName = Start # the mode the controls show
|
||||||
|
self.SetVolumeEverywhere = SetVolume
|
||||||
|
self._Quit = Quit
|
||||||
|
self.TrackNumber = 0 # changes with every new track; MPRIS track IDs are made from it
|
||||||
|
self.ArtPath: Optional[Path] = None
|
||||||
|
self._Shown: Optional[tuple] = None # (mode, track) the backend last heard about
|
||||||
|
self.Backend = None
|
||||||
|
for Name, ThePlayer in Players.items():
|
||||||
|
ThePlayer.Watchers.append(lambda Kind, Name=Name: self._PlayerEvent(Name, Kind))
|
||||||
|
|
||||||
|
async def Start(self) -> None:
|
||||||
|
try:
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
from Mpris import MprisBackend
|
||||||
|
self.Backend = MprisBackend(self)
|
||||||
|
elif sys.platform == "win32":
|
||||||
|
from Smtc import SmtcBackend
|
||||||
|
self.Backend = SmtcBackend(self)
|
||||||
|
if self.Backend is not None:
|
||||||
|
await self.Backend.Start()
|
||||||
|
except Exception as Error: # no session bus, no WinRT, ...: play without system controls
|
||||||
|
print(f"SaraTune: system media controls unavailable: {Error!r}", file=sys.stderr)
|
||||||
|
self.Backend = None
|
||||||
|
return
|
||||||
|
await self._TrackChanged()
|
||||||
|
|
||||||
|
async def Stop(self) -> None:
|
||||||
|
if self.Backend is not None:
|
||||||
|
try:
|
||||||
|
await self.Backend.Stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- What's playing -----------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def Mode(self):
|
||||||
|
return self.Modes[self.ActiveName]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ThePlayer(self) -> Player:
|
||||||
|
return self.Players[self.ActiveName]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def Info(self) -> Optional[Track]:
|
||||||
|
return self.Mode.NowPlaying if self.ThePlayer.Source else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def Shuffle(self) -> bool:
|
||||||
|
return bool(getattr(self.Mode, "Queue", None) and self.Mode.Queue.Shuffle)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def Repeat(self) -> str:
|
||||||
|
"""Off, All or One. Audio mode's loop counts as One."""
|
||||||
|
Queue = getattr(self.Mode, "Queue", None)
|
||||||
|
if Queue is not None:
|
||||||
|
return Queue.Repeat
|
||||||
|
return "One" if self.ThePlayer.Loop else "Off"
|
||||||
|
|
||||||
|
def _PlayerEvent(self, Name: str, Kind: str) -> None:
|
||||||
|
if self.Backend is None:
|
||||||
|
return
|
||||||
|
if Kind == "Started" and Name != self.ActiveName:
|
||||||
|
self.ActiveName = Name
|
||||||
|
self.Page.run_task(self._TrackChanged)
|
||||||
|
return
|
||||||
|
if Name != self.ActiveName:
|
||||||
|
return
|
||||||
|
if Kind == "Changed":
|
||||||
|
self.Backend.StateChanged()
|
||||||
|
elif Kind == "Seeked":
|
||||||
|
self.Backend.Seeked()
|
||||||
|
elif Kind == "Volume":
|
||||||
|
self.Backend.VolumeChanged()
|
||||||
|
|
||||||
|
def MediaChanged(self) -> None:
|
||||||
|
"""A mode's NowPlaying changed (Main hooks this to App.MediaChanged)."""
|
||||||
|
if self.Backend is not None:
|
||||||
|
self.Page.run_task(self._TrackChanged)
|
||||||
|
|
||||||
|
def ModesChanged(self) -> None:
|
||||||
|
"""Shuffle, repeat or loop changed in the app."""
|
||||||
|
if self.Backend is not None:
|
||||||
|
self.Backend.StateChanged()
|
||||||
|
|
||||||
|
async def _TrackChanged(self) -> None:
|
||||||
|
Info = self.Info
|
||||||
|
Key = (self.ActiveName, Info.Path if Info else None, id(Info))
|
||||||
|
if Key != self._Shown:
|
||||||
|
self._Shown = Key
|
||||||
|
self.TrackNumber += 1
|
||||||
|
self.ArtPath = await asyncio.to_thread(CoverPath, Info.Path, ArtSize) if Info else None
|
||||||
|
if self._Shown != Key:
|
||||||
|
return # changed again while the cover was being made
|
||||||
|
if self.Backend is not None:
|
||||||
|
self.Backend.TrackChanged()
|
||||||
|
|
||||||
|
# --- Commands from the system -------------------------------------------
|
||||||
|
|
||||||
|
def Run(self, Coroutine, *Arguments) -> None:
|
||||||
|
"""Backends call this from their callbacks; the work runs on Flet's loop."""
|
||||||
|
self.Page.run_task(Coroutine, *Arguments)
|
||||||
|
|
||||||
|
async def Play(self) -> None:
|
||||||
|
if not self.ThePlayer.Playing:
|
||||||
|
await self.ThePlayer.Toggle()
|
||||||
|
|
||||||
|
async def Pause(self) -> None:
|
||||||
|
await self.ThePlayer.Pause()
|
||||||
|
|
||||||
|
async def Toggle(self) -> None:
|
||||||
|
await self.ThePlayer.Toggle()
|
||||||
|
|
||||||
|
async def StopPlaying(self) -> None:
|
||||||
|
"""MPRIS Stop: pause and go back to the start, keeping the track."""
|
||||||
|
await self.ThePlayer.Pause()
|
||||||
|
await self.ThePlayer.Seek(0)
|
||||||
|
|
||||||
|
async def Next(self) -> None:
|
||||||
|
await self.Mode.Next()
|
||||||
|
|
||||||
|
async def Previous(self) -> None:
|
||||||
|
await self.Mode.Previous()
|
||||||
|
|
||||||
|
async def SeekBy(self, Milliseconds: int) -> None:
|
||||||
|
Target = self.ThePlayer.Position + Milliseconds
|
||||||
|
if self.ThePlayer.Duration and Target >= self.ThePlayer.Duration:
|
||||||
|
await self.Mode.Next() # MPRIS: seeking past the end acts like Next
|
||||||
|
else:
|
||||||
|
await self.ThePlayer.Seek(max(0, Target))
|
||||||
|
|
||||||
|
async def SetPosition(self, Milliseconds: int) -> None:
|
||||||
|
await self.ThePlayer.Seek(Milliseconds)
|
||||||
|
|
||||||
|
def SetVolume(self, Volume: float) -> None:
|
||||||
|
self.SetVolumeEverywhere(max(0.0, min(1.0, Volume)))
|
||||||
|
for Mode in self.Modes.values():
|
||||||
|
Mode.Bar.SetVolume(self.ThePlayer.Volume)
|
||||||
|
Mode._Show(Mode.Bar)
|
||||||
|
|
||||||
|
def SetShuffle(self, Enabled: bool) -> None:
|
||||||
|
"""The modes tell the backend back through ModesChanged."""
|
||||||
|
if hasattr(self.Mode, "SetShuffle"):
|
||||||
|
self.Mode.SetShuffle(Enabled)
|
||||||
|
else:
|
||||||
|
self.ModesChanged() # Audio mode has no shuffle: put the system's switch back
|
||||||
|
|
||||||
|
def SetRepeat(self, Mode: str) -> None:
|
||||||
|
if hasattr(self.Mode, "SetRepeat"):
|
||||||
|
self.Mode.SetRepeat(Mode)
|
||||||
|
else:
|
||||||
|
self.Mode.SetLoop(Mode == "One")
|
||||||
|
|
||||||
|
async def Raise(self) -> None:
|
||||||
|
await self.Page.window.to_front()
|
||||||
|
|
||||||
|
async def Quit(self) -> None:
|
||||||
|
await self._Quit()
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
"""Linux: SaraTune on the session bus as an MPRIS media player
|
||||||
|
(org.mpris.MediaPlayer2.SaraTune), which is what the desktop's media
|
||||||
|
controls, media keys, lock screen and playerctl use.
|
||||||
|
Spec: https://specifications.freedesktop.org/mpris-spec/latest/
|
||||||
|
|
||||||
|
dbus-fast runs on the same asyncio loop as Flet, so its callbacks can hand
|
||||||
|
work straight to Remote."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from dbus_fast import BusType, NameFlag, PropertyAccess, RequestNameReply, Variant
|
||||||
|
from dbus_fast.aio import MessageBus
|
||||||
|
from dbus_fast.annotations import (
|
||||||
|
DBusBool, DBusDict, DBusDouble, DBusInt64, DBusObjectPath, DBusSignature, DBusStr,
|
||||||
|
)
|
||||||
|
from dbus_fast.service import ServiceInterface, dbus_method, dbus_property, dbus_signal
|
||||||
|
|
||||||
|
from Core import AppName, AudioExtensions
|
||||||
|
|
||||||
|
StrList = Annotated[list[str], DBusSignature("as")]
|
||||||
|
|
||||||
|
ObjectPath = "/org/mpris/MediaPlayer2"
|
||||||
|
BusName = f"org.mpris.MediaPlayer2.{AppName}"
|
||||||
|
TrackPathBase = "/garden/srhtnin/SaraTune/Track/"
|
||||||
|
NoTrack = "/org/mpris/MediaPlayer2/TrackList/NoTrack"
|
||||||
|
LoopNames = {"Off": "None", "All": "Playlist", "One": "Track"} # SaraTune -> MPRIS
|
||||||
|
MimeTypes = [
|
||||||
|
"audio/mpeg", "audio/flac", "audio/x-flac", "audio/ogg", "audio/x-vorbis+ogg", "audio/opus", "audio/wav",
|
||||||
|
"audio/x-wav", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/x-aiff", "audio/x-matroska", "audio/webm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RootInterface(ServiceInterface):
|
||||||
|
def __init__(self, Remote):
|
||||||
|
super().__init__("org.mpris.MediaPlayer2")
|
||||||
|
self.Remote = Remote
|
||||||
|
|
||||||
|
@dbus_method()
|
||||||
|
def Raise(self) -> None:
|
||||||
|
self.Remote.Run(self.Remote.Raise)
|
||||||
|
|
||||||
|
@dbus_method()
|
||||||
|
def Quit(self) -> None:
|
||||||
|
self.Remote.Run(self.Remote.Quit)
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanQuit(self) -> DBusBool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanRaise(self) -> DBusBool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def HasTrackList(self) -> DBusBool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def Identity(self) -> DBusStr:
|
||||||
|
return AppName
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def DesktopEntry(self) -> DBusStr:
|
||||||
|
return AppName # SaraTune.desktop: the icon and name the desktop shows
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def SupportedUriSchemes(self) -> StrList:
|
||||||
|
return ["file"]
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def SupportedMimeTypes(self) -> StrList:
|
||||||
|
return MimeTypes
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerInterface(ServiceInterface):
|
||||||
|
def __init__(self, Remote):
|
||||||
|
super().__init__("org.mpris.MediaPlayer2.Player")
|
||||||
|
self.Remote = Remote
|
||||||
|
|
||||||
|
# --- Methods ------------------------------------------------------------
|
||||||
|
|
||||||
|
@dbus_method()
|
||||||
|
def Next(self) -> None:
|
||||||
|
self.Remote.Run(self.Remote.Next)
|
||||||
|
|
||||||
|
@dbus_method()
|
||||||
|
def Previous(self) -> None:
|
||||||
|
self.Remote.Run(self.Remote.Previous)
|
||||||
|
|
||||||
|
@dbus_method()
|
||||||
|
def Pause(self) -> None:
|
||||||
|
self.Remote.Run(self.Remote.Pause)
|
||||||
|
|
||||||
|
@dbus_method()
|
||||||
|
def PlayPause(self) -> None:
|
||||||
|
self.Remote.Run(self.Remote.Toggle)
|
||||||
|
|
||||||
|
@dbus_method()
|
||||||
|
def Stop(self) -> None:
|
||||||
|
self.Remote.Run(self.Remote.StopPlaying)
|
||||||
|
|
||||||
|
@dbus_method()
|
||||||
|
def Play(self) -> None:
|
||||||
|
self.Remote.Run(self.Remote.Play)
|
||||||
|
|
||||||
|
@dbus_method()
|
||||||
|
def Seek(self, Offset: DBusInt64) -> None:
|
||||||
|
self.Remote.Run(self.Remote.SeekBy, Offset // 1000)
|
||||||
|
|
||||||
|
@dbus_method()
|
||||||
|
def SetPosition(self, TrackId: DBusObjectPath, Position: DBusInt64) -> None:
|
||||||
|
# Ignored unless it's for the current track (the spec's rule against stale requests).
|
||||||
|
Duration = self.Remote.ThePlayer.Duration * 1000
|
||||||
|
if TrackId == self._TrackId() and 0 <= Position <= (Duration or Position):
|
||||||
|
self.Remote.Run(self.Remote.SetPosition, Position // 1000)
|
||||||
|
|
||||||
|
@dbus_method()
|
||||||
|
def OpenUri(self, Uri: DBusStr) -> None:
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
Parsed = urlparse(Uri)
|
||||||
|
if Parsed.scheme == "file" and os.path.splitext(Parsed.path)[1].lower() in AudioExtensions:
|
||||||
|
self.Remote.Page.run_task(self.Remote.Raise)
|
||||||
|
self.Remote.Modes["Audio"].TheApp.OpenInAudio([unquote(Parsed.path)])
|
||||||
|
|
||||||
|
@dbus_signal()
|
||||||
|
def Seeked(self) -> DBusInt64:
|
||||||
|
return self.Remote.ThePlayer.Position * 1000
|
||||||
|
|
||||||
|
# --- Properties ---------------------------------------------------------
|
||||||
|
|
||||||
|
def _TrackId(self) -> str:
|
||||||
|
return f"{TrackPathBase}{self.Remote.TrackNumber}" if self.Remote.Info else NoTrack
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def PlaybackStatus(self) -> DBusStr:
|
||||||
|
if not self.Remote.Info:
|
||||||
|
return "Stopped"
|
||||||
|
return "Playing" if self.Remote.ThePlayer.Playing else "Paused"
|
||||||
|
|
||||||
|
@dbus_property()
|
||||||
|
def LoopStatus(self) -> DBusStr:
|
||||||
|
return LoopNames[self.Remote.Repeat]
|
||||||
|
|
||||||
|
@LoopStatus.setter
|
||||||
|
def LoopStatus(self, Value: DBusStr) -> None:
|
||||||
|
Mode = {Mpris: Ours for Ours, Mpris in LoopNames.items()}.get(Value)
|
||||||
|
if Mode:
|
||||||
|
self.Remote.SetRepeat(Mode)
|
||||||
|
|
||||||
|
@dbus_property()
|
||||||
|
def Rate(self) -> DBusDouble:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
@Rate.setter
|
||||||
|
def Rate(self, Value: DBusDouble) -> None:
|
||||||
|
pass # only 1.0 (MinimumRate = MaximumRate)
|
||||||
|
|
||||||
|
@dbus_property()
|
||||||
|
def Shuffle(self) -> DBusBool:
|
||||||
|
return self.Remote.Shuffle
|
||||||
|
|
||||||
|
@Shuffle.setter
|
||||||
|
def Shuffle(self, Value: DBusBool) -> None:
|
||||||
|
self.Remote.SetShuffle(Value)
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def Metadata(self) -> DBusDict:
|
||||||
|
return self.MetadataValue()
|
||||||
|
|
||||||
|
@dbus_property()
|
||||||
|
def Volume(self) -> DBusDouble:
|
||||||
|
return float(self.Remote.ThePlayer.Volume)
|
||||||
|
|
||||||
|
@Volume.setter
|
||||||
|
def Volume(self, Value: DBusDouble) -> None:
|
||||||
|
self.Remote.SetVolume(Value)
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def Position(self) -> DBusInt64:
|
||||||
|
return self.Remote.ThePlayer.Position * 1000
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def MinimumRate(self) -> DBusDouble:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def MaximumRate(self) -> DBusDouble:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanGoNext(self) -> DBusBool:
|
||||||
|
return self.Remote.Info is not None
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanGoPrevious(self) -> DBusBool:
|
||||||
|
return self.Remote.Info is not None
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanPlay(self) -> DBusBool:
|
||||||
|
return self.Remote.Info is not None
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanPause(self) -> DBusBool:
|
||||||
|
return self.Remote.Info is not None
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanSeek(self) -> DBusBool:
|
||||||
|
return self.Remote.Info is not None
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanControl(self) -> DBusBool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def MetadataValue(self) -> dict:
|
||||||
|
Info = self.Remote.Info
|
||||||
|
if Info is None:
|
||||||
|
return {"mpris:trackid": Variant("o", NoTrack)}
|
||||||
|
Data = {
|
||||||
|
"mpris:trackid": Variant("o", self._TrackId()),
|
||||||
|
"xesam:title": Variant("s", Info.DisplayTitle),
|
||||||
|
"xesam:url": Variant("s", f"file://{_Quote(Info.Path)}"),
|
||||||
|
}
|
||||||
|
Length = self.Remote.ThePlayer.Duration or Info.Duration
|
||||||
|
if Length:
|
||||||
|
Data["mpris:length"] = Variant("x", int(Length) * 1000)
|
||||||
|
if Info.Artist or Info.AlbumArtist:
|
||||||
|
Data["xesam:artist"] = Variant("as", [Info.Artist or Info.AlbumArtist])
|
||||||
|
if Info.Album:
|
||||||
|
Data["xesam:album"] = Variant("s", Info.Album)
|
||||||
|
if Info.AlbumArtist:
|
||||||
|
Data["xesam:albumArtist"] = Variant("as", [Info.AlbumArtist])
|
||||||
|
if Info.Genre:
|
||||||
|
Data["xesam:genre"] = Variant("as", [Info.Genre])
|
||||||
|
if Info.TrackNumber:
|
||||||
|
Data["xesam:trackNumber"] = Variant("i", Info.TrackNumber)
|
||||||
|
if Info.DiscNumber:
|
||||||
|
Data["xesam:discNumber"] = Variant("i", Info.DiscNumber)
|
||||||
|
if self.Remote.ArtPath:
|
||||||
|
Data["mpris:artUrl"] = Variant("s", f"file://{_Quote(str(self.Remote.ArtPath))}")
|
||||||
|
return Data
|
||||||
|
|
||||||
|
|
||||||
|
def _Quote(PathText: str) -> str:
|
||||||
|
from urllib.parse import quote
|
||||||
|
return quote(PathText)
|
||||||
|
|
||||||
|
|
||||||
|
class MprisBackend:
|
||||||
|
def __init__(self, Remote):
|
||||||
|
self.Remote = Remote
|
||||||
|
self.Bus: MessageBus | None = None
|
||||||
|
self.Root = RootInterface(Remote)
|
||||||
|
self.Player = PlayerInterface(Remote)
|
||||||
|
self._LastState: dict = {}
|
||||||
|
|
||||||
|
async def Start(self) -> None:
|
||||||
|
self.Bus = await MessageBus(bus_type=BusType.SESSION).connect()
|
||||||
|
self.Bus.export(ObjectPath, self.Root)
|
||||||
|
self.Bus.export(ObjectPath, self.Player)
|
||||||
|
# A second SaraTune takes an instance name, as the spec suggests.
|
||||||
|
Reply = await self.Bus.request_name(BusName, NameFlag.DO_NOT_QUEUE)
|
||||||
|
if Reply not in (RequestNameReply.PRIMARY_OWNER, RequestNameReply.ALREADY_OWNER):
|
||||||
|
await self.Bus.request_name(f"{BusName}.instance{os.getpid()}", NameFlag.DO_NOT_QUEUE)
|
||||||
|
|
||||||
|
async def Stop(self) -> None:
|
||||||
|
if self.Bus is not None:
|
||||||
|
self.Bus.disconnect()
|
||||||
|
self.Bus = None
|
||||||
|
|
||||||
|
def _State(self) -> dict:
|
||||||
|
P = self.Player
|
||||||
|
return {
|
||||||
|
"PlaybackStatus": P.PlaybackStatus, "LoopStatus": P.LoopStatus, "Shuffle": P.Shuffle,
|
||||||
|
"CanGoNext": P.CanGoNext, "CanGoPrevious": P.CanGoPrevious, "CanPlay": P.CanPlay,
|
||||||
|
"CanPause": P.CanPause, "CanSeek": P.CanSeek,
|
||||||
|
}
|
||||||
|
|
||||||
|
def StateChanged(self) -> None:
|
||||||
|
"""Only what actually changed goes out (Changed fires often)."""
|
||||||
|
if self.Bus is None:
|
||||||
|
return
|
||||||
|
State = self._State()
|
||||||
|
Changed = {Key: Value for Key, Value in State.items() if self._LastState.get(Key) != Value}
|
||||||
|
self._LastState = State
|
||||||
|
if Changed:
|
||||||
|
self.Player.emit_properties_changed(Changed)
|
||||||
|
|
||||||
|
def TrackChanged(self) -> None:
|
||||||
|
if self.Bus is None:
|
||||||
|
return
|
||||||
|
self._LastState = self._State()
|
||||||
|
self.Player.emit_properties_changed({"Metadata": self.Player.MetadataValue(), **self._LastState})
|
||||||
|
|
||||||
|
def Seeked(self) -> None:
|
||||||
|
if self.Bus is not None:
|
||||||
|
self.Player.Seeked()
|
||||||
|
|
||||||
|
def VolumeChanged(self) -> None:
|
||||||
|
if self.Bus is not None:
|
||||||
|
self.Player.emit_properties_changed({"Volume": float(self.Remote.ThePlayer.Volume)})
|
||||||
+21
-5
@@ -15,7 +15,7 @@ from Library import Library, Matches
|
|||||||
from Metadata import Track
|
from Metadata import Track
|
||||||
from Player import Player
|
from Player import Player
|
||||||
from Playlists import ExportM3U, LoadPlaylists, NameTaken, Playlist, ReadM3U
|
from Playlists import ExportM3U, LoadPlaylists, NameTaken, Playlist, ReadM3U
|
||||||
from PlayQueue import RestartAfterMs, Session, LoadSession
|
from PlayQueue import RepeatModes, RestartAfterMs, Session, LoadSession
|
||||||
from Transport import CoverImage, Transport
|
from Transport import CoverImage, Transport
|
||||||
from Widgets import App, EmptyState
|
from Widgets import App, EmptyState
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ class MusicMode(ft.Column):
|
|||||||
self.ViewArgument: object = None
|
self.ViewArgument: object = None
|
||||||
self.Shown: list[Track] = []
|
self.Shown: list[Track] = []
|
||||||
self.RowsByPath: dict[str, list[ft.Container]] = {}
|
self.RowsByPath: dict[str, list[ft.Container]] = {}
|
||||||
|
self.NowPlaying: Optional[Track] = None # for the system media controls
|
||||||
|
|
||||||
ThePlayer.OnEnded = lambda: TheApp.Page.run_task(self._Ended)
|
ThePlayer.OnEnded = lambda: TheApp.Page.run_task(self._Ended)
|
||||||
ThePlayer.OnChanged = self._PlayerChanged
|
ThePlayer.OnChanged = self._PlayerChanged
|
||||||
@@ -507,12 +508,14 @@ class MusicMode(ft.Column):
|
|||||||
IsCurrent = PathText == Current and (Context != "Queue" or Index == self.Queue.Index)
|
IsCurrent = PathText == Current and (Context != "Queue" or Index == self.Queue.Index)
|
||||||
Row.bgcolor = Palette.Surface0 if IsCurrent else None
|
Row.bgcolor = Palette.Surface0 if IsCurrent else None
|
||||||
Lead = Row.content.controls[0]
|
Lead = Row.content.controls[0]
|
||||||
|
if isinstance(Lead, ft.Container) and isinstance(Lead.data, ft.Text):
|
||||||
|
Lead = Lead.data # already showing the icon: start from the number again
|
||||||
if IsCurrent:
|
if IsCurrent:
|
||||||
Row.content.controls[0] = ft.Container(
|
Row.content.controls[0] = ft.Container(
|
||||||
ft.Icon(ft.Icons.VOLUME_UP if self.ThePlayer.Playing else ft.Icons.PAUSE, size=16,
|
ft.Icon(ft.Icons.VOLUME_UP if self.ThePlayer.Playing else ft.Icons.PAUSE, size=16,
|
||||||
color=Palette.Mauve), width=36, data=Lead)
|
color=Palette.Mauve), width=36, data=Lead)
|
||||||
elif isinstance(Lead, ft.Container) and isinstance(Lead.data, ft.Text):
|
else:
|
||||||
Row.content.controls[0] = Lead.data
|
Row.content.controls[0] = Lead
|
||||||
|
|
||||||
async def _RowClicked(self, Context: str, Index: int) -> None:
|
async def _RowClicked(self, Context: str, Index: int) -> None:
|
||||||
if Context == "Queue":
|
if Context == "Queue":
|
||||||
@@ -552,6 +555,8 @@ class MusicMode(ft.Column):
|
|||||||
self.TheApp.Notify("Couldn't play any song in the queue (missing or unplayable files).", "error")
|
self.TheApp.Notify("Couldn't play any song in the queue (missing or unplayable files).", "error")
|
||||||
|
|
||||||
def _ShowNowPlaying(self, Item: Track) -> None:
|
def _ShowNowPlaying(self, Item: Track) -> None:
|
||||||
|
self.NowPlaying = Item
|
||||||
|
self.TheApp.MediaChanged()
|
||||||
Subtitle = " - ".join(Part for Part in (Item.DisplayArtist, Item.Album) if Part)
|
Subtitle = " - ".join(Part for Part in (Item.DisplayArtist, Item.Album) if Part)
|
||||||
self.Bar.ShowTrack(Item.DisplayTitle, Subtitle, None)
|
self.Bar.ShowTrack(Item.DisplayTitle, Subtitle, None)
|
||||||
self.TheApp.Page.run_task(self._LoadBarCover, Item.Path)
|
self.TheApp.Page.run_task(self._LoadBarCover, Item.Path)
|
||||||
@@ -606,7 +611,11 @@ class MusicMode(ft.Column):
|
|||||||
self.RepeatButton.tooltip = f"Repeat: {self.Queue.Repeat.lower()}"
|
self.RepeatButton.tooltip = f"Repeat: {self.Queue.Repeat.lower()}"
|
||||||
|
|
||||||
def _ToggleShuffle(self, e) -> None:
|
def _ToggleShuffle(self, e) -> None:
|
||||||
self.Queue.SetShuffle(not self.Queue.Shuffle)
|
self.SetShuffle(not self.Queue.Shuffle)
|
||||||
|
|
||||||
|
def SetShuffle(self, Enabled: bool) -> None:
|
||||||
|
self.Queue.SetShuffle(Enabled)
|
||||||
|
self.TheApp.MediaModesChanged()
|
||||||
self._ShowModes()
|
self._ShowModes()
|
||||||
self._Show(self.ShuffleButton)
|
self._Show(self.ShuffleButton)
|
||||||
self.SaveSession()
|
self.SaveSession()
|
||||||
@@ -614,7 +623,12 @@ class MusicMode(ft.Column):
|
|||||||
self._Rebuild()
|
self._Rebuild()
|
||||||
|
|
||||||
def _CycleRepeat(self, e) -> None:
|
def _CycleRepeat(self, e) -> None:
|
||||||
self.Queue.CycleRepeat()
|
self.SetRepeat(RepeatModes[(RepeatModes.index(self.Queue.Repeat) + 1) % len(RepeatModes)])
|
||||||
|
|
||||||
|
def SetRepeat(self, Mode: str) -> None:
|
||||||
|
"""Off, All or One."""
|
||||||
|
self.Queue.Repeat = Mode if Mode in RepeatModes else "Off"
|
||||||
|
self.TheApp.MediaModesChanged()
|
||||||
self._ShowModes()
|
self._ShowModes()
|
||||||
self._Show(self.RepeatButton)
|
self._Show(self.RepeatButton)
|
||||||
self.SaveSession()
|
self.SaveSession()
|
||||||
@@ -659,6 +673,8 @@ class MusicMode(ft.Column):
|
|||||||
self.Queue.Clear()
|
self.Queue.Clear()
|
||||||
self.TheApp.Page.run_task(self.ThePlayer.Stop)
|
self.TheApp.Page.run_task(self.ThePlayer.Stop)
|
||||||
self.Bar.ShowTrack("Nothing playing", "", None)
|
self.Bar.ShowTrack("Nothing playing", "", None)
|
||||||
|
self.NowPlaying = None
|
||||||
|
self.TheApp.MediaChanged()
|
||||||
self._AfterQueueEdit()
|
self._AfterQueueEdit()
|
||||||
|
|
||||||
def _Reordered(self, Context: str, Old: Optional[int], New: Optional[int]) -> None:
|
def _Reordered(self, Context: str, Old: Optional[int], New: Optional[int]) -> None:
|
||||||
|
|||||||
@@ -56,10 +56,6 @@ class PlayQueue:
|
|||||||
self.Paths = self.Unshuffled or self.Paths
|
self.Paths = self.Unshuffled or self.Paths
|
||||||
self.Index = self.Paths.index(Current) if Current in self.Paths else 0
|
self.Index = self.Paths.index(Current) if Current in self.Paths else 0
|
||||||
|
|
||||||
def CycleRepeat(self) -> str:
|
|
||||||
self.Repeat = RepeatModes[(RepeatModes.index(self.Repeat) + 1) % len(RepeatModes)]
|
|
||||||
return self.Repeat
|
|
||||||
|
|
||||||
def Next(self, Finished: bool = False) -> Optional[int]:
|
def Next(self, Finished: bool = False) -> Optional[int]:
|
||||||
"""The index to play after the current one (None: stop). Finished means
|
"""The index to play after the current one (None: stop). Finished means
|
||||||
the track ran out by itself, which is when Repeat One applies."""
|
the track ran out by itself, which is when Repeat One applies."""
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ class Player:
|
|||||||
self.OnEnded: Callable[[], None] = lambda: None
|
self.OnEnded: Callable[[], None] = lambda: None
|
||||||
self.OnChanged: Callable[[], None] = lambda: None # playing/paused, duration, source
|
self.OnChanged: Callable[[], None] = lambda: None # playing/paused, duration, source
|
||||||
self.OnError: Callable[[str], None] = lambda Message: None
|
self.OnError: Callable[[str], None] = lambda Message: None
|
||||||
|
# Others following along (the system media controls): called with
|
||||||
|
# "Changed", "Started" or "Seeked" after the owner's own callbacks.
|
||||||
|
self.Watchers: list[Callable[[str], None]] = []
|
||||||
|
|
||||||
# --- Position -----------------------------------------------------------
|
# --- Position -----------------------------------------------------------
|
||||||
|
|
||||||
@@ -51,6 +54,12 @@ class Player:
|
|||||||
self._Position = max(0, int(Milliseconds))
|
self._Position = max(0, int(Milliseconds))
|
||||||
self._PositionAt = time.monotonic()
|
self._PositionAt = time.monotonic()
|
||||||
|
|
||||||
|
def _Tell(self, Kind: str) -> None:
|
||||||
|
if Kind == "Changed":
|
||||||
|
self.OnChanged()
|
||||||
|
for Watcher in self.Watchers:
|
||||||
|
Watcher(Kind)
|
||||||
|
|
||||||
# --- Events from the client ---------------------------------------------
|
# --- Events from the client ---------------------------------------------
|
||||||
|
|
||||||
def _StateChanged(self, Audio: fta.Audio, e: fta.AudioStateChangeEvent) -> None:
|
def _StateChanged(self, Audio: fta.Audio, e: fta.AudioStateChangeEvent) -> None:
|
||||||
@@ -61,6 +70,7 @@ class Player:
|
|||||||
self._SetPosition(self._Position)
|
self._SetPosition(self._Position)
|
||||||
self.Playing = True
|
self.Playing = True
|
||||||
self.OnStarted()
|
self.OnStarted()
|
||||||
|
self._Tell("Started")
|
||||||
elif e.state == fta.AudioState.COMPLETED:
|
elif e.state == fta.AudioState.COMPLETED:
|
||||||
self.Playing = False
|
self.Playing = False
|
||||||
self._SetPosition(0)
|
self._SetPosition(0)
|
||||||
@@ -72,7 +82,7 @@ class Player:
|
|||||||
if self.Playing:
|
if self.Playing:
|
||||||
self._SetPosition(self.Position)
|
self._SetPosition(self.Position)
|
||||||
self.Playing = False
|
self.Playing = False
|
||||||
self.OnChanged()
|
self._Tell("Changed")
|
||||||
|
|
||||||
def _PositionChanged(self, Audio: fta.Audio, e: fta.AudioPositionChangeEvent) -> None:
|
def _PositionChanged(self, Audio: fta.Audio, e: fta.AudioPositionChangeEvent) -> None:
|
||||||
if Audio is self.Audio and self.Playing:
|
if Audio is self.Audio and self.Playing:
|
||||||
@@ -86,7 +96,7 @@ class Player:
|
|||||||
if Value > 0:
|
if Value > 0:
|
||||||
self.Duration = Value
|
self.Duration = Value
|
||||||
self._Ready.set()
|
self._Ready.set()
|
||||||
self.OnChanged()
|
self._Tell("Changed")
|
||||||
|
|
||||||
def _Loaded(self, Audio: fta.Audio) -> None:
|
def _Loaded(self, Audio: fta.Audio) -> None:
|
||||||
if Audio is self.Audio:
|
if Audio is self.Audio:
|
||||||
@@ -109,9 +119,16 @@ class Player:
|
|||||||
Old, self.Audio = self.Audio, None
|
Old, self.Audio = self.Audio, None
|
||||||
if Old is None:
|
if Old is None:
|
||||||
return
|
return
|
||||||
|
# Taking a service off the page doesn't stop its native player: it
|
||||||
|
# would play on, unseen. Stop and free it first.
|
||||||
|
for Method in (Old.pause, Old.release):
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(Method(), 2)
|
||||||
|
except (asyncio.TimeoutError, RuntimeError):
|
||||||
|
pass
|
||||||
if Old in self.Page.services:
|
if Old in self.Page.services:
|
||||||
self.Page.services.remove(Old)
|
self.Page.services.remove(Old)
|
||||||
self.Page.update() # disposes the old service and its native player
|
self.Page.update()
|
||||||
|
|
||||||
async def Load(self, FilePath: str, Start: int = 0, Play: bool = True, Duration: int = 0) -> bool:
|
async def Load(self, FilePath: str, Start: int = 0, Play: bool = True, Duration: int = 0) -> bool:
|
||||||
"""Switches to FilePath and (if Play) starts at Start (ms). Duration is
|
"""Switches to FilePath and (if Play) starts at Start (ms). Duration is
|
||||||
@@ -138,7 +155,7 @@ class Player:
|
|||||||
self.Audio = Audio
|
self.Audio = Audio
|
||||||
self.Page.services.append(Audio)
|
self.Page.services.append(Audio)
|
||||||
self.Page.update()
|
self.Page.update()
|
||||||
self.OnChanged()
|
self._Tell("Changed")
|
||||||
if Play:
|
if Play:
|
||||||
return await self.Play(Start)
|
return await self.Play(Start)
|
||||||
return True
|
return True
|
||||||
@@ -166,6 +183,7 @@ class Player:
|
|||||||
if not await self._Call(Audio.seek, ft.Duration(milliseconds=int(Position))):
|
if not await self._Call(Audio.seek, ft.Duration(milliseconds=int(Position))):
|
||||||
return False
|
return False
|
||||||
self._SetPosition(Position) # it played from 0 until the seek landed
|
self._SetPosition(Position) # it played from 0 until the seek landed
|
||||||
|
self._Tell("Seeked")
|
||||||
return True
|
return True
|
||||||
return await self._Call(Audio.play, ft.Duration(milliseconds=0))
|
return await self._Call(Audio.play, ft.Duration(milliseconds=0))
|
||||||
|
|
||||||
@@ -174,7 +192,7 @@ class Player:
|
|||||||
self._SetPosition(self.Position)
|
self._SetPosition(self.Position)
|
||||||
self.Playing = False
|
self.Playing = False
|
||||||
await self._Call(self.Audio.pause)
|
await self._Call(self.Audio.pause)
|
||||||
self.OnChanged()
|
self._Tell("Changed")
|
||||||
|
|
||||||
async def Toggle(self) -> None:
|
async def Toggle(self) -> None:
|
||||||
if self.Playing:
|
if self.Playing:
|
||||||
@@ -193,7 +211,8 @@ class Player:
|
|||||||
await self._Call(self.Audio.seek, ft.Duration(milliseconds=Milliseconds))
|
await self._Call(self.Audio.seek, ft.Duration(milliseconds=Milliseconds))
|
||||||
else:
|
else:
|
||||||
# Paused or not started: remember it, Play starts from there.
|
# Paused or not started: remember it, Play starts from there.
|
||||||
self.OnChanged()
|
self._Tell("Changed")
|
||||||
|
self._Tell("Seeked")
|
||||||
|
|
||||||
async def Stop(self) -> None:
|
async def Stop(self) -> None:
|
||||||
"""Back to nothing loaded."""
|
"""Back to nothing loaded."""
|
||||||
@@ -202,10 +221,11 @@ class Player:
|
|||||||
self.Playing = False
|
self.Playing = False
|
||||||
self.Duration = 0
|
self.Duration = 0
|
||||||
self._SetPosition(0)
|
self._SetPosition(0)
|
||||||
self.OnChanged()
|
self._Tell("Changed")
|
||||||
|
|
||||||
def SetVolume(self, Volume: float) -> None:
|
def SetVolume(self, Volume: float) -> None:
|
||||||
self.Volume = max(0.0, min(1.0, Volume))
|
self.Volume = max(0.0, min(1.0, Volume))
|
||||||
if self.Audio is not None:
|
if self.Audio is not None:
|
||||||
self.Audio.volume = self.Volume
|
self.Audio.volume = self.Volume
|
||||||
self.Audio.update()
|
self.Audio.update()
|
||||||
|
self._Tell("Volume")
|
||||||
@@ -14,6 +14,22 @@ A music player with two modes, switched in the header:
|
|||||||
Each mode has its own player, so switching modes doesn't lose your place; starting one pauses
|
Each mode has its own player, so switching modes doesn't lose your place; starting one pauses
|
||||||
the other.
|
the other.
|
||||||
|
|
||||||
|
## Media controls
|
||||||
|
|
||||||
|
SaraTune counts as a media player to the system, so the desktop's media widget, the lock screen
|
||||||
|
and media keys (keyboard or Bluetooth headphones) show and control whatever played last, in
|
||||||
|
either mode: play/pause, next/previous, seeking, volume, and shuffle/repeat (in Audio mode, repeat
|
||||||
|
is the loop button; there's no shuffle).
|
||||||
|
|
||||||
|
- Linux: MPRIS on the session bus as `org.mpris.MediaPlayer2.SaraTune` (`Mpris.py`, with
|
||||||
|
`dbus-fast`). Try it with `playerctl -p SaraTune status`.
|
||||||
|
- Windows: the System Media Transport Controls (`Smtc.py`, with the `winrt` packages), through a
|
||||||
|
WinRT MediaPlayer whose own command handling is off, since the window belongs to the Flet
|
||||||
|
client's process. Not tested on real Windows yet.
|
||||||
|
|
||||||
|
`MediaControls.py` is the shared part. Settings > General can turn it off; if it can't start
|
||||||
|
(no session bus, no WinRT), SaraTune plays without it.
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
| Path | What |
|
| Path | What |
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], OnClearCach
|
|||||||
SeekField = _NumberField("Arrow keys seek by (seconds, Audio mode)", Config.SeekSeconds)
|
SeekField = _NumberField("Arrow keys seek by (seconds, Audio mode)", Config.SeekSeconds)
|
||||||
AdvanceSwitch = ft.Switch(label="Audio mode plays the next file in the folder when one ends",
|
AdvanceSwitch = ft.Switch(label="Audio mode plays the next file in the folder when one ends",
|
||||||
value=Config.AudioAutoAdvance)
|
value=Config.AudioAutoAdvance)
|
||||||
|
MediaSwitch = ft.Switch(label="Show in the system's media controls and answer media keys",
|
||||||
|
value=Config.MediaControls)
|
||||||
|
|
||||||
# Music: one folder per line, plus a Browse button that adds a line.
|
# Music: one folder per line, plus a Browse button that adds a line.
|
||||||
FoldersField = ft.TextField(label="Music folders (one per line)", value="\n".join(Config.MusicFolders),
|
FoldersField = ft.TextField(label="Music folders (one per line)", value="\n".join(Config.MusicFolders),
|
||||||
@@ -212,6 +214,9 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], OnClearCach
|
|||||||
_Note("Opening files with SaraTune (e.g. from a file manager) always starts in Audio mode."),
|
_Note("Opening files with SaraTune (e.g. from a file manager) always starts in Audio mode."),
|
||||||
SeekField,
|
SeekField,
|
||||||
AdvanceSwitch,
|
AdvanceSwitch,
|
||||||
|
MediaSwitch,
|
||||||
|
_Note("Media controls: the desktop's media widget and lock screen (MPRIS on Linux), the media "
|
||||||
|
"overlay on Windows. Changing it applies the next time SaraTune starts."),
|
||||||
ft.Divider(height=1, color=Palette.Surface1),
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
OwnAppearance,
|
OwnAppearance,
|
||||||
],
|
],
|
||||||
@@ -271,6 +276,7 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], OnClearCach
|
|||||||
Config.StartMode = StartMode.value or Config.StartMode
|
Config.StartMode = StartMode.value or Config.StartMode
|
||||||
Config.SeekSeconds = _ReadInt(SeekField, Config.SeekSeconds, 1)
|
Config.SeekSeconds = _ReadInt(SeekField, Config.SeekSeconds, 1)
|
||||||
Config.AudioAutoAdvance = AdvanceSwitch.value
|
Config.AudioAutoAdvance = AdvanceSwitch.value
|
||||||
|
Config.MediaControls = MediaSwitch.value
|
||||||
Config.MusicFolders = [os.path.expanduser(Line.strip()) for Line in (FoldersField.value or "").splitlines()
|
Config.MusicFolders = [os.path.expanduser(Line.strip()) for Line in (FoldersField.value or "").splitlines()
|
||||||
if Line.strip()]
|
if Line.strip()]
|
||||||
Config.ScanOnStart = ScanSwitch.value
|
Config.ScanOnStart = ScanSwitch.value
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Windows: SaraTune in the System Media Transport Controls (the media
|
||||||
|
overlay by the volume flyout, media keys, the lock screen).
|
||||||
|
|
||||||
|
The controls normally belong to a window, but the window here is the Flet
|
||||||
|
client's, a separate process. The usual way out for apps like that: make a
|
||||||
|
WinRT MediaPlayer, switch off its own command handling, and drive its
|
||||||
|
SystemMediaTransportControls by hand. That needs no window handle.
|
||||||
|
|
||||||
|
WinRT raises its events on its own threads; they're passed to Flet's loop
|
||||||
|
with call_soon_threadsafe. Written against the winrt 3.2 type stubs and only
|
||||||
|
tested as far as importing goes (no real Windows to run it on)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from winrt.windows.media import (
|
||||||
|
MediaPlaybackAutoRepeatMode, MediaPlaybackStatus, MediaPlaybackType, SystemMediaTransportControlsButton,
|
||||||
|
SystemMediaTransportControlsTimelineProperties,
|
||||||
|
)
|
||||||
|
from winrt.windows.media.playback import MediaPlayer
|
||||||
|
from winrt.windows.storage import StorageFile
|
||||||
|
from winrt.windows.storage.streams import RandomAccessStreamReference
|
||||||
|
|
||||||
|
from MediaControls import SeekStep
|
||||||
|
|
||||||
|
RepeatModes = { # SaraTune <-> Windows
|
||||||
|
"Off": MediaPlaybackAutoRepeatMode.NONE,
|
||||||
|
"All": MediaPlaybackAutoRepeatMode.LIST,
|
||||||
|
"One": MediaPlaybackAutoRepeatMode.TRACK,
|
||||||
|
}
|
||||||
|
TimelineEverySeconds = 5 # Windows doesn't move the position along by itself
|
||||||
|
|
||||||
|
|
||||||
|
def _Time(Milliseconds: int) -> datetime.timedelta:
|
||||||
|
return datetime.timedelta(milliseconds=max(0, int(Milliseconds)))
|
||||||
|
|
||||||
|
|
||||||
|
class SmtcBackend:
|
||||||
|
def __init__(self, Remote):
|
||||||
|
self.Remote = Remote
|
||||||
|
self.Loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self.MediaPlayer: MediaPlayer | None = None
|
||||||
|
self.Controls = None
|
||||||
|
self._Tokens: list[tuple[str, object]] = []
|
||||||
|
self._Ticker: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def Start(self) -> None:
|
||||||
|
self.Loop = asyncio.get_running_loop()
|
||||||
|
self.MediaPlayer = MediaPlayer()
|
||||||
|
self.MediaPlayer.command_manager.is_enabled = False # SaraTune answers the buttons, not this player
|
||||||
|
Controls = self.Controls = self.MediaPlayer.system_media_transport_controls
|
||||||
|
Controls.is_enabled = True
|
||||||
|
for Name in ("play", "pause", "stop", "next", "previous", "fast_forward", "rewind"):
|
||||||
|
setattr(Controls, f"is_{Name}_enabled", True)
|
||||||
|
self._Tokens = [
|
||||||
|
("button_pressed", Controls.add_button_pressed(
|
||||||
|
lambda Sender, Arguments: self._OnLoop(self._Button, Arguments.button))),
|
||||||
|
("playback_position_change_requested", Controls.add_playback_position_change_requested(
|
||||||
|
lambda Sender, Arguments: self._OnLoop(self._SeekTo, Arguments.requested_playback_position))),
|
||||||
|
("shuffle_enabled_change_requested", Controls.add_shuffle_enabled_change_requested(
|
||||||
|
lambda Sender, Arguments: self._OnLoop(self.Remote.SetShuffle, Arguments.requested_shuffle_enabled))),
|
||||||
|
("auto_repeat_mode_change_requested", Controls.add_auto_repeat_mode_change_requested(
|
||||||
|
lambda Sender, Arguments: self._OnLoop(self._Repeat, Arguments.requested_auto_repeat_mode))),
|
||||||
|
]
|
||||||
|
self._Ticker = self.Loop.create_task(self._Tick())
|
||||||
|
|
||||||
|
async def Stop(self) -> None:
|
||||||
|
if self._Ticker:
|
||||||
|
self._Ticker.cancel()
|
||||||
|
if self.Controls is not None:
|
||||||
|
for Name, Token in self._Tokens:
|
||||||
|
getattr(self.Controls, f"remove_{Name}")(Token)
|
||||||
|
self.Controls.display_updater.clear_all()
|
||||||
|
self.Controls.is_enabled = False
|
||||||
|
if self.MediaPlayer is not None:
|
||||||
|
self.MediaPlayer.close()
|
||||||
|
self.Controls = self.MediaPlayer = None
|
||||||
|
|
||||||
|
# --- From Windows -------------------------------------------------------
|
||||||
|
|
||||||
|
def _OnLoop(self, Function, *Arguments) -> None:
|
||||||
|
"""WinRT thread -> Flet's loop."""
|
||||||
|
if self.Loop is not None:
|
||||||
|
self.Loop.call_soon_threadsafe(Function, *Arguments)
|
||||||
|
|
||||||
|
def _Button(self, Button) -> None:
|
||||||
|
R = self.Remote
|
||||||
|
Actions = {
|
||||||
|
SystemMediaTransportControlsButton.PLAY: (R.Play,),
|
||||||
|
SystemMediaTransportControlsButton.PAUSE: (R.Pause,),
|
||||||
|
SystemMediaTransportControlsButton.STOP: (R.StopPlaying,),
|
||||||
|
SystemMediaTransportControlsButton.NEXT: (R.Next,),
|
||||||
|
SystemMediaTransportControlsButton.PREVIOUS: (R.Previous,),
|
||||||
|
SystemMediaTransportControlsButton.FAST_FORWARD: (R.SeekBy, SeekStep),
|
||||||
|
SystemMediaTransportControlsButton.REWIND: (R.SeekBy, -SeekStep),
|
||||||
|
}
|
||||||
|
Action = Actions.get(Button)
|
||||||
|
if Action:
|
||||||
|
R.Run(*Action)
|
||||||
|
|
||||||
|
def _SeekTo(self, Position: datetime.timedelta) -> None:
|
||||||
|
self.Remote.Run(self.Remote.SetPosition, int(Position.total_seconds() * 1000))
|
||||||
|
|
||||||
|
def _Repeat(self, Mode) -> None:
|
||||||
|
Ours = next((Name for Name, Value in RepeatModes.items() if Value == Mode), "Off")
|
||||||
|
self.Remote.SetRepeat(Ours)
|
||||||
|
|
||||||
|
# --- To Windows ---------------------------------------------------------
|
||||||
|
|
||||||
|
def StateChanged(self) -> None:
|
||||||
|
if self.Controls is None:
|
||||||
|
return
|
||||||
|
R = self.Remote
|
||||||
|
C = self.Controls
|
||||||
|
if not R.Info:
|
||||||
|
C.playback_status = MediaPlaybackStatus.CLOSED
|
||||||
|
else:
|
||||||
|
C.playback_status = MediaPlaybackStatus.PLAYING if R.ThePlayer.Playing else MediaPlaybackStatus.PAUSED
|
||||||
|
C.shuffle_enabled = R.Shuffle
|
||||||
|
C.auto_repeat_mode = RepeatModes[R.Repeat]
|
||||||
|
self._Timeline()
|
||||||
|
|
||||||
|
def TrackChanged(self) -> None:
|
||||||
|
if self.Controls is None or self.Loop is None:
|
||||||
|
return
|
||||||
|
self.Loop.create_task(self._Display())
|
||||||
|
self.StateChanged()
|
||||||
|
|
||||||
|
async def _Display(self) -> None:
|
||||||
|
Updater = self.Controls.display_updater
|
||||||
|
Info = self.Remote.Info
|
||||||
|
if Info is None:
|
||||||
|
Updater.clear_all()
|
||||||
|
Updater.update()
|
||||||
|
return
|
||||||
|
Updater.clear_all() # the previous track's cover too
|
||||||
|
Updater.type = MediaPlaybackType.MUSIC
|
||||||
|
Music = Updater.music_properties
|
||||||
|
Music.title = Info.DisplayTitle
|
||||||
|
Music.artist = Info.Artist or Info.AlbumArtist or ""
|
||||||
|
Music.album_title = Info.Album or ""
|
||||||
|
Music.album_artist = Info.AlbumArtist or ""
|
||||||
|
Music.track_number = Info.TrackNumber or 0
|
||||||
|
Art = self.Remote.ArtPath
|
||||||
|
if Art:
|
||||||
|
try:
|
||||||
|
File = await StorageFile.get_file_from_path_async(str(Art))
|
||||||
|
Updater.thumbnail = RandomAccessStreamReference.create_from_file(File)
|
||||||
|
except Exception:
|
||||||
|
pass # no cover rather than no controls
|
||||||
|
Updater.update()
|
||||||
|
|
||||||
|
def _Timeline(self) -> None:
|
||||||
|
R = self.Remote
|
||||||
|
Timeline = SystemMediaTransportControlsTimelineProperties()
|
||||||
|
Length = R.ThePlayer.Duration or (R.Info.Duration if R.Info else 0)
|
||||||
|
Timeline.start_time = _Time(0)
|
||||||
|
Timeline.min_seek_time = _Time(0)
|
||||||
|
Timeline.end_time = _Time(Length)
|
||||||
|
Timeline.max_seek_time = _Time(Length)
|
||||||
|
Timeline.position = _Time(R.ThePlayer.Position)
|
||||||
|
self.Controls.update_timeline_properties(Timeline)
|
||||||
|
|
||||||
|
def Seeked(self) -> None:
|
||||||
|
if self.Controls is not None:
|
||||||
|
self._Timeline()
|
||||||
|
|
||||||
|
def VolumeChanged(self) -> None:
|
||||||
|
pass # the overlay has no volume of its own for an app
|
||||||
|
|
||||||
|
async def _Tick(self) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(TimelineEverySeconds)
|
||||||
|
if self.Controls is not None and self.Remote.ThePlayer.Playing:
|
||||||
|
try:
|
||||||
|
self._Timeline()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -18,6 +18,8 @@ class App:
|
|||||||
# Set by Main.
|
# Set by Main.
|
||||||
self.SwitchMode: Callable[[str], None] = lambda Name: None
|
self.SwitchMode: Callable[[str], None] = lambda Name: None
|
||||||
self.OpenInAudio: Callable[[list[str]], None] = lambda Paths: None
|
self.OpenInAudio: Callable[[list[str]], None] = lambda Paths: None
|
||||||
|
self.MediaChanged: Callable[[], None] = lambda: None # a mode's NowPlaying changed
|
||||||
|
self.MediaModesChanged: Callable[[], None] = lambda: None # shuffle, repeat or loop changed
|
||||||
|
|
||||||
def Notify(self, Text: str, Kind: str = "info") -> None:
|
def Notify(self, Text: str, Kind: str = "info") -> None:
|
||||||
Background, Accent = NotificationStyle(Kind)
|
Background, Accent = NotificationStyle(Kind)
|
||||||
|
|||||||
@@ -4,3 +4,11 @@ flet-audio>=1.0,<2.0 # playback; needs Flet's "full" desktop client (Main.py pi
|
|||||||
mutagen # tags, details and cover art
|
mutagen # tags, details and cover art
|
||||||
pillow # cover thumbnails
|
pillow # cover thumbnails
|
||||||
tomlkit
|
tomlkit
|
||||||
|
dbus-fast; sys_platform == "linux" # MPRIS: the desktop's media controls and media keys
|
||||||
|
# Windows' media overlay (System Media Transport Controls)
|
||||||
|
winrt-runtime; sys_platform == "win32"
|
||||||
|
winrt-Windows.Foundation; sys_platform == "win32"
|
||||||
|
winrt-Windows.Media; sys_platform == "win32"
|
||||||
|
winrt-Windows.Media.Playback; sys_platform == "win32"
|
||||||
|
winrt-Windows.Storage; sys_platform == "win32"
|
||||||
|
winrt-Windows.Storage.Streams; sys_platform == "win32"
|
||||||
Reference in new issue
Block a user