mirror of
https://git.srhtnin.garden/sarah/SaraTune.git
synced 2026-10-13 18:18:11 +02:00
Initial commit
This commit is contained in:
commit
0eeff23589
25 files changed
+3328
No files matched your search
+293
@@ -0,0 +1,293 @@
|
||||
"""Audio mode: open a file or a folder and listen, right away. A folder
|
||||
browser on the left, the playing file's cover, details and tags on the right.
|
||||
Nothing is saved: no library, no queue, no history."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from Core import AudioExtensions, DisplayPath, Home, IsAudioFile, Note, Palette
|
||||
from Covers import CoverFor
|
||||
from Metadata import ReadDetails, ReadTrack
|
||||
from Player import Player
|
||||
from Transport import CoverImage, Transport
|
||||
from Widgets import App, EmptyState, InfoTable
|
||||
|
||||
BrowserWidth = 360
|
||||
ViewerCoverSize = 220
|
||||
|
||||
|
||||
def NaturalKey(Name: str) -> list:
|
||||
""""Track 2" before "Track 10"."""
|
||||
return [int(Part) if Part.isdigit() else Part.lower() for Part in re.split(r"(\d+)", Name)]
|
||||
|
||||
|
||||
class AudioMode(ft.Column):
|
||||
def __init__(self, TheApp: App, ThePlayer: Player, OnVolume: Callable[[float], None]):
|
||||
super().__init__(expand=True, spacing=0)
|
||||
self.TheApp = TheApp
|
||||
self.ThePlayer = ThePlayer
|
||||
self.Folder: Optional[Path] = None
|
||||
self.Opened: list[Path] = [] # files opened together (not a folder listing)
|
||||
self.Files: list[Path] = [] # the audio files shown, in order: what Previous/Next walk
|
||||
self.Rows: dict[str, ft.Container] = {}
|
||||
self.Current: Optional[str] = None
|
||||
self.AutoAdvance = TheApp.Config.AudioAutoAdvance
|
||||
|
||||
ThePlayer.OnEnded = lambda: TheApp.Page.run_task(self._Ended)
|
||||
ThePlayer.OnChanged = lambda: self.Bar.Refresh()
|
||||
ThePlayer.OnError = lambda Message: TheApp.Notify(f"Can't play {Path(self.Current or '').name}: {Message}", "error")
|
||||
|
||||
# --- Browser ---------------------------------------------------------
|
||||
self.FolderLabel = ft.Text("", color=Palette.Text, size=13, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS,
|
||||
expand=True, tooltip="")
|
||||
self.UpButton = ft.IconButton(ft.Icons.ARROW_UPWARD, icon_color=Palette.Mauve, tooltip="Parent folder",
|
||||
on_click=self._Up)
|
||||
Toolbar = ft.Row([
|
||||
self.UpButton,
|
||||
self.FolderLabel,
|
||||
ft.IconButton(ft.Icons.AUDIO_FILE_OUTLINED, icon_color=Palette.Mauve, tooltip="Open files",
|
||||
on_click=self._PickFiles),
|
||||
ft.IconButton(ft.Icons.FOLDER_OPEN_OUTLINED, icon_color=Palette.Mauve, tooltip="Open folder",
|
||||
on_click=self._PickFolder),
|
||||
], spacing=2)
|
||||
self.List = ft.ListView(expand=True, spacing=0, item_extent=40)
|
||||
Browser = ft.Container(
|
||||
ft.Column([Toolbar, ft.Divider(height=1, color=Palette.Surface1), self.List], spacing=4, expand=True),
|
||||
width=BrowserWidth, bgcolor=Palette.Mantle, padding=ft.Padding.only(left=6, right=6, top=6),
|
||||
border=ft.Border.only(right=ft.BorderSide(1, Palette.Surface1)),
|
||||
)
|
||||
|
||||
# --- Viewer ----------------------------------------------------------
|
||||
self.Viewer = ft.Container(
|
||||
EmptyState("Pick a file to play it", "Click one on the left, or open files or a folder.",
|
||||
ft.Icons.HEADPHONES_OUTLINED),
|
||||
expand=True, padding=24,
|
||||
)
|
||||
|
||||
# --- Play bar --------------------------------------------------------
|
||||
self.LoopButton = ft.IconButton(ft.Icons.REPEAT_ONE, icon_color=Palette.Overlay0, tooltip="Loop this file",
|
||||
on_click=self._ToggleLoop)
|
||||
self.AdvanceButton = ft.IconButton(
|
||||
ft.Icons.PLAYLIST_PLAY, tooltip="Play the next file in the folder when one ends",
|
||||
icon_color=Palette.Mauve if self.AutoAdvance else Palette.Overlay0, on_click=self._ToggleAdvance,
|
||||
)
|
||||
self.Bar = Transport(ThePlayer, self.Previous, self.Next, OnVolume, Extras=[self.LoopButton, self.AdvanceButton])
|
||||
|
||||
self.controls = [
|
||||
ft.Row([Browser, self.Viewer], expand=True, spacing=0, vertical_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||
self.Bar,
|
||||
]
|
||||
Start = Home / "Music"
|
||||
self._ShowFolder(Start if Start.is_dir() else Home, Update=False)
|
||||
|
||||
|
||||
def _Show(self, Control: ft.Control) -> None:
|
||||
"""Updates Control if this mode is on screen; a hidden mode's changes
|
||||
go out with Main's page update when it's shown."""
|
||||
if self.Bar.Shown:
|
||||
Control.update()
|
||||
|
||||
# --- Browsing -----------------------------------------------------------
|
||||
|
||||
def _ShowFolder(self, Folder: Path, Update: bool = True) -> None:
|
||||
try:
|
||||
Children = list(Folder.iterdir())
|
||||
except OSError as Error:
|
||||
self.TheApp.Notify(f"Can't open {Folder}: {Error.strerror or Error}", "error")
|
||||
return
|
||||
self.Folder, self.Opened = Folder, []
|
||||
Folders = sorted((C for C in Children if C.is_dir() and not C.name.startswith(".")), key=lambda C: NaturalKey(C.name))
|
||||
self.Files = sorted((C for C in Children if C.is_file() and IsAudioFile(C)), key=lambda C: NaturalKey(C.name))
|
||||
self.FolderLabel.value = DisplayPath(str(Folder))
|
||||
self.FolderLabel.tooltip = str(Folder)
|
||||
self.UpButton.disabled = Folder.parent == Folder
|
||||
self._FillList(Folders)
|
||||
if Update:
|
||||
self._Show(self)
|
||||
|
||||
def _ShowOpened(self, Files: list[Path]) -> None:
|
||||
self.Folder, self.Opened, self.Files = None, Files, Files
|
||||
self.FolderLabel.value = f"{len(Files)} opened files"
|
||||
self.FolderLabel.tooltip = ""
|
||||
self.UpButton.disabled = False # goes to the first file's folder
|
||||
self._FillList([])
|
||||
self._Show(self)
|
||||
|
||||
def _FillList(self, Folders: list[Path]) -> None:
|
||||
self.Rows = {}
|
||||
Items: list[ft.Control] = []
|
||||
for Child in Folders:
|
||||
Items.append(self._Row(Child, ft.Icons.FOLDER, Palette.Yellow, lambda e, C=Child: self._ShowFolder(C)))
|
||||
for Child in self.Files:
|
||||
Row = self._Row(Child, ft.Icons.AUDIO_FILE, Palette.Mauve, lambda e, C=Child: self.TheApp.Page.run_task(self.PlayFile, str(C)))
|
||||
self.Rows[str(Child)] = Row
|
||||
Items.append(Row)
|
||||
if not Items:
|
||||
Items.append(ft.Container(Note("No audio files or folders here."), padding=12))
|
||||
self.List.controls = Items
|
||||
self._Highlight()
|
||||
|
||||
def _Row(self, Item: Path, Icon, Colour: str, OnClick) -> ft.Container:
|
||||
return ft.Container(
|
||||
ft.Row([
|
||||
ft.Icon(Icon, color=Colour, size=18),
|
||||
ft.Text(Item.name, size=13, color=Palette.Text, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, expand=True),
|
||||
], spacing=8),
|
||||
padding=ft.Padding.symmetric(horizontal=8), border_radius=4, on_click=OnClick, ink=True,
|
||||
tooltip=Item.name if len(Item.name) > 40 else None,
|
||||
)
|
||||
|
||||
def _Highlight(self) -> None:
|
||||
for PathText, Row in self.Rows.items():
|
||||
Row.bgcolor = Palette.Surface0 if PathText == self.Current else None
|
||||
|
||||
def _Up(self, e) -> None:
|
||||
if self.Opened:
|
||||
self._ShowFolder(self.Opened[0].parent)
|
||||
elif self.Folder and self.Folder.parent != self.Folder:
|
||||
self._ShowFolder(self.Folder.parent)
|
||||
|
||||
async def _PickFiles(self, e) -> None:
|
||||
Picked = await self.TheApp.Picker.pick_files(
|
||||
dialog_title="Open audio files", allow_multiple=True, file_type=ft.FilePickerFileType.CUSTOM,
|
||||
allowed_extensions=sorted(Extension.lstrip(".") for Extension in AudioExtensions),
|
||||
initial_directory=str(self.Folder) if self.Folder else None,
|
||||
)
|
||||
Paths = [Item.path for Item in Picked or [] if Item.path]
|
||||
if Paths:
|
||||
await self.Open(Paths)
|
||||
|
||||
async def _PickFolder(self, e) -> None:
|
||||
Chosen = await self.TheApp.Picker.get_directory_path(
|
||||
dialog_title="Open a folder", initial_directory=str(self.Folder) if self.Folder else None)
|
||||
if Chosen:
|
||||
self._ShowFolder(Path(Chosen))
|
||||
|
||||
async def Open(self, Paths: list[str]) -> None:
|
||||
"""Files (play the first) or a folder (show it). One file shows its
|
||||
whole folder, so Next carries on through it."""
|
||||
Items = [Path(Item).expanduser().resolve() for Item in Paths]
|
||||
Folders = [Item for Item in Items if Item.is_dir()]
|
||||
Files = [Item for Item in Items if Item.is_file()]
|
||||
if not Files:
|
||||
if Folders:
|
||||
self._ShowFolder(Folders[0])
|
||||
return
|
||||
if len(Files) == 1:
|
||||
self._ShowFolder(Files[0].parent)
|
||||
if str(Files[0]) not in self.Rows: # not an audio extension we list; play it anyway
|
||||
self.Files.append(Files[0])
|
||||
else:
|
||||
self._ShowOpened(Files)
|
||||
await self.PlayFile(str(Files[0]))
|
||||
|
||||
# --- Playing ------------------------------------------------------------
|
||||
|
||||
async def PlayFile(self, FilePath: str) -> None:
|
||||
Info = await asyncio.to_thread(ReadTrack, FilePath)
|
||||
if not Path(FilePath).is_file():
|
||||
self.TheApp.Notify(f"{Path(FilePath).name} is gone.", "error")
|
||||
return
|
||||
self.Current = FilePath
|
||||
await self.ThePlayer.Load(FilePath, Duration=Info.Duration) # errors arrive through OnError
|
||||
self._Highlight()
|
||||
self._Show(self.List)
|
||||
self._ScrollTo(FilePath)
|
||||
Subtitle = " - ".join(Part for Part in (Info.Artist, Info.Album) if Part)
|
||||
self.Bar.ShowTrack(Info.DisplayTitle, Subtitle or Path(FilePath).parent.name, None)
|
||||
await self._ShowDetails(FilePath, Info)
|
||||
|
||||
def _ScrollTo(self, FilePath: str) -> None:
|
||||
Keys = list(self.Rows)
|
||||
if FilePath in Keys:
|
||||
Offset = (len(self.List.controls) - len(Keys) + Keys.index(FilePath)) * 40 - 120
|
||||
self.TheApp.Page.run_task(self.List.scroll_to, offset=max(0, Offset), duration=200)
|
||||
|
||||
async def _ShowDetails(self, FilePath: str, Info) -> None:
|
||||
(Technical, Tags), Cover = await asyncio.gather(
|
||||
asyncio.to_thread(ReadDetails, FilePath), asyncio.to_thread(CoverFor, FilePath, ViewerCoverSize * 2))
|
||||
if self.Current != FilePath:
|
||||
return # another file was clicked meanwhile
|
||||
self.Bar.ShowTrack(self.Bar.Title.value, self.Bar.Subtitle.value, Cover)
|
||||
Heading = [
|
||||
ft.Text(Info.DisplayTitle, size=24, color=Palette.Mauve, selectable=True),
|
||||
ft.Text(Info.DisplayArtist if Info.Artist or Info.AlbumArtist else "", size=16, color=Palette.Text),
|
||||
ft.Text(" - ".join(Part for Part in (Info.Album, Info.Year) if Part), size=14, color=Palette.Overlay0),
|
||||
ft.Text(str(Path(FilePath).parent), size=12, color=Palette.Overlay0, selectable=True),
|
||||
]
|
||||
Sections: list[ft.Control] = [
|
||||
ft.Row([CoverImage(Cover, ViewerCoverSize, 6), ft.Column(Heading, spacing=6, expand=True)],
|
||||
spacing=24, vertical_alignment=ft.CrossAxisAlignment.START),
|
||||
ft.Divider(height=24, color=Palette.Surface1),
|
||||
ft.Text("Details", color=Palette.Mauve, size=16),
|
||||
InfoTable(Technical),
|
||||
]
|
||||
if Tags:
|
||||
Sections += [
|
||||
ft.Divider(height=24, color=Palette.Surface1),
|
||||
ft.Text("Tags", color=Palette.Mauve, size=16),
|
||||
InfoTable(Tags, LabelWidth=160),
|
||||
]
|
||||
self.Viewer.content = ft.Column(Sections, scroll=ft.ScrollMode.AUTO, expand=True, spacing=8)
|
||||
self._Show(self.Viewer)
|
||||
|
||||
def _Neighbour(self, Step: int) -> Optional[str]:
|
||||
Paths = [str(Item) for Item in self.Files]
|
||||
if not Paths:
|
||||
return None
|
||||
if self.Current not in Paths:
|
||||
return Paths[0]
|
||||
At = Paths.index(self.Current) + Step
|
||||
return Paths[At] if 0 <= At < len(Paths) else None
|
||||
|
||||
async def Next(self) -> None:
|
||||
Target = self._Neighbour(1)
|
||||
if Target:
|
||||
await self.PlayFile(Target)
|
||||
|
||||
async def Previous(self) -> None:
|
||||
if self.ThePlayer.Position > 3000:
|
||||
await self.ThePlayer.Seek(0)
|
||||
return
|
||||
Target = self._Neighbour(-1)
|
||||
if Target:
|
||||
await self.PlayFile(Target)
|
||||
|
||||
async def _Ended(self) -> None:
|
||||
Target = self._Neighbour(1) if self.AutoAdvance else None
|
||||
if Target:
|
||||
await self.PlayFile(Target)
|
||||
else:
|
||||
self.Bar.Refresh()
|
||||
|
||||
def _ToggleLoop(self, e) -> None:
|
||||
self.ThePlayer.Loop = not self.ThePlayer.Loop
|
||||
self.LoopButton.icon_color = Palette.Mauve if self.ThePlayer.Loop else Palette.Overlay0
|
||||
self._Show(self.LoopButton)
|
||||
|
||||
def _ToggleAdvance(self, e) -> None:
|
||||
self.AutoAdvance = not self.AutoAdvance
|
||||
self.AdvanceButton.icon_color = Palette.Mauve if self.AutoAdvance else Palette.Overlay0
|
||||
self._Show(self.AdvanceButton)
|
||||
|
||||
# --- Keys ---------------------------------------------------------------
|
||||
|
||||
async def HandleKey(self, e: ft.KeyboardEvent) -> None:
|
||||
"""Space: play/pause. Left/right: seek. Up/down: previous/next file."""
|
||||
Step = self.TheApp.Config.SeekSeconds * 1000
|
||||
if e.key == " ":
|
||||
await self.ThePlayer.Toggle()
|
||||
elif e.key == "Arrow Left":
|
||||
await self.ThePlayer.Seek(self.ThePlayer.Position - Step)
|
||||
self.Bar.Refresh()
|
||||
elif e.key == "Arrow Right":
|
||||
await self.ThePlayer.Seek(self.ThePlayer.Position + Step)
|
||||
self.Bar.Refresh()
|
||||
elif e.key == "Arrow Up":
|
||||
await self.Previous()
|
||||
elif e.key == "Arrow Down":
|
||||
await self.Next()
|
||||
Reference in new issue
Block a user