"""The play bar at the bottom of both modes: what's playing, previous / play / next, the seek bar and volume. Each mode adds its own toggles (loop, shuffle, repeat) through Extras.""" import asyncio from typing import Awaitable, Callable, Optional import flet as ft from Core import FormatTime, Palette from Player import Player CoverSize = 52 def CoverImage(Data: Optional[bytes], Size: int, Radius: int = 4) -> ft.Control: """A cover, or a pixel-style note on the theme's colours when there's none.""" if Data: return ft.Image(src=Data, width=Size, height=Size, fit=ft.BoxFit.COVER, border_radius=Radius, filter_quality=ft.FilterQuality.MEDIUM, gapless_playback=True) return ft.Container( ft.Icon(ft.Icons.MUSIC_NOTE, color=Palette.Mauve, size=Size * 0.5), width=Size, height=Size, bgcolor=Palette.Surface0, border_radius=Radius, border=ft.Border.all(1, Palette.Surface1), alignment=ft.Alignment.CENTER, ) class Transport(ft.Container): def __init__(self, ThePlayer: Player, OnPrevious: Callable[[], Awaitable[None]], OnNext: Callable[[], Awaitable[None]], OnVolume: Callable[[float], None], Extras: Optional[list[ft.Control]] = None, OnInfoClick: Optional[Callable] = None): super().__init__( bgcolor=Palette.Mantle, border=ft.Border.only(top=ft.BorderSide(1, Palette.Surface1)), padding=ft.Padding.symmetric(horizontal=12, vertical=8), ) self.ThePlayer = ThePlayer self._Dragging = False self.Shown = False # set by Main: only the shown mode's bar is on the page self.Cover = ft.Container(CoverImage(None, CoverSize)) self.Title = ft.Text("Nothing playing", color=Palette.Text, size=15, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS) self.Subtitle = ft.Text("", color=Palette.Overlay0, size=12, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS) Info = ft.Container( ft.Row([self.Cover, ft.Column([self.Title, self.Subtitle], spacing=2, expand=True, tight=True)], spacing=10), width=280, on_click=OnInfoClick, ink=OnInfoClick is not None, border_radius=4, ) async def Previous(e) -> None: await OnPrevious() async def Next(e) -> None: await OnNext() async def Toggle(e) -> None: await ThePlayer.Toggle() self.PlayButton = ft.IconButton( ft.Icons.PLAY_ARROW_ROUNDED, icon_size=34, icon_color=Palette.Crust, bgcolor=Palette.Mauve, tooltip="Play / pause (space)", on_click=Toggle, ) Buttons = ft.Row([ ft.IconButton(ft.Icons.SKIP_PREVIOUS_ROUNDED, icon_color=Palette.Text, tooltip="Previous", on_click=Previous), self.PlayButton, ft.IconButton(ft.Icons.SKIP_NEXT_ROUNDED, icon_color=Palette.Text, tooltip="Next", on_click=Next), ], spacing=4) self.Elapsed = ft.Text("0:00", color=Palette.Overlay0, size=12, width=52, text_align=ft.TextAlign.RIGHT) self.Total = ft.Text("0:00", color=Palette.Overlay0, size=12, width=52) self.Seek = ft.Slider( min=0, max=1, value=0, expand=True, active_color=Palette.Mauve, inactive_color=Palette.Surface1, thumb_color=Palette.Mauve, on_change_start=self._DragStart, on_change=self._Dragged, on_change_end=self._DragEnd, ) def VolumeChanged(e) -> None: OnVolume(e.control.value) self.VolumeIcon.icon = self._VolumeIconFor(e.control.value) self.VolumeIcon.update() self.VolumeIcon = ft.Icon(self._VolumeIconFor(ThePlayer.Volume), color=Palette.Overlay0, size=20) self.Volume = ft.Slider(min=0, max=1, value=ThePlayer.Volume, width=120, active_color=Palette.Mauve, inactive_color=Palette.Surface1, thumb_color=Palette.Mauve, on_change=VolumeChanged) self.content = ft.Row([ Info, Buttons, self.Elapsed, self.Seek, self.Total, *(Extras or []), self.VolumeIcon, self.Volume, ], spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER) @staticmethod def _VolumeIconFor(Value: float): return ft.Icons.VOLUME_OFF if Value <= 0 else ft.Icons.VOLUME_DOWN if Value < 0.5 else ft.Icons.VOLUME_UP def SetVolume(self, Value: float) -> None: """The other mode's bar changed the shared volume.""" self.Volume.value = Value self.VolumeIcon.icon = self._VolumeIconFor(Value) def ShowTrack(self, Title: str, Subtitle: str, Cover: Optional[bytes]) -> None: self.Title.value = Title self.Subtitle.value = Subtitle self.Cover.content = CoverImage(Cover, CoverSize) if self.Shown: self.update() # --- Seek bar ----------------------------------------------------------- def _DragStart(self, e) -> None: self._Dragging = True def _Dragged(self, e) -> None: self.Elapsed.value = FormatTime(e.control.value) self.Elapsed.update() async def _DragEnd(self, e) -> None: self._Dragging = False await self.ThePlayer.Seek(int(e.control.value)) def Refresh(self) -> None: """Play button, times and seek bar from the player; called by a ticker a few times a second while playing.""" P = self.ThePlayer self.PlayButton.icon = ft.Icons.PAUSE_ROUNDED if P.Playing else ft.Icons.PLAY_ARROW_ROUNDED Duration = max(P.Duration, 1) self.Seek.max = Duration self.Seek.disabled = not P.Source self.Total.value = FormatTime(P.Duration) if not self._Dragging: self.Seek.value = min(P.Position, Duration) self.Elapsed.value = FormatTime(P.Position) if self.Shown: self.update() async def Ticker(Bars: list[Transport], Interval: float = 0.25) -> None: """Keeps the playing bars moving. Runs for the app's lifetime.""" while True: for Bar in Bars: if Bar.ThePlayer.Playing and Bar.Shown: Bar.Refresh() await asyncio.sleep(Interval)