Files

301 lines
14 KiB
Python

"""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, Track
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
self.NowPlaying: Optional[Track] = None # for the system media controls
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
self.NowPlaying = Info
await self.ThePlayer.Load(FilePath, Duration=Info.Duration) # errors arrive through OnError
self.TheApp.MediaChanged()
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.SetLoop(not self.ThePlayer.Loop)
def SetLoop(self, Enabled: bool) -> None:
self.ThePlayer.Loop = Enabled
self.LoopButton.icon_color = Palette.Mauve if self.ThePlayer.Loop else Palette.Overlay0
self._Show(self.LoopButton)
self.TheApp.MediaModesChanged()
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()