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