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 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
+3
View File
@@ -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)
+20 -5
View File
@@ -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:
+20 -3
View File
@@ -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()])
+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 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:
-4
View File
@@ -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."""
+27 -7
View File
@@ -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")
+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 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 |
+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) 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
+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. # 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)
+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 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"