Files
SaraTune/Transport.py
T
2026-10-09 15:33:43 +02:00

147 lines
6.1 KiB
Python

"""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)