"""One audio player. Each mode has its own, so switching modes never loses what the other was playing; Main pauses one when the other starts. Every track gets a fresh flet_audio.Audio service: changing an existing one's src only works the first time (later play calls never answer), and sources are file:// URIs, since plain paths are read as URLs and break on names with # or %. Events from a replaced service are ignored. The Flet client only reports the position about once a second, so the position is kept here and moved along with the clock in between.""" import asyncio import time from pathlib import Path from typing import Callable, Optional import flet as ft import flet_audio as fta CallTimeout = 8 # seconds; a call the client never answers shouldn't hang the app class Player: def __init__(self, Page: ft.Page, Volume: float): self.Page = Page self.Volume = Volume self.Audio: Optional[fta.Audio] = None self.Source: Optional[str] = None self.Playing = False self.Duration = 0 # milliseconds; 0 until known self._Position = 0 self._PositionAt = time.monotonic() self._Ready = asyncio.Event() # the current service has loaded its source self.Loop = False # Set by the owner. OnEnded: the track ran out by itself. self.OnStarted: Callable[[], None] = lambda: None 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 ----------------------------------------------------------- @property def Position(self) -> int: if not self.Playing: return self._Position Moved = self._Position + (time.monotonic() - self._PositionAt) * 1000 return int(min(Moved, self.Duration) if self.Duration else Moved) def _SetPosition(self, Milliseconds: int) -> None: 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: if Audio is not self.Audio: return if e.state == fta.AudioState.PLAYING: if not self.Playing: self._SetPosition(self._Position) self.Playing = True self.OnStarted() self._Tell("Started") elif e.state == fta.AudioState.COMPLETED: self.Playing = False self._SetPosition(0) if self.Loop: self.Page.run_task(self.Play, 0) else: self.OnEnded() elif e.state in (fta.AudioState.PAUSED, fta.AudioState.STOPPED): if self.Playing: self._SetPosition(self.Position) self.Playing = False self._Tell("Changed") def _PositionChanged(self, Audio: fta.Audio, e: fta.AudioPositionChangeEvent) -> None: if Audio is self.Audio and self.Playing: self._SetPosition(e.position) def _DurationChanged(self, Audio: fta.Audio, e: fta.AudioDurationChangeEvent) -> None: if Audio is not self.Audio: return Duration = e.duration Value = int(Duration.in_milliseconds if isinstance(Duration, ft.Duration) else Duration or 0) if Value > 0: self.Duration = Value self._Ready.set() self._Tell("Changed") def _Loaded(self, Audio: fta.Audio) -> None: if Audio is self.Audio: self._Ready.set() # --- Control ------------------------------------------------------------ async def _Call(self, Method, *Arguments) -> bool: try: await asyncio.wait_for(Method(*Arguments), CallTimeout) return True except (asyncio.TimeoutError, RuntimeError) as Error: # e.g. GStreamer can't decode it: the client raises a PlatformException. Text = str(Error) self.OnError("The player didn't answer." if isinstance(Error, asyncio.TimeoutError) else Text.split(",")[1].strip() if "PlatformException" in Text and "," in Text else Text) return False async def _Unload(self) -> None: 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() 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 a hint (e.g. from the tags) until the client reports the real one. False when the file doesn't exist or can't be played.""" if not Path(FilePath).is_file(): return False await self._Unload() self.Source = FilePath self.Duration = Duration self.Playing = False self._SetPosition(Start) self._Ready = asyncio.Event() Audio = fta.Audio( src=Path(FilePath).absolute().as_uri(), volume=self.Volume, release_mode=fta.ReleaseMode.STOP, ) # Handlers know which service they belong to, so a replaced one's late events are dropped. Audio.on_state_change = lambda e: self._StateChanged(Audio, e) Audio.on_position_change = lambda e: self._PositionChanged(Audio, e) Audio.on_duration_change = lambda e: self._DurationChanged(Audio, e) Audio.on_loaded = lambda e: self._Loaded(Audio) self.Audio = Audio self.Page.services.append(Audio) self.Page.update() self._Tell("Changed") if Play: return await self.Play(Start) return True async def _WaitReady(self) -> None: try: await asyncio.wait_for(self._Ready.wait(), 3) except asyncio.TimeoutError: pass # play anyway; it answers with an error if the file is bad async def Play(self, Start: Optional[int] = None) -> bool: """From Start (ms), or from where it was.""" if not self.Source: return False if self.Audio is None: # stopped: load it again return await self.Load(self.Source, self._Position if Start is None else Start, Duration=self.Duration) Audio = self.Audio await self._WaitReady() Position = self._Position if Start is None else Start self._SetPosition(Position) if Position > 0: # play()'s own position isn't reliable before the first play: start, then seek. if not await self._Call(Audio.resume): return False 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)) async def Pause(self) -> None: if self.Playing and self.Audio is not None: self._SetPosition(self.Position) self.Playing = False await self._Call(self.Audio.pause) self._Tell("Changed") async def Toggle(self) -> None: if self.Playing: await self.Pause() else: # A finished track (position at the end) starts over. AtEnd = self.Duration and self._Position >= self.Duration - 250 await self.Play(0 if AtEnd else None) async def Seek(self, Milliseconds: int) -> None: if not self.Source: return Milliseconds = max(0, min(int(Milliseconds), self.Duration or int(Milliseconds))) self._SetPosition(Milliseconds) if self.Playing and self.Audio is not None: await self._Call(self.Audio.seek, ft.Duration(milliseconds=Milliseconds)) else: # Paused or not started: remember it, Play starts from there. self._Tell("Changed") self._Tell("Seeked") async def Stop(self) -> None: """Back to nothing loaded.""" await self._Unload() self.Source = None self.Playing = False self.Duration = 0 self._SetPosition(0) 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")