From d7f5eba1c23873360f39c4f3de6b1fd914b458ce Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Fri, 9 Oct 2026 18:57:03 +0200 Subject: [PATCH] Media controls (MPRIS, Windows SMTC); stop old tracks when switching --- AudioMode.py | 11 +- Core.py | 3 + Covers.py | 25 +++- Main.py | 23 +++- MediaControls.py | 188 +++++++++++++++++++++++++++++ Mpris.py | 301 +++++++++++++++++++++++++++++++++++++++++++++++ MusicMode.py | 26 +++- PlayQueue.py | 4 - Player.py | 34 ++++-- README.md | 16 +++ Settings.py | 6 + Smtc.py | 178 ++++++++++++++++++++++++++++ Widgets.py | 2 + requirements.txt | 8 ++ 14 files changed, 799 insertions(+), 26 deletions(-) create mode 100644 MediaControls.py create mode 100644 Mpris.py create mode 100644 Smtc.py diff --git a/AudioMode.py b/AudioMode.py index 3f78486..8692156 100644 --- a/AudioMode.py +++ b/AudioMode.py @@ -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 diff --git a/Core.py b/Core.py index 5634775..aede4a4 100644 --- a/Core.py +++ b/Core.py @@ -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) diff --git a/Covers.py b/Covers.py index 6827b05..d2315f3 100644 --- a/Covers.py +++ b/Covers.py @@ -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: diff --git a/Main.py b/Main.py index e7fba7a..d5a4321 100644 --- a/Main.py +++ b/Main.py @@ -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()]) diff --git a/MediaControls.py b/MediaControls.py new file mode 100644 index 0000000..5ede150 --- /dev/null +++ b/MediaControls.py @@ -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() diff --git a/Mpris.py b/Mpris.py new file mode 100644 index 0000000..3bc04b3 --- /dev/null +++ b/Mpris.py @@ -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)}) diff --git a/MusicMode.py b/MusicMode.py index a66bfad..b2a2ae7 100644 --- a/MusicMode.py +++ b/MusicMode.py @@ -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: diff --git a/PlayQueue.py b/PlayQueue.py index fe0da6e..00dcdad 100644 --- a/PlayQueue.py +++ b/PlayQueue.py @@ -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.""" diff --git a/Player.py b/Player.py index 9773593..29cc829 100644 --- a/Player.py +++ b/Player.py @@ -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") diff --git a/README.md b/README.md index a5abf2b..b8dc9fe 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/Settings.py b/Settings.py index af57821..1738bb9 100644 --- a/Settings.py +++ b/Settings.py @@ -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 diff --git a/Smtc.py b/Smtc.py new file mode 100644 index 0000000..d6d6964 --- /dev/null +++ b/Smtc.py @@ -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 diff --git a/Widgets.py b/Widgets.py index ea4a8ff..22b3e62 100644 --- a/Widgets.py +++ b/Widgets.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 555dfca..16335fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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"