Media controls (MPRIS, Windows SMTC); stop old tracks when switching

This commit is contained in:
sarah committed 2026-10-09 18:57:03 +02:00
1 parent e2d7515e08
commit d7f5eba1c2
14 files changed
+799 -26

No files matched your search

+9 -2
View File
@@ -11,7 +11,7 @@ import flet as ft
from Core import AudioExtensions, DisplayPath, Home, IsAudioFile, Note, Palette
from Covers import CoverFor
from Metadata import ReadDetails, ReadTrack
from Metadata import ReadDetails, ReadTrack, Track
from Player import Player
from Transport import CoverImage, Transport
from Widgets import App, EmptyState, InfoTable
@@ -36,6 +36,7 @@ class AudioMode(ft.Column):
self.Rows: dict[str, ft.Container] = {}
self.Current: Optional[str] = None
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.OnChanged = lambda: self.Bar.Refresh()
@@ -193,7 +194,9 @@ class AudioMode(ft.Column):
self.TheApp.Notify(f"{Path(FilePath).name} is gone.", "error")
return
self.Current = FilePath
self.NowPlaying = Info
await self.ThePlayer.Load(FilePath, Duration=Info.Duration) # errors arrive through OnError
self.TheApp.MediaChanged()
self._Highlight()
self._Show(self.List)
self._ScrollTo(FilePath)
@@ -265,9 +268,13 @@ class AudioMode(ft.Column):
self.Bar.Refresh()
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._Show(self.LoopButton)
self.TheApp.MediaModesChanged()
def _ToggleAdvance(self, e) -> None:
self.AutoAdvance = not self.AutoAdvance
+3
View File
@@ -126,6 +126,9 @@ class AppConfig:
AudioAutoAdvance: bool = False
# Music mode: keep the queue and position between runs.
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:
ConfigDir.mkdir(parents=True, exist_ok=True)
+20 -5
View File
@@ -31,18 +31,33 @@ def Thumbnail(Data: bytes, Size: int) -> Optional[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]:
"""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."""
Key = (FilePath, Size)
if Key in _Memory:
return _Memory[Key] or None
try:
Stamp = Path(FilePath).stat().st_mtime_ns
except OSError:
CachePath = _CachePath(FilePath, Size)
if CachePath is None:
return None
Name = hashlib.sha1(f"{FilePath}|{Size}|{Stamp}".encode("utf-8", "surrogateescape")).hexdigest()
CachePath = CoversDir / f"{Name}.jpg"
if CachePath.exists():
Data = CachePath.read_bytes()
else:
+20 -3
View File
@@ -14,6 +14,7 @@ import sys
import flet as ft
from AudioMode import AudioMode
from MediaControls import Remote
from Core import (
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataHome, EffectiveSettings,
IsWindows, LoadConfig, Palette,
@@ -200,11 +201,20 @@ def Main(Page: ft.Page) -> None:
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:
if e.type == ft.WindowEventType.CLOSE:
ModeViews["Music"].SaveSession()
Config.Save() # the volume
await Page.window.destroy()
await Quit()
Page.window.prevent_close = True
Page.window.on_event = OnWindowEvent
@@ -232,6 +242,13 @@ def Main(Page: ft.Page) -> None:
Page.run_task(ModeViews["Audio"].Open, Files)
else:
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(Ticker, [Mode.Bar for Mode in ModeViews.values()])
+188
View File
@@ -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()
+301
View File
@@ -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
View File
@@ -15,7 +15,7 @@ from Library import Library, Matches
from Metadata import Track
from Player import Player
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 Widgets import App, EmptyState
@@ -51,6 +51,7 @@ class MusicMode(ft.Column):
self.ViewArgument: object = None
self.Shown: list[Track] = []
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.OnChanged = self._PlayerChanged
@@ -507,12 +508,14 @@ class MusicMode(ft.Column):
IsCurrent = PathText == Current and (Context != "Queue" or Index == self.Queue.Index)
Row.bgcolor = Palette.Surface0 if IsCurrent else None
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:
Row.content.controls[0] = ft.Container(
ft.Icon(ft.Icons.VOLUME_UP if self.ThePlayer.Playing else ft.Icons.PAUSE, size=16,
color=Palette.Mauve), width=36, data=Lead)
elif isinstance(Lead, ft.Container) and isinstance(Lead.data, ft.Text):
Row.content.controls[0] = Lead.data
else:
Row.content.controls[0] = Lead
async def _RowClicked(self, Context: str, Index: int) -> None:
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")
def _ShowNowPlaying(self, Item: Track) -> None:
self.NowPlaying = Item
self.TheApp.MediaChanged()
Subtitle = " - ".join(Part for Part in (Item.DisplayArtist, Item.Album) if Part)
self.Bar.ShowTrack(Item.DisplayTitle, Subtitle, None)
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()}"
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._Show(self.ShuffleButton)
self.SaveSession()
@@ -614,7 +623,12 @@ class MusicMode(ft.Column):
self._Rebuild()
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._Show(self.RepeatButton)
self.SaveSession()
@@ -659,6 +673,8 @@ class MusicMode(ft.Column):
self.Queue.Clear()
self.TheApp.Page.run_task(self.ThePlayer.Stop)
self.Bar.ShowTrack("Nothing playing", "", None)
self.NowPlaying = None
self.TheApp.MediaChanged()
self._AfterQueueEdit()
def _Reordered(self, Context: str, Old: Optional[int], New: Optional[int]) -> None:
-4
View File
@@ -56,10 +56,6 @@ class PlayQueue:
self.Paths = self.Unshuffled or self.Paths
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]:
"""The index to play after the current one (None: stop). Finished means
the track ran out by itself, which is when Repeat One applies."""
+27 -7
View File
@@ -37,6 +37,9 @@ class Player:
self.OnEnded: Callable[[], None] = lambda: None
self.OnChanged: Callable[[], None] = lambda: None # playing/paused, duration, source
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 -----------------------------------------------------------
@@ -51,6 +54,12 @@ class Player:
self._Position = max(0, int(Milliseconds))
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 ---------------------------------------------
def _StateChanged(self, Audio: fta.Audio, e: fta.AudioStateChangeEvent) -> None:
@@ -61,6 +70,7 @@ class Player:
self._SetPosition(self._Position)
self.Playing = True
self.OnStarted()
self._Tell("Started")
elif e.state == fta.AudioState.COMPLETED:
self.Playing = False
self._SetPosition(0)
@@ -72,7 +82,7 @@ class Player:
if self.Playing:
self._SetPosition(self.Position)
self.Playing = False
self.OnChanged()
self._Tell("Changed")
def _PositionChanged(self, Audio: fta.Audio, e: fta.AudioPositionChangeEvent) -> None:
if Audio is self.Audio and self.Playing:
@@ -86,7 +96,7 @@ class Player:
if Value > 0:
self.Duration = Value
self._Ready.set()
self.OnChanged()
self._Tell("Changed")
def _Loaded(self, Audio: fta.Audio) -> None:
if Audio is self.Audio:
@@ -109,9 +119,16 @@ class Player:
Old, self.Audio = self.Audio, None
if Old is None:
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:
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:
"""Switches to FilePath and (if Play) starts at Start (ms). Duration is
@@ -138,7 +155,7 @@ class Player:
self.Audio = Audio
self.Page.services.append(Audio)
self.Page.update()
self.OnChanged()
self._Tell("Changed")
if Play:
return await self.Play(Start)
return True
@@ -166,6 +183,7 @@ class Player:
if not await self._Call(Audio.seek, ft.Duration(milliseconds=int(Position))):
return False
self._SetPosition(Position) # it played from 0 until the seek landed
self._Tell("Seeked")
return True
return await self._Call(Audio.play, ft.Duration(milliseconds=0))
@@ -174,7 +192,7 @@ class Player:
self._SetPosition(self.Position)
self.Playing = False
await self._Call(self.Audio.pause)
self.OnChanged()
self._Tell("Changed")
async def Toggle(self) -> None:
if self.Playing:
@@ -193,7 +211,8 @@ class Player:
await self._Call(self.Audio.seek, ft.Duration(milliseconds=Milliseconds))
else:
# Paused or not started: remember it, Play starts from there.
self.OnChanged()
self._Tell("Changed")
self._Tell("Seeked")
async def Stop(self) -> None:
"""Back to nothing loaded."""
@@ -202,10 +221,11 @@ class Player:
self.Playing = False
self.Duration = 0
self._SetPosition(0)
self.OnChanged()
self._Tell("Changed")
def SetVolume(self, Volume: float) -> None:
self.Volume = max(0.0, min(1.0, Volume))
if self.Audio is not None:
self.Audio.volume = self.Volume
self.Audio.update()
self._Tell("Volume")
+16
View File
@@ -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
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
| Path | What |
+6
View File
@@ -137,6 +137,8 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], OnClearCach
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",
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.
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."),
SeekField,
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),
OwnAppearance,
],
@@ -271,6 +276,7 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], OnClearCach
Config.StartMode = StartMode.value or Config.StartMode
Config.SeekSeconds = _ReadInt(SeekField, Config.SeekSeconds, 1)
Config.AudioAutoAdvance = AdvanceSwitch.value
Config.MediaControls = MediaSwitch.value
Config.MusicFolders = [os.path.expanduser(Line.strip()) for Line in (FoldersField.value or "").splitlines()
if Line.strip()]
Config.ScanOnStart = ScanSwitch.value
+178
View File
@@ -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
+2
View File
@@ -18,6 +18,8 @@ class App:
# Set by Main.
self.SwitchMode: Callable[[str], None] = lambda Name: 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:
Background, Accent = NotificationStyle(Kind)
+8
View File
@@ -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
pillow # cover thumbnails
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"