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
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
build/
|
||||||
|
.flet/
|
||||||
|
dist/
|
||||||
|
*.AppImage
|
||||||
+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()
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
"""Shared app-wide constants: paths, palette, theme, SaraTune's own config.
|
||||||
|
|
||||||
|
The palette, theming and shared settings mirror SaraSuite's and SaraFem's
|
||||||
|
Core.py and read the same ~/.config/SaraSuite files, so a theme picked for
|
||||||
|
one Sara program looks the same in all of them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
AppName = "SaraTune"
|
||||||
|
# Bundled in assets/fonts and always registered, so it works as FontFamily
|
||||||
|
# without being installed.
|
||||||
|
BundledFontFamily = "Fixedsys Excelsior"
|
||||||
|
ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme
|
||||||
|
|
||||||
|
IsWindows = sys.platform == "win32"
|
||||||
|
|
||||||
|
# Each system's own per-user folders, like SaraSuite's: ~/.config and
|
||||||
|
# ~/.local/share on Linux, %APPDATA% and %LOCALAPPDATA% on Windows.
|
||||||
|
Home = Path.home()
|
||||||
|
if IsWindows:
|
||||||
|
ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming")
|
||||||
|
DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local")
|
||||||
|
else:
|
||||||
|
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
|
||||||
|
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
|
||||||
|
|
||||||
|
SharedConfigDir = ConfigHome / "SaraSuite"
|
||||||
|
ThemesDir = SharedConfigDir / "Themes"
|
||||||
|
SharedConfigPath = SharedConfigDir / "Config.toml"
|
||||||
|
ConfigDir = SharedConfigDir / AppName
|
||||||
|
ConfigPath = ConfigDir / "Config.toml"
|
||||||
|
PlaylistsDir = ConfigDir / "Playlists" # one <Name>.toml per playlist
|
||||||
|
SessionPath = ConfigDir / "Session.toml" # Music mode's queue and position
|
||||||
|
CacheDir = (DataHome if IsWindows else Path(os.environ.get("XDG_CACHE_HOME") or Home / ".cache")) / "SaraSuite" / AppName
|
||||||
|
LibraryCachePath = CacheDir / "Library.toml" # tags of every scanned file, keyed by path
|
||||||
|
|
||||||
|
# What both modes list. Playback itself goes through the Flet client's
|
||||||
|
# audioplayers plugin (GStreamer on Linux, Media Foundation on Windows), so
|
||||||
|
# what actually plays depends on the system's codecs.
|
||||||
|
AudioExtensions = {
|
||||||
|
".mp3", ".flac", ".ogg", ".oga", ".opus", ".wav", ".m4a", ".aac", ".aif", ".aiff", ".wma", ".mka", ".webm",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inside a PyInstaller build, bundled files are unpacked to sys._MEIPASS.
|
||||||
|
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
||||||
|
AssetsDir = BundleDir / "assets"
|
||||||
|
|
||||||
|
# Themes SaraFem seeds into ThemesDir. Only other themes travel with an
|
||||||
|
# export as full palettes (same rule as SaraFem's ExportSettings).
|
||||||
|
BuiltinThemeNames = {"CatppuccinMocha", "Forest", "Gruvbox"}
|
||||||
|
|
||||||
|
|
||||||
|
class Palette:
|
||||||
|
"""Live values for the active theme (see ApplyTheme). Same attribute names
|
||||||
|
and role mapping as SaraSuite's and SaraFem's Palette."""
|
||||||
|
|
||||||
|
Mauve = "#cba6f7"
|
||||||
|
Red = "#f38ba8"
|
||||||
|
Peach = "#fab387"
|
||||||
|
Yellow = "#f9e2af"
|
||||||
|
Green = "#a6e3a1"
|
||||||
|
Blue = "#89b4fa"
|
||||||
|
Text = "#cdd6f4"
|
||||||
|
White = "#cdd6f4"
|
||||||
|
Overlay0 = "#6c7086"
|
||||||
|
Surface1 = "#45475a"
|
||||||
|
Surface0 = "#313244"
|
||||||
|
Base = "#1e1e2e"
|
||||||
|
Mantle = "#181825"
|
||||||
|
Crust = "#11111b"
|
||||||
|
|
||||||
|
|
||||||
|
_ThemeRoleMap = {
|
||||||
|
"Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1",
|
||||||
|
"Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White",
|
||||||
|
"Red": "Red", "Orange": "Peach", "Yellow": "Yellow", "Green": "Green",
|
||||||
|
"Blue": "Blue", "Purple": "Mauve",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ApplyTheme(ThemeName: str) -> None:
|
||||||
|
"""Mutate Palette in place from ~/.config/SaraSuite/Themes/<ThemeName>.toml.
|
||||||
|
Keeps the built-in Catppuccin Mocha values if the file is missing. Must
|
||||||
|
run before any control reads Palette.X."""
|
||||||
|
ThemeFile = ThemesDir / f"{ThemeName}.toml"
|
||||||
|
if not ThemeFile.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
RoleValues = tomlkit.parse(ThemeFile.read_text()).unwrap().get("Palette", {})
|
||||||
|
except tomlkit.exceptions.TOMLKitError:
|
||||||
|
return
|
||||||
|
for RoleName, AttrName in _ThemeRoleMap.items():
|
||||||
|
if RoleName in RoleValues:
|
||||||
|
setattr(Palette, AttrName, RoleValues[RoleName])
|
||||||
|
|
||||||
|
|
||||||
|
def AvailableThemes() -> list[str]:
|
||||||
|
Names = {ThemePath.stem for ThemePath in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set()
|
||||||
|
Names.add("CatppuccinMocha")
|
||||||
|
return sorted(Names)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
# True: Theme, FontFamily and HideWindowTitleBar come from the shared
|
||||||
|
# ~/.config/SaraSuite/Config.toml. False: SaraTune's own values below.
|
||||||
|
UseSharedSettings: bool = True
|
||||||
|
Theme: str = "CatppuccinMocha"
|
||||||
|
FontFamily: str = BundledFontFamily
|
||||||
|
HideWindowTitleBar: bool = False
|
||||||
|
StartMode: str = "Music" # "Audio" or "Music"; opening files always starts in Audio
|
||||||
|
# Music mode's library: every audio file below these folders.
|
||||||
|
MusicFolders: list[str] = field(default_factory=lambda: [str(Home / "Music")])
|
||||||
|
ScanOnStart: bool = True # look for new and changed files at every start
|
||||||
|
Volume: float = 1.0 # shared by both modes, saved when changed
|
||||||
|
# Audio mode: seek step for the arrow keys, and whether a finished file
|
||||||
|
# moves on to the next one in its folder.
|
||||||
|
SeekSeconds: int = 5
|
||||||
|
AudioAutoAdvance: bool = False
|
||||||
|
# Music mode: keep the queue and position between runs.
|
||||||
|
RememberSession: bool = True
|
||||||
|
|
||||||
|
def Save(self) -> None:
|
||||||
|
ConfigDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ConfigPath.write_text(tomlkit.dumps(asdict(self)))
|
||||||
|
|
||||||
|
|
||||||
|
def LoadConfig() -> AppConfig:
|
||||||
|
if not ConfigPath.exists():
|
||||||
|
Config = AppConfig()
|
||||||
|
Config.Save()
|
||||||
|
return Config
|
||||||
|
try:
|
||||||
|
Data = tomlkit.parse(ConfigPath.read_text()).unwrap()
|
||||||
|
except tomlkit.exceptions.TOMLKitError:
|
||||||
|
return AppConfig()
|
||||||
|
KnownFields = set(AppConfig.__dataclass_fields__)
|
||||||
|
return AppConfig(**{Key: Value for Key, Value in Data.items() if Key in KnownFields})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SharedSettings:
|
||||||
|
"""~/.config/SaraSuite/Config.toml, shared by every SaraSuite program.
|
||||||
|
Same schema and fallback rules as SaraSuite's and SaraFem's."""
|
||||||
|
|
||||||
|
Theme: str = "CatppuccinMocha"
|
||||||
|
FontFamily: str = BundledFontFamily
|
||||||
|
HideWindowTitleBar: bool = False
|
||||||
|
|
||||||
|
def Save(self) -> None:
|
||||||
|
SharedConfigDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
SharedConfigPath.write_text(tomlkit.dumps(asdict(self)))
|
||||||
|
|
||||||
|
|
||||||
|
def _OwnSettings(Config: AppConfig) -> SharedSettings:
|
||||||
|
return SharedSettings(Config.Theme, Config.FontFamily, Config.HideWindowTitleBar)
|
||||||
|
|
||||||
|
|
||||||
|
def LoadSharedSettings(Config: AppConfig) -> SharedSettings:
|
||||||
|
"""The shared file, created from SaraTune's own values if missing."""
|
||||||
|
if not SharedConfigPath.exists():
|
||||||
|
Shared = _OwnSettings(Config)
|
||||||
|
Shared.Save()
|
||||||
|
return Shared
|
||||||
|
Result = _OwnSettings(Config) # fallback for keys the file doesn't have
|
||||||
|
try:
|
||||||
|
Data = tomlkit.parse(SharedConfigPath.read_text()).unwrap()
|
||||||
|
except tomlkit.exceptions.TOMLKitError:
|
||||||
|
return Result
|
||||||
|
for Key in SharedSettings.__dataclass_fields__:
|
||||||
|
if Key in Data:
|
||||||
|
setattr(Result, Key, Data[Key])
|
||||||
|
return Result
|
||||||
|
|
||||||
|
|
||||||
|
def EffectiveSettings(Config: AppConfig) -> SharedSettings:
|
||||||
|
return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config)
|
||||||
|
|
||||||
|
|
||||||
|
def ExportSettings(Config: AppConfig, DestPath: Path) -> None:
|
||||||
|
"""One portable file in the same format as SaraFem's export: the shared
|
||||||
|
settings under [SaraSuite] (with a custom theme's palette as ThemeData)
|
||||||
|
and SaraTune's own under [SaraTune]. Other programs ignore [SaraTune],
|
||||||
|
and SaraTune ignores their sections."""
|
||||||
|
Shared = LoadSharedSettings(Config)
|
||||||
|
SharedData = asdict(Shared)
|
||||||
|
if Shared.Theme not in BuiltinThemeNames:
|
||||||
|
ThemeFile = ThemesDir / f"{Shared.Theme}.toml"
|
||||||
|
if ThemeFile.exists():
|
||||||
|
try:
|
||||||
|
SharedData["ThemeData"] = tomlkit.parse(ThemeFile.read_text()).unwrap().get("Palette", {})
|
||||||
|
except tomlkit.exceptions.TOMLKitError:
|
||||||
|
pass
|
||||||
|
DestPath.write_text(tomlkit.dumps({"SaraSuite": SharedData, AppName: asdict(Config)}))
|
||||||
|
|
||||||
|
|
||||||
|
def ImportSettings(Config: AppConfig, SourcePath: Path) -> None:
|
||||||
|
"""Applies [SaraSuite] to the shared file and [SaraTune] onto Config, key
|
||||||
|
by key: keys the file doesn't mention stay as they are, unknown sections
|
||||||
|
and keys are skipped. Raises TOMLKitError on an unreadable file."""
|
||||||
|
Data = tomlkit.parse(SourcePath.read_text()).unwrap()
|
||||||
|
|
||||||
|
SharedData = Data.get("SaraSuite")
|
||||||
|
if isinstance(SharedData, dict):
|
||||||
|
ThemeData = SharedData.pop("ThemeData", None)
|
||||||
|
Merged = asdict(LoadSharedSettings(Config))
|
||||||
|
Merged.update({Key: Value for Key, Value in SharedData.items() if Key in SharedSettings.__dataclass_fields__})
|
||||||
|
ImportedTheme = Merged.get("Theme")
|
||||||
|
if isinstance(ThemeData, dict) and ImportedTheme and ImportedTheme not in BuiltinThemeNames:
|
||||||
|
ThemesDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(ThemesDir / f"{ImportedTheme}.toml").write_text(tomlkit.dumps({"Palette": ThemeData}))
|
||||||
|
SharedSettings(**Merged).Save()
|
||||||
|
|
||||||
|
OwnData = Data.get(AppName)
|
||||||
|
if isinstance(OwnData, dict):
|
||||||
|
for Key, Value in OwnData.items():
|
||||||
|
if Key in AppConfig.__dataclass_fields__:
|
||||||
|
setattr(Config, Key, Value)
|
||||||
|
Config.Save()
|
||||||
|
|
||||||
|
|
||||||
|
def ApplyAppearance(Settings: SharedSettings) -> None:
|
||||||
|
"""Theme colours and font; must run before anything reads Palette.X."""
|
||||||
|
global ActiveFontFamily
|
||||||
|
ApplyTheme(Settings.Theme)
|
||||||
|
ActiveFontFamily = Settings.FontFamily or BundledFontFamily
|
||||||
|
|
||||||
|
|
||||||
|
def ChildEnvironment() -> dict[str, str]:
|
||||||
|
"""The environment for child processes (xdg-open, fc-list).
|
||||||
|
A PyInstaller build points LD_LIBRARY_PATH at its own bundled libraries;
|
||||||
|
system tools need the original back."""
|
||||||
|
Environment = dict(os.environ)
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
Original = Environment.pop("LD_LIBRARY_PATH_ORIG", None)
|
||||||
|
if Original is not None:
|
||||||
|
Environment["LD_LIBRARY_PATH"] = Original
|
||||||
|
else:
|
||||||
|
Environment.pop("LD_LIBRARY_PATH", None)
|
||||||
|
for Key in ("PYTHONHOME", "PYTHONPATH"):
|
||||||
|
Environment.pop(Key, None)
|
||||||
|
if IsWindows:
|
||||||
|
Environment["PYTHONUTF8"] = "1" # every Sara program reads and writes UTF-8
|
||||||
|
return Environment
|
||||||
|
|
||||||
|
|
||||||
|
def BuildTheme() -> ft.Theme:
|
||||||
|
ColorScheme = ft.ColorScheme(
|
||||||
|
primary=Palette.Mauve,
|
||||||
|
on_primary=Palette.Crust,
|
||||||
|
primary_container=Palette.Surface0,
|
||||||
|
on_primary_container=Palette.Mauve,
|
||||||
|
secondary=Palette.Mauve,
|
||||||
|
on_secondary=Palette.Crust,
|
||||||
|
secondary_container=Palette.Surface0,
|
||||||
|
on_secondary_container=Palette.Mauve,
|
||||||
|
surface=Palette.Base,
|
||||||
|
on_surface=Palette.Text,
|
||||||
|
on_surface_variant=Palette.Overlay0,
|
||||||
|
surface_container=Palette.Mantle,
|
||||||
|
surface_container_low=Palette.Mantle,
|
||||||
|
surface_container_high=Palette.Surface0,
|
||||||
|
surface_container_highest=Palette.Surface1,
|
||||||
|
outline=Palette.Surface1,
|
||||||
|
outline_variant=Palette.Surface1,
|
||||||
|
error=Palette.Red,
|
||||||
|
on_error=Palette.Crust,
|
||||||
|
)
|
||||||
|
return ft.Theme(
|
||||||
|
font_family=ActiveFontFamily,
|
||||||
|
color_scheme=ColorScheme,
|
||||||
|
use_material3=True,
|
||||||
|
scaffold_bgcolor=Palette.Base,
|
||||||
|
canvas_color=Palette.Base,
|
||||||
|
dialog_theme=ft.DialogTheme(
|
||||||
|
bgcolor=Palette.Mantle,
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=8, side=ft.BorderSide(1, Palette.Mauve)),
|
||||||
|
title_text_style=ft.TextStyle(color=Palette.Mauve, size=18, font_family=ActiveFontFamily),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str:
|
||||||
|
"""Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent),
|
||||||
|
for tinted-but-still-dark backgrounds. Same as SaraFem's."""
|
||||||
|
BaseRGB = tuple(int(BaseHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
|
||||||
|
AccentRGB = tuple(int(AccentHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
|
||||||
|
Mixed = (round(B + (A - B) * Ratio) for B, A in zip(BaseRGB, AccentRGB))
|
||||||
|
return "#" + "".join(f"{C:02x}" for C in Mixed)
|
||||||
|
|
||||||
|
|
||||||
|
def NotificationStyle(Kind: str) -> tuple[str, str]:
|
||||||
|
"""(Background, AccentColor) for a toast, from the theme's own colours like
|
||||||
|
SaraFem's: "success" green, "error" red, plus "warning" yellow and "info"
|
||||||
|
mauve for plain notes."""
|
||||||
|
Accent = {"success": Palette.Green, "error": Palette.Red, "warning": Palette.Yellow}.get(Kind, Palette.Mauve)
|
||||||
|
return BlendHex(Palette.Mantle, Accent, 0.12), Accent
|
||||||
|
|
||||||
|
|
||||||
|
def ChipButtonStyle(Accent: str = "") -> ft.ButtonStyle:
|
||||||
|
"""SaraView's button look: SurfaceAlt fill, 1px accent border, small radius."""
|
||||||
|
return ft.ButtonStyle(
|
||||||
|
color=Palette.Text,
|
||||||
|
bgcolor=Palette.Surface0,
|
||||||
|
side=ft.BorderSide(1, Accent or Palette.Mauve),
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=4),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ChipButton(Text: str, OnClick, Icon=None, Accent: str = "", Disabled: bool = False) -> ft.OutlinedButton:
|
||||||
|
return ft.OutlinedButton(content=Text, icon=Icon, style=ChipButtonStyle(Accent), on_click=OnClick, disabled=Disabled)
|
||||||
|
|
||||||
|
|
||||||
|
def Note(Text: str, Size: int = 12) -> ft.Text:
|
||||||
|
return ft.Text(Text, color=Palette.Overlay0, size=Size)
|
||||||
|
|
||||||
|
|
||||||
|
def Heading(Text: str) -> ft.Text:
|
||||||
|
return ft.Text(Text, color=Palette.Mauve, size=20)
|
||||||
|
|
||||||
|
|
||||||
|
def DisplayPath(PathText: str) -> str:
|
||||||
|
"""/home/sarah/Music -> ~/Music, for labels."""
|
||||||
|
HomeText = str(Home)
|
||||||
|
if PathText == HomeText or PathText.startswith(HomeText + os.sep):
|
||||||
|
return "~" + PathText[len(HomeText):]
|
||||||
|
return PathText
|
||||||
|
|
||||||
|
|
||||||
|
def FormatTime(Milliseconds: float) -> str:
|
||||||
|
"""3:07, or 1:02:45 for an hour or more."""
|
||||||
|
Seconds = max(0, int(Milliseconds // 1000))
|
||||||
|
Hours, Seconds = divmod(Seconds, 3600)
|
||||||
|
Minutes, Seconds = divmod(Seconds, 60)
|
||||||
|
return f"{Hours}:{Minutes:02}:{Seconds:02}" if Hours else f"{Minutes}:{Seconds:02}"
|
||||||
|
|
||||||
|
|
||||||
|
def IsAudioFile(PathValue: Path) -> bool:
|
||||||
|
return PathValue.suffix.lower() in AudioExtensions and not PathValue.name.startswith(".")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Small versions of cover art. Embedded covers can be several megabytes,
|
||||||
|
and everything shown goes through Flet's connection, so the album grid and
|
||||||
|
play bar get thumbnails, cached in ~/.cache/SaraSuite/SaraTune/Covers/."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
from Core import CacheDir
|
||||||
|
from Metadata import ReadCover
|
||||||
|
|
||||||
|
CoversDir = CacheDir / "Covers"
|
||||||
|
_NoCover = b"" # remembered in the memory cache so a coverless album isn't read again
|
||||||
|
|
||||||
|
|
||||||
|
def Thumbnail(Data: bytes, Size: int) -> Optional[bytes]:
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(Data)) as Picture:
|
||||||
|
Picture = Picture.convert("RGB")
|
||||||
|
Picture.thumbnail((Size, Size), Image.LANCZOS)
|
||||||
|
Output = io.BytesIO()
|
||||||
|
Picture.save(Output, "JPEG", quality=88)
|
||||||
|
return Output.getvalue()
|
||||||
|
except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_Memory: dict[tuple[str, int], bytes] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def CoverFor(FilePath: str, Size: int) -> Optional[bytes]:
|
||||||
|
"""FilePath's cover at most Size pixels wide. Blocking (run it in a
|
||||||
|
thread). Cached on disk by path, size and the file's mtime."""
|
||||||
|
Key = (FilePath, Size)
|
||||||
|
if Key in _Memory:
|
||||||
|
return _Memory[Key] or None
|
||||||
|
try:
|
||||||
|
Stamp = Path(FilePath).stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
Name = hashlib.sha1(f"{FilePath}|{Size}|{Stamp}".encode("utf-8", "surrogateescape")).hexdigest()
|
||||||
|
CachePath = CoversDir / f"{Name}.jpg"
|
||||||
|
if CachePath.exists():
|
||||||
|
Data = CachePath.read_bytes()
|
||||||
|
else:
|
||||||
|
Original = ReadCover(FilePath)
|
||||||
|
Data = Thumbnail(Original, Size) if Original else None
|
||||||
|
if Data:
|
||||||
|
try:
|
||||||
|
CoversDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
CachePath.write_bytes(Data)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_Memory[Key] = Data or _NoCover
|
||||||
|
return Data
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
"""Music mode's library: every audio file below the configured folders, with
|
||||||
|
their tags. The tags are cached in ~/.cache/SaraSuite/SaraTune/Library.toml
|
||||||
|
so a start only reads files that are new or changed since the last scan."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from Core import IsAudioFile, LibraryCachePath
|
||||||
|
from Metadata import Track, ReadTrack
|
||||||
|
|
||||||
|
CacheVersion = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _TomlValue(Value) -> str:
|
||||||
|
# JSON's string escapes are valid TOML basic-string escapes.
|
||||||
|
if isinstance(Value, str):
|
||||||
|
return json.dumps(Value, ensure_ascii=False)
|
||||||
|
return repr(Value)
|
||||||
|
|
||||||
|
|
||||||
|
class Library:
|
||||||
|
def __init__(self):
|
||||||
|
self.Tracks: dict[str, Track] = {}
|
||||||
|
|
||||||
|
# --- Cache --------------------------------------------------------------
|
||||||
|
|
||||||
|
def LoadCache(self) -> None:
|
||||||
|
try:
|
||||||
|
Data = tomllib.loads(LibraryCachePath.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, tomllib.TOMLDecodeError):
|
||||||
|
return
|
||||||
|
if Data.get("Version") != CacheVersion:
|
||||||
|
return
|
||||||
|
self.Tracks = {}
|
||||||
|
for Entry in Data.get("Tracks", []):
|
||||||
|
if "Path" in Entry:
|
||||||
|
self.Tracks[Entry["Path"]] = Track.FromDict(Entry)
|
||||||
|
|
||||||
|
def SaveCache(self) -> None:
|
||||||
|
# Written by hand: tomlkit takes seconds for a few thousand tables.
|
||||||
|
Lines = [f"Version = {CacheVersion}", ""]
|
||||||
|
for Item in sorted(self.Tracks.values(), key=lambda T: T.Path):
|
||||||
|
Lines.append("[[Tracks]]")
|
||||||
|
Lines += [f"{Key} = {_TomlValue(Value)}" for Key, Value in Item.ToDict().items()]
|
||||||
|
Lines.append("")
|
||||||
|
LibraryCachePath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Temporary = LibraryCachePath.with_suffix(".new")
|
||||||
|
Temporary.write_text("\n".join(Lines), encoding="utf-8")
|
||||||
|
Temporary.replace(LibraryCachePath)
|
||||||
|
|
||||||
|
def ClearCache(self) -> None:
|
||||||
|
self.Tracks = {}
|
||||||
|
LibraryCachePath.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# --- Scanning -----------------------------------------------------------
|
||||||
|
|
||||||
|
def Scan(self, Folders: list[str], OnProgress: Optional[Callable[[int], None]] = None) -> tuple[int, int]:
|
||||||
|
"""Reads new and changed files, forgets ones that are gone. Blocking
|
||||||
|
(run it in a thread). Returns (added or changed, removed)."""
|
||||||
|
Found: dict[str, float] = {}
|
||||||
|
for Folder in Folders:
|
||||||
|
Root = Path(Folder).expanduser()
|
||||||
|
if not Root.is_dir():
|
||||||
|
continue
|
||||||
|
for DirPath, DirNames, FileNames in os.walk(Root, followlinks=True):
|
||||||
|
DirNames[:] = [Name for Name in DirNames if not Name.startswith(".")]
|
||||||
|
for Name in FileNames:
|
||||||
|
FilePath = Path(DirPath) / Name
|
||||||
|
if IsAudioFile(FilePath):
|
||||||
|
try:
|
||||||
|
Found[str(FilePath)] = FilePath.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
Changed = 0
|
||||||
|
for Index, (FilePath, Modified) in enumerate(Found.items()):
|
||||||
|
Known = self.Tracks.get(FilePath)
|
||||||
|
if Known is None or Known.Modified != Modified:
|
||||||
|
self.Tracks[FilePath] = ReadTrack(FilePath)
|
||||||
|
Changed += 1
|
||||||
|
if OnProgress and Changed % 25 == 0:
|
||||||
|
OnProgress(Index + 1)
|
||||||
|
Gone = [FilePath for FilePath in self.Tracks if FilePath not in Found]
|
||||||
|
for FilePath in Gone:
|
||||||
|
del self.Tracks[FilePath]
|
||||||
|
if Changed or Gone:
|
||||||
|
self.SaveCache()
|
||||||
|
return Changed, len(Gone)
|
||||||
|
|
||||||
|
# --- Views --------------------------------------------------------------
|
||||||
|
|
||||||
|
def Get(self, FilePath: str) -> Track:
|
||||||
|
"""The library's Track, or one read on the spot for files outside it
|
||||||
|
(playlists may point anywhere)."""
|
||||||
|
return self.Tracks.get(FilePath) or ReadTrack(FilePath)
|
||||||
|
|
||||||
|
def Songs(self) -> list[Track]:
|
||||||
|
return sorted(self.Tracks.values(), key=SongKey)
|
||||||
|
|
||||||
|
def Albums(self) -> list[list[Track]]:
|
||||||
|
"""Each album's tracks in disc/track order; albums by artist, then name."""
|
||||||
|
Groups: dict[tuple[str, str], list[Track]] = defaultdict(list)
|
||||||
|
for Item in self.Tracks.values():
|
||||||
|
if Item.Album:
|
||||||
|
Groups[Item.AlbumKey].append(Item)
|
||||||
|
return [sorted(Tracks, key=AlbumOrderKey) for _, Tracks in sorted(Groups.items())]
|
||||||
|
|
||||||
|
def Artists(self) -> list[tuple[str, list[Track]]]:
|
||||||
|
Groups: dict[str, list[Track]] = defaultdict(list)
|
||||||
|
Names: dict[str, str] = {}
|
||||||
|
for Item in self.Tracks.values():
|
||||||
|
Name = Item.AlbumArtist or Item.Artist or "Unknown artist"
|
||||||
|
Groups[Name.lower()].append(Item)
|
||||||
|
Names.setdefault(Name.lower(), Name)
|
||||||
|
return [(Names[Key], sorted(Groups[Key], key=ArtistOrderKey)) for Key in sorted(Groups)]
|
||||||
|
|
||||||
|
|
||||||
|
def SongKey(Item: Track) -> tuple:
|
||||||
|
return Item.DisplayArtist.lower(), Item.DisplayAlbum.lower(), Item.DiscNumber, Item.TrackNumber, Item.DisplayTitle.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def AlbumOrderKey(Item: Track) -> tuple:
|
||||||
|
return Item.DiscNumber, Item.TrackNumber, Item.DisplayTitle.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def ArtistOrderKey(Item: Track) -> tuple:
|
||||||
|
return Item.Year, Item.DisplayAlbum.lower(), Item.DiscNumber, Item.TrackNumber, Item.DisplayTitle.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def Matches(Item: Track, Query: str) -> bool:
|
||||||
|
"""Every word of the search has to appear in the title, artist, album or genre."""
|
||||||
|
Haystack = " ".join((Item.DisplayTitle, Item.Artist, Item.AlbumArtist, Item.Album, Item.Genre)).lower()
|
||||||
|
return all(Word in Haystack for Word in Query.lower().split())
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""SaraTune: a music player with two modes.
|
||||||
|
|
||||||
|
- Audio: open a file or folder and listen right away; shows the file's
|
||||||
|
details and tags. Nothing is saved.
|
||||||
|
- Music: a library of your music folders, a queue, and saved playlists.
|
||||||
|
|
||||||
|
Files given on the command line (e.g. "Open with SaraTune") open in Audio mode."""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from AudioMode import AudioMode
|
||||||
|
from Core import (
|
||||||
|
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataHome, EffectiveSettings,
|
||||||
|
IsWindows, LoadConfig, Palette,
|
||||||
|
)
|
||||||
|
from MusicMode import MusicMode
|
||||||
|
from Player import Player
|
||||||
|
from Settings import ShowSettingsDialog
|
||||||
|
from Transport import Ticker
|
||||||
|
from Widgets import App
|
||||||
|
|
||||||
|
Modes = [("Audio", ft.Icons.HEADPHONES_OUTLINED), ("Music", ft.Icons.LIBRARY_MUSIC_OUTLINED)]
|
||||||
|
|
||||||
|
# When running as an AppImage, SaraTune can put itself in the app menu (like SaraSuite).
|
||||||
|
_AppImage = os.environ.get("APPIMAGE", "")
|
||||||
|
_MenuEntry = DataHome / "applications" / "SaraTune.desktop"
|
||||||
|
_MenuIcon = DataHome / "SaraTune" / "SaraTune.png"
|
||||||
|
MimeTypes = "audio/mpeg;audio/flac;audio/x-flac;audio/ogg;audio/x-vorbis+ogg;audio/opus;audio/x-opus+ogg;" \
|
||||||
|
"audio/wav;audio/x-wav;audio/mp4;audio/x-m4a;audio/aac;audio/x-aiff;audio/x-ms-wma;audio/x-matroska;" \
|
||||||
|
"audio/webm;"
|
||||||
|
|
||||||
|
# The Flet client's audio plugin is only in its "full" build, which on Linux
|
||||||
|
# needs the system's libmpv (Fedora: mpv-libs). Windows' full client brings
|
||||||
|
# everything itself.
|
||||||
|
def _HasMpv() -> bool:
|
||||||
|
"""Loads the exact library the client links against (like the client
|
||||||
|
will), so LD_LIBRARY_PATH and odd install paths count too."""
|
||||||
|
try:
|
||||||
|
ctypes.CDLL("libmpv.so.2")
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
NeedsMpv = sys.platform.startswith("linux") and not _HasMpv()
|
||||||
|
|
||||||
|
|
||||||
|
def _SetMenuEntry(Enabled: bool) -> None:
|
||||||
|
if not Enabled:
|
||||||
|
_MenuEntry.unlink(missing_ok=True)
|
||||||
|
_MenuIcon.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
_MenuIcon.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy(AssetsDir / "Icon.png", _MenuIcon)
|
||||||
|
_MenuEntry.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_MenuEntry.write_text(
|
||||||
|
"[Desktop Entry]\nType=Application\nName=SaraTune\n"
|
||||||
|
"Comment=Play audio files and your music library\n"
|
||||||
|
f"Exec={_AppImage} %F\nIcon={_MenuIcon}\nTerminal=false\n"
|
||||||
|
f"Categories=AudioVideo;Audio;Player;\nMimeType={MimeTypes}\nStartupNotify=true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _SetUpPage(Page: ft.Page, Appearance) -> None:
|
||||||
|
Page.title = AppName
|
||||||
|
Page.theme_mode = ft.ThemeMode.DARK
|
||||||
|
Page.theme = BuildTheme()
|
||||||
|
Page.dark_theme = BuildTheme()
|
||||||
|
Page.fonts = {BundledFontFamily: "fonts/Fixedsys_Excelsior_Regular.ttf"}
|
||||||
|
Page.window.title_bar_hidden = Appearance.HideWindowTitleBar
|
||||||
|
Page.padding = 0
|
||||||
|
Page.bgcolor = Palette.Base
|
||||||
|
if IsWindows:
|
||||||
|
Page.window.icon = str(AssetsDir / "Icon.ico") # taskbar/title bar (Linux: the desktop entry)
|
||||||
|
|
||||||
|
|
||||||
|
def MissingMpv(Page: ft.Page) -> None:
|
||||||
|
"""Shown instead of the player when libmpv isn't installed."""
|
||||||
|
ApplyAppearance(EffectiveSettings(LoadConfig()))
|
||||||
|
_SetUpPage(Page, EffectiveSettings(LoadConfig()))
|
||||||
|
Page.window.width, Page.window.height = 620, 360
|
||||||
|
Command = "sudo dnf install mpv-libs"
|
||||||
|
Page.add(ft.Container(ft.Column([
|
||||||
|
ft.Text(AppName, size=24, color=Palette.Mauve),
|
||||||
|
ft.Text("SaraTune needs libmpv to play audio.", size=16, color=Palette.Text),
|
||||||
|
ft.Text("Flet's audio player is part of its full desktop client, which links against the system's "
|
||||||
|
"libmpv. Install it, then start SaraTune again:", color=Palette.Overlay0),
|
||||||
|
ft.Container(ft.Text(Command, selectable=True, color=Palette.Green), bgcolor=Palette.Mantle, padding=10,
|
||||||
|
border_radius=4, border=ft.Border.all(1, Palette.Surface1)),
|
||||||
|
ft.Text("(On other distributions the package is usually called libmpv2 or mpv-libs.)", size=12,
|
||||||
|
color=Palette.Overlay0),
|
||||||
|
], spacing=12), padding=24))
|
||||||
|
|
||||||
|
|
||||||
|
def Main(Page: ft.Page) -> None:
|
||||||
|
Config = LoadConfig()
|
||||||
|
Appearance = EffectiveSettings(Config)
|
||||||
|
ApplyAppearance(Appearance) # before anything reads Palette.X
|
||||||
|
_SetUpPage(Page, Appearance)
|
||||||
|
Page.window.width = 1180
|
||||||
|
Page.window.height = 760
|
||||||
|
Page.window.min_width = 820
|
||||||
|
Page.window.min_height = 520
|
||||||
|
|
||||||
|
TheApp = App(Page, Config)
|
||||||
|
|
||||||
|
def SetVolume(Value: float) -> None:
|
||||||
|
Config.Volume = round(Value, 3)
|
||||||
|
for Each in Players.values():
|
||||||
|
Each.SetVolume(Value)
|
||||||
|
for Name, Mode in ModeViews.items():
|
||||||
|
if Mode is not ModeViews[State["Mode"]]:
|
||||||
|
Mode.Bar.SetVolume(Value)
|
||||||
|
|
||||||
|
Players = {Name: Player(Page, Config.Volume) for Name, _ in Modes}
|
||||||
|
ModeViews = {
|
||||||
|
"Audio": AudioMode(TheApp, Players["Audio"], SetVolume),
|
||||||
|
"Music": MusicMode(TheApp, Players["Music"], SetVolume),
|
||||||
|
}
|
||||||
|
# One sound at a time: starting one mode's player pauses the other's.
|
||||||
|
for Name, ThePlayer in Players.items():
|
||||||
|
Others = [Other for OtherName, Other in Players.items() if OtherName != Name]
|
||||||
|
ThePlayer.OnStarted = lambda Others=Others: [Page.run_task(Other.Pause) for Other in Others if Other.Playing]
|
||||||
|
|
||||||
|
Body = ft.Container(expand=True)
|
||||||
|
State = {"Mode": ""}
|
||||||
|
|
||||||
|
def SwitchMode(Name: str) -> None:
|
||||||
|
if Name not in ModeViews:
|
||||||
|
return
|
||||||
|
State["Mode"] = Name
|
||||||
|
for ModeName, Mode in ModeViews.items():
|
||||||
|
Mode.Bar.Shown = ModeName == Name
|
||||||
|
Switcher.selected = [Name]
|
||||||
|
Body.content = ModeViews[Name]
|
||||||
|
Page.update()
|
||||||
|
ModeViews[Name].Bar.Refresh()
|
||||||
|
|
||||||
|
def OpenInAudio(Paths: list[str]) -> None:
|
||||||
|
SwitchMode("Audio")
|
||||||
|
Page.run_task(ModeViews["Audio"].Open, Paths)
|
||||||
|
|
||||||
|
TheApp.SwitchMode = SwitchMode
|
||||||
|
TheApp.OpenInAudio = OpenInAudio
|
||||||
|
|
||||||
|
Switcher = ft.SegmentedButton(
|
||||||
|
segments=[ft.Segment(value=Name, label=Name, icon=Icon) for Name, Icon in Modes],
|
||||||
|
selected=["Music"],
|
||||||
|
show_selected_icon=False,
|
||||||
|
style=ft.ButtonStyle(
|
||||||
|
color={ft.ControlState.SELECTED: Palette.Crust, ft.ControlState.DEFAULT: Palette.Text},
|
||||||
|
bgcolor={ft.ControlState.SELECTED: Palette.Mauve, ft.ControlState.DEFAULT: Palette.Surface0},
|
||||||
|
side=ft.BorderSide(1, Palette.Mauve),
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=4),
|
||||||
|
),
|
||||||
|
on_change=lambda e: SwitchMode(e.control.selected[0]) if e.control.selected else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def ToggleMenuEntry(e) -> None:
|
||||||
|
_SetMenuEntry(not _MenuEntry.exists())
|
||||||
|
MenuButton.content = "Remove from app menu" if _MenuEntry.exists() else "Add to app menu"
|
||||||
|
MenuButton.update()
|
||||||
|
|
||||||
|
MenuButton = ft.OutlinedButton(
|
||||||
|
content="Remove from app menu" if _MenuEntry.exists() else "Add to app menu",
|
||||||
|
style=ChipButtonStyle(),
|
||||||
|
visible=bool(_AppImage),
|
||||||
|
on_click=ToggleMenuEntry,
|
||||||
|
)
|
||||||
|
|
||||||
|
def AfterSettings(FoldersChanged: bool) -> None:
|
||||||
|
if FoldersChanged:
|
||||||
|
ModeViews["Music"].StartScan()
|
||||||
|
|
||||||
|
def ClearCache() -> None:
|
||||||
|
Music = ModeViews["Music"]
|
||||||
|
Music.Library.ClearCache()
|
||||||
|
Music.ShowView("Songs")
|
||||||
|
Music.StartScan()
|
||||||
|
|
||||||
|
SettingsButton = ft.IconButton(
|
||||||
|
icon=ft.Icons.SETTINGS_OUTLINED,
|
||||||
|
icon_color=Palette.Mauve,
|
||||||
|
tooltip="Settings",
|
||||||
|
on_click=lambda e: ShowSettingsDialog(TheApp, AfterSettings, ClearCache),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def OnKey(e: ft.KeyboardEvent) -> None:
|
||||||
|
# Not while a dialog is open (its text fields take the keys), and
|
||||||
|
# only in Audio mode: Music mode has a search field.
|
||||||
|
if any(Dialog.open and not isinstance(Dialog, ft.SnackBar) for Dialog in Page._dialogs.controls):
|
||||||
|
return
|
||||||
|
if State["Mode"] == "Audio" and not (e.ctrl or e.alt or e.meta):
|
||||||
|
await ModeViews["Audio"].HandleKey(e)
|
||||||
|
|
||||||
|
Page.on_keyboard_event = OnKey
|
||||||
|
|
||||||
|
async def OnWindowEvent(e: ft.WindowEvent) -> None:
|
||||||
|
if e.type == ft.WindowEventType.CLOSE:
|
||||||
|
ModeViews["Music"].SaveSession()
|
||||||
|
Config.Save() # the volume
|
||||||
|
await Page.window.destroy()
|
||||||
|
|
||||||
|
Page.window.prevent_close = True
|
||||||
|
Page.window.on_event = OnWindowEvent
|
||||||
|
|
||||||
|
Page.add(ft.Column([
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Row([
|
||||||
|
ft.Text(AppName, size=24, color=Palette.Mauve),
|
||||||
|
ft.Container(width=16),
|
||||||
|
Switcher,
|
||||||
|
ft.Container(expand=True),
|
||||||
|
MenuButton,
|
||||||
|
SettingsButton,
|
||||||
|
]),
|
||||||
|
padding=ft.Padding.symmetric(horizontal=16, vertical=8),
|
||||||
|
bgcolor=Palette.Mantle,
|
||||||
|
border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1)),
|
||||||
|
),
|
||||||
|
Body,
|
||||||
|
], expand=True, spacing=0))
|
||||||
|
|
||||||
|
Files = [Argument for Argument in sys.argv[1:] if not Argument.startswith("-")]
|
||||||
|
if Files:
|
||||||
|
SwitchMode("Audio")
|
||||||
|
Page.run_task(ModeViews["Audio"].Open, Files)
|
||||||
|
else:
|
||||||
|
SwitchMode(Config.StartMode if Config.StartMode in ModeViews else "Music")
|
||||||
|
Page.run_task(ModeViews["Music"].Started)
|
||||||
|
Page.run_task(Ticker, [Mode.Bar for Mode in ModeViews.values()])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if NeedsMpv:
|
||||||
|
os.environ["FLET_DESKTOP_FLAVOR"] = "light"
|
||||||
|
os.environ.pop("FLET_VIEW_PATH", None) # Launch.sh's client copy is the full one
|
||||||
|
ft.run(MissingMpv, assets_dir=str(AssetsDir))
|
||||||
|
else:
|
||||||
|
os.environ.setdefault("FLET_DESKTOP_FLAVOR", "full")
|
||||||
|
ft.run(Main, assets_dir=str(AssetsDir))
|
||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
"""Reading audio files with mutagen: the tags Music mode sorts by, the full
|
||||||
|
details Audio mode shows, and cover art (embedded, or a cover image next to
|
||||||
|
the file). Never writes to the files."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import mutagen
|
||||||
|
from mutagen.flac import Picture
|
||||||
|
|
||||||
|
# Images next to a file that count as its cover, best first.
|
||||||
|
CoverNames = ["cover", "folder", "front", "album", "albumart", "albumartsmall"]
|
||||||
|
CoverExtensions = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Track:
|
||||||
|
Path: str
|
||||||
|
Title: str = ""
|
||||||
|
Artist: str = ""
|
||||||
|
Album: str = ""
|
||||||
|
AlbumArtist: str = ""
|
||||||
|
Genre: str = ""
|
||||||
|
Year: str = ""
|
||||||
|
TrackNumber: int = 0
|
||||||
|
DiscNumber: int = 0
|
||||||
|
Duration: int = 0 # milliseconds
|
||||||
|
Modified: float = 0 # the file's mtime when it was read, so a rescan can skip it
|
||||||
|
|
||||||
|
@property
|
||||||
|
def DisplayTitle(self) -> str:
|
||||||
|
return self.Title or Path(self.Path).stem
|
||||||
|
|
||||||
|
@property
|
||||||
|
def DisplayArtist(self) -> str:
|
||||||
|
return self.Artist or self.AlbumArtist or "Unknown artist"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def DisplayAlbum(self) -> str:
|
||||||
|
return self.Album or "Unknown album"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def AlbumKey(self) -> tuple[str, str]:
|
||||||
|
"""Albums are told apart by album artist too, so two "Greatest Hits" stay two."""
|
||||||
|
return (self.AlbumArtist or self.Artist or "").lower(), (self.Album or "").lower()
|
||||||
|
|
||||||
|
def ToDict(self) -> dict:
|
||||||
|
return {Key: Value for Key, Value in asdict(self).items() if Value not in ("", 0) or Key == "Path"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def FromDict(cls, Data: dict) -> "Track":
|
||||||
|
Known = cls.__dataclass_fields__
|
||||||
|
return cls(**{Key: Value for Key, Value in Data.items() if Key in Known})
|
||||||
|
|
||||||
|
|
||||||
|
def _First(Tags, *Keys: str) -> str:
|
||||||
|
for Key in Keys:
|
||||||
|
try:
|
||||||
|
Value = Tags.get(Key)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
continue
|
||||||
|
if Value:
|
||||||
|
if isinstance(Value, list):
|
||||||
|
Value = Value[0]
|
||||||
|
return str(Value).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _Number(Text: str) -> int:
|
||||||
|
""""3/12" -> 3."""
|
||||||
|
try:
|
||||||
|
return int(Text.split("/")[0].strip() or 0)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _Open(FilePath: str, Easy: bool):
|
||||||
|
try:
|
||||||
|
return mutagen.File(FilePath, easy=Easy)
|
||||||
|
except Exception: # mutagen raises many kinds of errors for broken files
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ReadTrack(FilePath: str) -> Track:
|
||||||
|
"""The tags Music mode uses. A file mutagen can't read still becomes a
|
||||||
|
Track (titled after its file name), so it can be played."""
|
||||||
|
Result = Track(Path=FilePath)
|
||||||
|
try:
|
||||||
|
Result.Modified = Path(FilePath).stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
File = _Open(FilePath, Easy=True)
|
||||||
|
if File is None:
|
||||||
|
return Result
|
||||||
|
if File.info is not None and getattr(File.info, "length", None):
|
||||||
|
Result.Duration = int(File.info.length * 1000)
|
||||||
|
Tags = File.tags or {}
|
||||||
|
Result.Title = _First(Tags, "title")
|
||||||
|
Result.Artist = _First(Tags, "artist")
|
||||||
|
Result.Album = _First(Tags, "album")
|
||||||
|
Result.AlbumArtist = _First(Tags, "albumartist", "album artist", "performer")
|
||||||
|
Result.Genre = _First(Tags, "genre")
|
||||||
|
Result.Year = _First(Tags, "date", "year", "originaldate")[:4]
|
||||||
|
Result.TrackNumber = _Number(_First(Tags, "tracknumber", "track"))
|
||||||
|
Result.DiscNumber = _Number(_First(Tags, "discnumber", "disc"))
|
||||||
|
return Result
|
||||||
|
|
||||||
|
|
||||||
|
def ReadDetails(FilePath: str) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
|
||||||
|
"""(technical info, every tag) as label/value rows for Audio mode."""
|
||||||
|
PathValue = Path(FilePath)
|
||||||
|
Info: list[tuple[str, str]] = []
|
||||||
|
try:
|
||||||
|
Size = PathValue.stat().st_size
|
||||||
|
Info.append(("Size", _FormatSize(Size)))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
Info.append(("Format", PathValue.suffix.lstrip(".").upper() or "?"))
|
||||||
|
Tags: list[tuple[str, str]] = []
|
||||||
|
File = _Open(FilePath, Easy=False)
|
||||||
|
if File is None:
|
||||||
|
Info.append(("Note", "mutagen couldn't read this file's details"))
|
||||||
|
return Info, Tags
|
||||||
|
|
||||||
|
Stream = File.info
|
||||||
|
Codec = getattr(Stream, "codec_description", "") or getattr(Stream, "codec", "")
|
||||||
|
if Codec:
|
||||||
|
Info.append(("Codec", str(Codec)))
|
||||||
|
if getattr(Stream, "length", None):
|
||||||
|
from Core import FormatTime
|
||||||
|
Info.append(("Length", FormatTime(Stream.length * 1000)))
|
||||||
|
if getattr(Stream, "bitrate", None):
|
||||||
|
Mode = getattr(Stream, "bitrate_mode", None)
|
||||||
|
ModeText = {1: " CBR", 2: " VBR", 3: " ABR"}.get(int(Mode), "") if Mode is not None else ""
|
||||||
|
Info.append(("Bitrate", f"{round(Stream.bitrate / 1000)} kbps{ModeText}"))
|
||||||
|
if getattr(Stream, "sample_rate", None):
|
||||||
|
Info.append(("Sample rate", f"{Stream.sample_rate / 1000:g} kHz"))
|
||||||
|
if getattr(Stream, "bits_per_sample", None):
|
||||||
|
Info.append(("Bit depth", f"{Stream.bits_per_sample} bit"))
|
||||||
|
if getattr(Stream, "channels", None):
|
||||||
|
Info.append(("Channels", {1: "Mono", 2: "Stereo"}.get(Stream.channels, str(Stream.channels))))
|
||||||
|
|
||||||
|
for Key, Value in _AllTags(File):
|
||||||
|
Tags.append((Key, Value))
|
||||||
|
return Info, Tags
|
||||||
|
|
||||||
|
|
||||||
|
# Readable names for ID3 frames and MP4 atoms; Vorbis comments (FLAC, Ogg,
|
||||||
|
# Opus) already have names, and only get title-cased.
|
||||||
|
TagNames = {
|
||||||
|
"TIT2": "Title", "TPE1": "Artist", "TPE2": "Album artist", "TALB": "Album", "TRCK": "Track", "TPOS": "Disc",
|
||||||
|
"TDRC": "Date", "TYER": "Year", "TCON": "Genre", "TCOM": "Composer", "TBPM": "BPM", "TSSE": "Encoder",
|
||||||
|
"TENC": "Encoded by", "TCOP": "Copyright", "TPUB": "Publisher", "TLAN": "Language", "COMM": "Comment",
|
||||||
|
"USLT": "Lyrics", "TIT1": "Grouping", "TIT3": "Subtitle", "TKEY": "Key", "TMOO": "Mood", "TSRC": "ISRC",
|
||||||
|
"TDOR": "Original date", "TOPE": "Original artist", "TEXT": "Lyricist", "TLEN": "Length (ms)",
|
||||||
|
"\xa9nam": "Title", "\xa9ART": "Artist", "aART": "Album artist", "\xa9alb": "Album", "trkn": "Track",
|
||||||
|
"disk": "Disc", "\xa9day": "Date", "\xa9gen": "Genre", "\xa9wrt": "Composer", "\xa9too": "Encoder",
|
||||||
|
"\xa9cmt": "Comment", "\xa9lyr": "Lyrics", "cprt": "Copyright", "tmpo": "BPM", "\xa9grp": "Grouping",
|
||||||
|
"tracknumber": "Track", "discnumber": "Disc", "albumartist": "Album artist", "tracktotal": "Total tracks",
|
||||||
|
"totaltracks": "Total tracks", "disctotal": "Total discs", "totaldiscs": "Total discs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _TagName(Key: str) -> str:
|
||||||
|
if Key.startswith("TXXX:"): # ID3 user text frames read "TXXX:Name"
|
||||||
|
return Key[5:]
|
||||||
|
if Key.startswith("----:"): # MP4 freeform atoms read "----:com.apple.iTunes:Name"
|
||||||
|
return Key.rsplit(":", 1)[-1]
|
||||||
|
Base = Key.split(":")[0] # COMM frames read "COMM::eng"
|
||||||
|
if Base in TagNames:
|
||||||
|
return TagNames[Base]
|
||||||
|
return Base.replace("_", " ").capitalize() if Base.islower() else Base
|
||||||
|
|
||||||
|
|
||||||
|
def _AllTags(File) -> list[tuple[str, str]]:
|
||||||
|
"""Readable key/value pairs from any tag format, without the binary ones."""
|
||||||
|
Rows: list[tuple[str, str]] = []
|
||||||
|
if not File.tags:
|
||||||
|
return Rows
|
||||||
|
try:
|
||||||
|
Items = list(File.tags.items())
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return Rows
|
||||||
|
for Key, Value in Items:
|
||||||
|
KeyText = str(Key)
|
||||||
|
if KeyText.startswith(("APIC", "covr", "metadata_block_picture", "PRIV", "GEOB", "MCDI")):
|
||||||
|
continue
|
||||||
|
if hasattr(Value, "text"): # ID3 frames
|
||||||
|
Value = Value.text
|
||||||
|
if isinstance(Value, list):
|
||||||
|
# MP4 track/disc numbers are (number, total) pairs.
|
||||||
|
Value = ", ".join("/".join(str(N) for N in Part if N) if isinstance(Part, tuple) else
|
||||||
|
Part.decode("utf-8", "replace") if isinstance(Part, bytes) else str(Part) for Part in Value)
|
||||||
|
Text = str(Value)
|
||||||
|
if not Text or len(Text) > 400 or "\x00" in Text:
|
||||||
|
continue
|
||||||
|
Rows.append((_TagName(KeyText), Text))
|
||||||
|
return Rows
|
||||||
|
|
||||||
|
|
||||||
|
def ReadCover(FilePath: str) -> Optional[bytes]:
|
||||||
|
"""The embedded front cover (or the first picture), else an image file
|
||||||
|
named like cover.jpg/folder.png in the same folder."""
|
||||||
|
File = _Open(FilePath, Easy=False)
|
||||||
|
Embedded = _EmbeddedCover(File) if File is not None else None
|
||||||
|
if Embedded:
|
||||||
|
return Embedded
|
||||||
|
return FolderCover(Path(FilePath).parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _EmbeddedCover(File) -> Optional[bytes]:
|
||||||
|
try:
|
||||||
|
Pictures = getattr(File, "pictures", None) # FLAC
|
||||||
|
if Pictures:
|
||||||
|
Front = [P for P in Pictures if P.type == 3]
|
||||||
|
return (Front or Pictures)[0].data
|
||||||
|
Tags = File.tags
|
||||||
|
if Tags is None:
|
||||||
|
return None
|
||||||
|
if hasattr(Tags, "getall"): # ID3
|
||||||
|
Frames = Tags.getall("APIC")
|
||||||
|
if Frames:
|
||||||
|
Front = [F for F in Frames if F.type == 3]
|
||||||
|
return (Front or Frames)[0].data
|
||||||
|
if "covr" in Tags: # MP4
|
||||||
|
return bytes(Tags["covr"][0])
|
||||||
|
if "metadata_block_picture" in Tags: # Ogg Vorbis/Opus
|
||||||
|
for Encoded in Tags["metadata_block_picture"]:
|
||||||
|
return Picture(base64.b64decode(Encoded)).data
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def FolderCover(Folder: Path) -> Optional[bytes]:
|
||||||
|
try:
|
||||||
|
Files = {Child.name.lower(): Child for Child in Folder.iterdir() if Child.is_file()}
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
for Name in CoverNames:
|
||||||
|
for Extension in CoverExtensions:
|
||||||
|
Found = Files.get(Name + Extension)
|
||||||
|
if Found:
|
||||||
|
try:
|
||||||
|
return Found.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _FormatSize(Size: int) -> str:
|
||||||
|
Value = float(Size)
|
||||||
|
for Unit in ("B", "KB", "MB", "GB"):
|
||||||
|
if Value < 1024 or Unit == "GB":
|
||||||
|
return f"{Value:.0f} {Unit}" if Unit == "B" else f"{Value:.1f} {Unit}"
|
||||||
|
Value /= 1024
|
||||||
|
return f"{Size} B"
|
||||||
+811
@@ -0,0 +1,811 @@
|
|||||||
|
"""Music mode: a library of the music folders (songs, albums, artists), a
|
||||||
|
queue with shuffle and repeat, and saved playlists. The queue and position
|
||||||
|
are kept between runs (Session.toml), and the library's tags are cached."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from Core import ChipButton, DisplayPath, FormatTime, Home, IsAudioFile, Note, Palette
|
||||||
|
from Covers import CoverFor
|
||||||
|
from Library import Library, Matches
|
||||||
|
from Metadata import Track
|
||||||
|
from Player import Player
|
||||||
|
from Playlists import ExportM3U, LoadPlaylists, NameTaken, Playlist, ReadM3U
|
||||||
|
from PlayQueue import RestartAfterMs, Session, LoadSession
|
||||||
|
from Transport import CoverImage, Transport
|
||||||
|
from Widgets import App, EmptyState
|
||||||
|
|
||||||
|
SidebarWidth = 220
|
||||||
|
RowHeight = 44
|
||||||
|
PageSize = 400 # rows shown at once; "Show more" adds another page
|
||||||
|
AlbumCardWidth = 168
|
||||||
|
AlbumCoverSize = 150
|
||||||
|
SaveEverySeconds = 15
|
||||||
|
|
||||||
|
LibraryViews = [
|
||||||
|
("Songs", ft.Icons.MUSIC_NOTE_OUTLINED),
|
||||||
|
("Albums", ft.Icons.ALBUM_OUTLINED),
|
||||||
|
("Artists", ft.Icons.PERSON_OUTLINE),
|
||||||
|
("Queue", ft.Icons.QUEUE_MUSIC),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class MusicMode(ft.Column):
|
||||||
|
def __init__(self, TheApp: App, ThePlayer: Player, OnVolume: Callable[[float], None]):
|
||||||
|
super().__init__(expand=True, spacing=0)
|
||||||
|
self.TheApp = TheApp
|
||||||
|
self.Config = TheApp.Config
|
||||||
|
self.ThePlayer = ThePlayer
|
||||||
|
self.Library = Library()
|
||||||
|
self.Library.LoadCache()
|
||||||
|
self.Session = LoadSession() if self.Config.RememberSession else Session()
|
||||||
|
self.Queue = self.Session.Queue
|
||||||
|
self.Playlists: list[Playlist] = LoadPlaylists()
|
||||||
|
self.Scanning = False
|
||||||
|
# What the open view shows, for Play all / clicking a row.
|
||||||
|
self.View = ""
|
||||||
|
self.ViewArgument: object = None
|
||||||
|
self.Shown: list[Track] = []
|
||||||
|
self.RowsByPath: dict[str, list[ft.Container]] = {}
|
||||||
|
|
||||||
|
ThePlayer.OnEnded = lambda: TheApp.Page.run_task(self._Ended)
|
||||||
|
ThePlayer.OnChanged = self._PlayerChanged
|
||||||
|
ThePlayer.OnError = lambda Message: TheApp.Notify(
|
||||||
|
f"Can't play {Path(ThePlayer.Source or '').name}: {Message}", "error")
|
||||||
|
|
||||||
|
# --- Sidebar ---------------------------------------------------------
|
||||||
|
self.NavTiles: dict[str, ft.Container] = {}
|
||||||
|
self.PlaylistColumn = ft.Column(spacing=0)
|
||||||
|
self.ScanNote = Note("")
|
||||||
|
self.SidebarList = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0)
|
||||||
|
Sidebar = ft.Container(
|
||||||
|
ft.Column([
|
||||||
|
self.SidebarList,
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
ft.Row([
|
||||||
|
ft.Container(self.ScanNote, expand=True),
|
||||||
|
ft.IconButton(ft.Icons.REFRESH, icon_color=Palette.Mauve, icon_size=18,
|
||||||
|
tooltip="Look for new and changed files", on_click=lambda e: self.StartScan()),
|
||||||
|
]),
|
||||||
|
], spacing=4, expand=True),
|
||||||
|
width=SidebarWidth, bgcolor=Palette.Mantle, padding=ft.Padding.only(left=6, right=6, top=6),
|
||||||
|
border=ft.Border.only(right=ft.BorderSide(1, Palette.Surface1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Main area -------------------------------------------------------
|
||||||
|
self.Heading = ft.Text("", size=22, color=Palette.Mauve, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
|
||||||
|
self.SubHeading = Note("")
|
||||||
|
self.Search = ft.TextField(
|
||||||
|
hint_text="Search", prefix_icon=ft.Icons.SEARCH, dense=True, width=260,
|
||||||
|
on_change=lambda e: self._Rebuild(), on_submit=lambda e: self._Rebuild(),
|
||||||
|
)
|
||||||
|
self.Actions = ft.Row(spacing=6)
|
||||||
|
Header = ft.Container(
|
||||||
|
ft.Row([
|
||||||
|
ft.Column([self.Heading, self.SubHeading], spacing=0, expand=True, tight=True),
|
||||||
|
self.Actions,
|
||||||
|
self.Search,
|
||||||
|
], vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||||
|
padding=ft.Padding.only(left=20, right=16, top=12, bottom=8),
|
||||||
|
)
|
||||||
|
self.Body = ft.Container(expand=True, padding=ft.Padding.symmetric(horizontal=12))
|
||||||
|
|
||||||
|
# --- Play bar --------------------------------------------------------
|
||||||
|
self.ShuffleButton = ft.IconButton(ft.Icons.SHUFFLE, tooltip="Shuffle", on_click=self._ToggleShuffle)
|
||||||
|
self.RepeatButton = ft.IconButton(ft.Icons.REPEAT, tooltip="Repeat", on_click=self._CycleRepeat)
|
||||||
|
self._ShowModes()
|
||||||
|
self.Bar = Transport(ThePlayer, self.Previous, self.Next, OnVolume,
|
||||||
|
Extras=[self.ShuffleButton, self.RepeatButton],
|
||||||
|
OnInfoClick=lambda e: self.ShowView("Queue"))
|
||||||
|
|
||||||
|
self.controls = [
|
||||||
|
ft.Row([
|
||||||
|
Sidebar,
|
||||||
|
ft.Column([Header, self.Body], expand=True, spacing=0),
|
||||||
|
], expand=True, spacing=0, vertical_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||||
|
self.Bar,
|
||||||
|
]
|
||||||
|
self._BuildSidebar()
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# --- Start / stop -------------------------------------------------------
|
||||||
|
|
||||||
|
async def Started(self) -> None:
|
||||||
|
"""Once the page is up: the saved view and track (paused), the scan, autosave."""
|
||||||
|
View = self.Session.View
|
||||||
|
self.ShowView(View if View in dict(LibraryViews) else "Songs")
|
||||||
|
if self.Queue.Current:
|
||||||
|
await self._LoadCurrent(Play=False, Start=self.Session.Position)
|
||||||
|
if self.Config.ScanOnStart or not self.Library.Tracks:
|
||||||
|
self.StartScan()
|
||||||
|
self.TheApp.Page.run_task(self._AutoSave)
|
||||||
|
|
||||||
|
async def _AutoSave(self) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(SaveEverySeconds)
|
||||||
|
if self.ThePlayer.Playing:
|
||||||
|
self.SaveSession()
|
||||||
|
|
||||||
|
def SaveSession(self) -> None:
|
||||||
|
if not self.Config.RememberSession:
|
||||||
|
return
|
||||||
|
self.Session.Position = self.ThePlayer.Position if self.ThePlayer.Source else 0
|
||||||
|
self.Session.View = self.View if self.View in dict(LibraryViews) else "Songs"
|
||||||
|
try:
|
||||||
|
self.Session.Save()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Scanning -----------------------------------------------------------
|
||||||
|
|
||||||
|
def StartScan(self) -> None:
|
||||||
|
if self.Scanning:
|
||||||
|
return
|
||||||
|
self.TheApp.Page.run_task(self._Scan)
|
||||||
|
|
||||||
|
async def _Scan(self) -> None:
|
||||||
|
self.Scanning = True
|
||||||
|
self.ScanNote.value = "Scanning..."
|
||||||
|
self._Show(self.ScanNote)
|
||||||
|
Folders = [Folder for Folder in self.Config.MusicFolders if Folder.strip()]
|
||||||
|
Loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def Progress(Count: int) -> None:
|
||||||
|
def Show() -> None:
|
||||||
|
self.ScanNote.value = f"Scanning... {Count} files"
|
||||||
|
self._Show(self.ScanNote)
|
||||||
|
Loop.call_soon_threadsafe(Show)
|
||||||
|
|
||||||
|
try:
|
||||||
|
Changed, Removed = await asyncio.to_thread(self.Library.Scan, Folders, Progress)
|
||||||
|
except OSError as Error:
|
||||||
|
self.TheApp.Notify(f"Scanning failed: {Error}", "error")
|
||||||
|
Changed = Removed = 0
|
||||||
|
self.Scanning = False
|
||||||
|
self._ShowCount()
|
||||||
|
if Changed or Removed:
|
||||||
|
self._Rebuild()
|
||||||
|
if not Folders or not any(Path(Folder).expanduser().is_dir() for Folder in Folders):
|
||||||
|
self.TheApp.Notify("None of the music folders exist. Pick them in Settings > Music.", "warning")
|
||||||
|
|
||||||
|
def _ShowCount(self) -> None:
|
||||||
|
Count = len(self.Library.Tracks)
|
||||||
|
self.ScanNote.value = f"{Count} song{'s' if Count != 1 else ''}"
|
||||||
|
self.ScanNote.tooltip = "\n".join(DisplayPath(str(Path(F).expanduser())) for F in self.Config.MusicFolders)
|
||||||
|
self._Show(self.ScanNote)
|
||||||
|
|
||||||
|
# --- Sidebar ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _NavTile(self, Key: str, Label: str, Icon, OnClick) -> ft.Container:
|
||||||
|
Tile = ft.Container(
|
||||||
|
ft.Row([ft.Icon(Icon, size=18, color=Palette.Mauve),
|
||||||
|
ft.Text(Label, size=14, color=Palette.Text, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, expand=True)],
|
||||||
|
spacing=10),
|
||||||
|
padding=ft.Padding.symmetric(horizontal=10, vertical=8), border_radius=4, ink=True, on_click=OnClick,
|
||||||
|
)
|
||||||
|
self.NavTiles[Key] = Tile
|
||||||
|
return Tile
|
||||||
|
|
||||||
|
def _BuildSidebar(self) -> None:
|
||||||
|
self.NavTiles = {}
|
||||||
|
Items: list[ft.Control] = [ft.Container(Note("Library"), padding=ft.Padding.only(left=10, top=4, bottom=4))]
|
||||||
|
for Name, Icon in LibraryViews:
|
||||||
|
Items.append(self._NavTile(Name, Name, Icon, lambda e, N=Name: self.ShowView(N)))
|
||||||
|
Items.append(ft.Container(
|
||||||
|
ft.Row([
|
||||||
|
Note("Playlists"),
|
||||||
|
ft.Container(expand=True),
|
||||||
|
ft.IconButton(ft.Icons.FILE_OPEN_OUTLINED, icon_size=16, icon_color=Palette.Mauve,
|
||||||
|
tooltip="Import an .m3u playlist", on_click=self._ImportM3U),
|
||||||
|
ft.IconButton(ft.Icons.ADD, icon_size=18, icon_color=Palette.Mauve, tooltip="New playlist",
|
||||||
|
on_click=lambda e: self._NewPlaylist()),
|
||||||
|
], spacing=0),
|
||||||
|
padding=ft.Padding.only(left=10, top=12),
|
||||||
|
))
|
||||||
|
for Item in self.Playlists:
|
||||||
|
Items.append(self._NavTile(f"Playlist:{Item.Name}", Item.Name, ft.Icons.PLAYLIST_PLAY,
|
||||||
|
lambda e, N=Item.Name: self.ShowView("Playlist", N)))
|
||||||
|
if not self.Playlists:
|
||||||
|
Items.append(ft.Container(Note("None yet. Make one with +, or from the queue."),
|
||||||
|
padding=ft.Padding.only(left=10, right=6)))
|
||||||
|
self.SidebarList.controls = Items
|
||||||
|
self._MarkNav()
|
||||||
|
|
||||||
|
def _MarkNav(self) -> None:
|
||||||
|
Selected = f"Playlist:{self.ViewArgument}" if self.View == "Playlist" else self.View
|
||||||
|
if self.View in ("Album", "Artist"):
|
||||||
|
Selected = self.View + "s"
|
||||||
|
for Key, Tile in self.NavTiles.items():
|
||||||
|
Tile.bgcolor = Palette.Surface0 if Key == Selected else None
|
||||||
|
|
||||||
|
# --- Views --------------------------------------------------------------
|
||||||
|
|
||||||
|
def ShowView(self, View: str, Argument: object = None, KeepSearch: bool = False) -> None:
|
||||||
|
if not KeepSearch:
|
||||||
|
self.Search.value = ""
|
||||||
|
self.View, self.ViewArgument = View, Argument
|
||||||
|
self._MarkNav()
|
||||||
|
self._Rebuild()
|
||||||
|
|
||||||
|
def _Rebuild(self) -> None:
|
||||||
|
Builders = {
|
||||||
|
"Songs": self._SongsView, "Albums": self._AlbumsView, "Artists": self._ArtistsView,
|
||||||
|
"Queue": self._QueueView, "Playlist": self._PlaylistView, "Album": self._AlbumView,
|
||||||
|
"Artist": self._ArtistView,
|
||||||
|
}
|
||||||
|
self.Actions.controls = []
|
||||||
|
self.Search.visible = self.View in ("Songs", "Albums", "Artists")
|
||||||
|
Builders.get(self.View, self._SongsView)()
|
||||||
|
self._Show(self)
|
||||||
|
|
||||||
|
def _SetHeading(self, Title: str, Sub: str) -> None:
|
||||||
|
self.Heading.value = Title
|
||||||
|
self.SubHeading.value = Sub
|
||||||
|
|
||||||
|
def _PlayButtons(self, Tracks: list[Track]) -> list[ft.Control]:
|
||||||
|
Paths = [Item.Path for Item in Tracks]
|
||||||
|
return [
|
||||||
|
ChipButton("Play", lambda e: self.TheApp.Page.run_task(self.PlayList, Paths, 0), ft.Icons.PLAY_ARROW,
|
||||||
|
Disabled=not Paths),
|
||||||
|
ChipButton("Shuffle", lambda e: self.TheApp.Page.run_task(self.PlayList, Paths, -1), ft.Icons.SHUFFLE,
|
||||||
|
Disabled=not Paths),
|
||||||
|
ft.PopupMenuButton(icon=ft.Icons.MORE_HORIZ, icon_color=Palette.Mauve, items=[
|
||||||
|
ft.PopupMenuItem(content="Add all to queue", on_click=lambda e: self._Enqueue(Paths)),
|
||||||
|
ft.PopupMenuItem(content="Add all to a playlist...", on_click=lambda e: self._AddToPlaylist(Paths)),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _Filtered(self, Tracks: list[Track]) -> list[Track]:
|
||||||
|
Query = (self.Search.value or "").strip()
|
||||||
|
return [Item for Item in Tracks if Matches(Item, Query)] if Query else Tracks
|
||||||
|
|
||||||
|
def _SongsView(self) -> None:
|
||||||
|
Tracks = self._Filtered(self.Library.Songs())
|
||||||
|
self._SetHeading("Songs", f"{len(Tracks)} songs" + (f" matching \"{self.Search.value.strip()}\""
|
||||||
|
if (self.Search.value or "").strip() else ""))
|
||||||
|
self.Actions.controls = self._PlayButtons(Tracks)
|
||||||
|
if not self.Library.Tracks:
|
||||||
|
self.Body.content = EmptyState(
|
||||||
|
"No music yet" if not self.Scanning else "Scanning your music...",
|
||||||
|
"SaraTune lists the audio files in " + ", ".join(DisplayPath(F) for F in self.Config.MusicFolders)
|
||||||
|
+ ". Change the folders in Settings > Music.")
|
||||||
|
return
|
||||||
|
self.Body.content = self._TrackList(Tracks)
|
||||||
|
|
||||||
|
def _AlbumsView(self) -> None:
|
||||||
|
Query = (self.Search.value or "").strip()
|
||||||
|
Albums = [Album for Album in self.Library.Albums() if not Query or any(Matches(Item, Query) for Item in Album)]
|
||||||
|
self._SetHeading("Albums", f"{len(Albums)} albums")
|
||||||
|
if not Albums:
|
||||||
|
self.Body.content = EmptyState("No albums", "Albums come from the files' album tags.", ft.Icons.ALBUM_OUTLINED)
|
||||||
|
return
|
||||||
|
Cards: list[ft.Control] = []
|
||||||
|
Pending: list[tuple[str, ft.Container]] = []
|
||||||
|
for Album in Albums:
|
||||||
|
First = Album[0]
|
||||||
|
CoverBox = ft.Container(CoverImage(None, AlbumCoverSize, 6))
|
||||||
|
Pending.append((First.Path, CoverBox))
|
||||||
|
Cards.append(ft.Container(
|
||||||
|
ft.Column([
|
||||||
|
CoverBox,
|
||||||
|
ft.Text(First.DisplayAlbum, size=13, color=Palette.Text, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
ft.Text(First.AlbumArtist or First.DisplayArtist, size=12, color=Palette.Overlay0, max_lines=1,
|
||||||
|
overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
], spacing=4, tight=True),
|
||||||
|
padding=8, border_radius=6, ink=True, tooltip=f"{First.DisplayAlbum}\n{len(Album)} songs",
|
||||||
|
on_click=lambda e, Key=First.AlbumKey: self.ShowView("Album", Key),
|
||||||
|
))
|
||||||
|
self.Body.content = ft.GridView(Cards, max_extent=AlbumCardWidth, child_aspect_ratio=0.78, spacing=4,
|
||||||
|
run_spacing=4, expand=True)
|
||||||
|
self.TheApp.Page.run_task(self._LoadCovers, Pending, self.Body.content)
|
||||||
|
|
||||||
|
async def _LoadCovers(self, Pending: list[tuple[str, ft.Container]], Grid: ft.Control) -> None:
|
||||||
|
for Index, (FilePath, Box) in enumerate(Pending):
|
||||||
|
if self.Body.content is not Grid:
|
||||||
|
return # left the albums view
|
||||||
|
Data = await asyncio.to_thread(CoverFor, FilePath, AlbumCoverSize * 2)
|
||||||
|
if Data:
|
||||||
|
Box.content = CoverImage(Data, AlbumCoverSize, 6)
|
||||||
|
if Index % 12 == 11 or Index == len(Pending) - 1:
|
||||||
|
self._Show(Grid)
|
||||||
|
|
||||||
|
def _AlbumTracks(self, Key) -> list[Track]:
|
||||||
|
for Album in self.Library.Albums():
|
||||||
|
if Album[0].AlbumKey == Key:
|
||||||
|
return Album
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _AlbumView(self) -> None:
|
||||||
|
Tracks = self._AlbumTracks(self.ViewArgument)
|
||||||
|
if not Tracks:
|
||||||
|
self.ShowView("Albums")
|
||||||
|
return
|
||||||
|
First = Tracks[0]
|
||||||
|
Length = sum(Item.Duration for Item in Tracks)
|
||||||
|
self._SetHeading(First.DisplayAlbum, " - ".join(Part for Part in (
|
||||||
|
First.AlbumArtist or First.DisplayArtist, First.Year, f"{len(Tracks)} songs", FormatTime(Length)) if Part))
|
||||||
|
self.Actions.controls = [ChipButton("Albums", lambda e: self.ShowView("Albums"), ft.Icons.ARROW_BACK)] + \
|
||||||
|
self._PlayButtons(Tracks)
|
||||||
|
self.Body.content = self._TrackList(Tracks, Numbered=True)
|
||||||
|
|
||||||
|
def _ArtistsView(self) -> None:
|
||||||
|
Query = (self.Search.value or "").strip().lower()
|
||||||
|
Artists = [(Name, Tracks) for Name, Tracks in self.Library.Artists() if not Query or Query in Name.lower()]
|
||||||
|
self._SetHeading("Artists", f"{len(Artists)} artists")
|
||||||
|
if not Artists:
|
||||||
|
self.Body.content = EmptyState("No artists", "", ft.Icons.PERSON_OUTLINE)
|
||||||
|
return
|
||||||
|
Rows = []
|
||||||
|
for Name, Tracks in Artists:
|
||||||
|
Albums = len({Item.AlbumKey for Item in Tracks if Item.Album})
|
||||||
|
Rows.append(ft.Container(
|
||||||
|
ft.Row([
|
||||||
|
ft.Icon(ft.Icons.PERSON, color=Palette.Mauve, size=18),
|
||||||
|
ft.Text(Name, size=14, color=Palette.Text, expand=True, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
Note(f"{Albums} album{'s' if Albums != 1 else ''}, {len(Tracks)} song{'s' if len(Tracks) != 1 else ''}"),
|
||||||
|
], spacing=10),
|
||||||
|
padding=ft.Padding.symmetric(horizontal=10), height=RowHeight, border_radius=4, ink=True,
|
||||||
|
on_click=lambda e, N=Name: self.ShowView("Artist", N),
|
||||||
|
))
|
||||||
|
self.Body.content = ft.ListView(Rows, item_extent=RowHeight, expand=True)
|
||||||
|
|
||||||
|
def _ArtistView(self) -> None:
|
||||||
|
Tracks = next((T for Name, T in self.Library.Artists() if Name == self.ViewArgument), [])
|
||||||
|
self._SetHeading(str(self.ViewArgument), f"{len(Tracks)} songs")
|
||||||
|
self.Actions.controls = [ChipButton("Artists", lambda e: self.ShowView("Artists"), ft.Icons.ARROW_BACK)] + \
|
||||||
|
self._PlayButtons(Tracks)
|
||||||
|
self.Body.content = self._TrackList(Tracks)
|
||||||
|
|
||||||
|
def _QueueView(self) -> None:
|
||||||
|
Tracks = [self.Library.Get(PathText) for PathText in self.Queue.Paths]
|
||||||
|
Left = sum(Item.Duration for Item in Tracks[max(self.Queue.Index, 0):])
|
||||||
|
self._SetHeading("Queue", f"{len(Tracks)} songs" + (f", {FormatTime(Left)} left" if Tracks else ""))
|
||||||
|
self.Actions.controls = [
|
||||||
|
ChipButton("Save as playlist", lambda e: self._QueueToPlaylist(), ft.Icons.PLAYLIST_ADD,
|
||||||
|
Disabled=not Tracks),
|
||||||
|
ChipButton("Clear", lambda e: self._ClearQueue(), ft.Icons.CLEAR_ALL, Accent=Palette.Red,
|
||||||
|
Disabled=not Tracks),
|
||||||
|
]
|
||||||
|
if not Tracks:
|
||||||
|
self.Body.content = EmptyState("The queue is empty", "Play something from the library or a playlist.",
|
||||||
|
ft.Icons.QUEUE_MUSIC)
|
||||||
|
return
|
||||||
|
self.Body.content = self._TrackList(Tracks, Context="Queue")
|
||||||
|
|
||||||
|
def _Playlist(self, Name) -> Optional[Playlist]:
|
||||||
|
return next((Item for Item in self.Playlists if Item.Name == Name), None)
|
||||||
|
|
||||||
|
def _PlaylistView(self) -> None:
|
||||||
|
Item = self._Playlist(self.ViewArgument)
|
||||||
|
if Item is None:
|
||||||
|
self.ShowView("Songs")
|
||||||
|
return
|
||||||
|
Tracks = [self.Library.Get(PathText) for PathText in Item.Tracks]
|
||||||
|
self._SetHeading(Item.Name, f"{len(Tracks)} songs, {FormatTime(sum(T.Duration for T in Tracks))}")
|
||||||
|
self.Actions.controls = self._PlayButtons(Tracks)[:2] + [
|
||||||
|
ft.PopupMenuButton(icon=ft.Icons.MORE_HORIZ, icon_color=Palette.Mauve, items=[
|
||||||
|
ft.PopupMenuItem(content="Add files...", icon=ft.Icons.ADD, on_click=self._AddFilesToPlaylist),
|
||||||
|
ft.PopupMenuItem(content="Add all to queue", icon=ft.Icons.QUEUE,
|
||||||
|
on_click=lambda e: self._Enqueue(list(Item.Tracks))),
|
||||||
|
ft.PopupMenuItem(content="Rename...", icon=ft.Icons.EDIT_OUTLINED, on_click=lambda e: self._RenamePlaylist(Item)),
|
||||||
|
ft.PopupMenuItem(content="Export as .m3u...", icon=ft.Icons.SAVE_ALT, on_click=lambda e: self.TheApp.Page.run_task(self._ExportM3U, Item)),
|
||||||
|
ft.PopupMenuItem(content="Delete", icon=ft.Icons.DELETE_OUTLINE, on_click=lambda e: self._DeletePlaylist(Item)),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
if not Tracks:
|
||||||
|
self.Body.content = EmptyState("This playlist is empty",
|
||||||
|
"Add songs with the ... button on any song, or Add files above.",
|
||||||
|
ft.Icons.PLAYLIST_ADD)
|
||||||
|
return
|
||||||
|
self.Body.content = self._TrackList(Tracks, Context="Playlist", Numbered=True)
|
||||||
|
|
||||||
|
# --- Track rows ---------------------------------------------------------
|
||||||
|
|
||||||
|
def _TrackList(self, Tracks: list[Track], Context: str = "Library", Numbered: bool = False) -> ft.Control:
|
||||||
|
"""Library rows play the list from there; Queue rows jump in the queue;
|
||||||
|
Queue and Playlist rows can be dragged into another order."""
|
||||||
|
self.Shown = Tracks
|
||||||
|
self.RowsByPath = {}
|
||||||
|
Reorderable = Context in ("Queue", "Playlist")
|
||||||
|
Header = ft.Container(ft.Row([
|
||||||
|
ft.Container(width=36),
|
||||||
|
ft.Text("Title", size=12, color=Palette.Overlay0, expand=4),
|
||||||
|
ft.Text("Artist", size=12, color=Palette.Overlay0, expand=3),
|
||||||
|
ft.Text("Album", size=12, color=Palette.Overlay0, expand=3),
|
||||||
|
ft.Container(ft.Icon(ft.Icons.SCHEDULE, size=14, color=Palette.Overlay0), width=56),
|
||||||
|
ft.Container(width=40 + (24 if Reorderable else 0)),
|
||||||
|
], spacing=8), padding=ft.Padding.symmetric(horizontal=8))
|
||||||
|
|
||||||
|
# Reorderable lists need all rows (dragging works on the full list);
|
||||||
|
# the library pages through long ones.
|
||||||
|
Limit = len(Tracks) if Reorderable else PageSize
|
||||||
|
Rows = [self._TrackRow(Item, Index, Context, Numbered) for Index, Item in enumerate(Tracks[:Limit])]
|
||||||
|
if Reorderable:
|
||||||
|
List = ft.ReorderableListView(Rows, expand=True, show_default_drag_handles=False,
|
||||||
|
on_reorder=lambda e: self._Reordered(Context, e.old_index, e.new_index))
|
||||||
|
else:
|
||||||
|
List = ft.ListView(Rows, expand=True, item_extent=RowHeight)
|
||||||
|
if len(Tracks) > Limit:
|
||||||
|
self._AddMoreButton(List, Tracks, Limit, Context, Numbered)
|
||||||
|
self._MarkPlaying()
|
||||||
|
return ft.Column([Header, ft.Divider(height=1, color=Palette.Surface1), List], expand=True, spacing=0)
|
||||||
|
|
||||||
|
def _AddMoreButton(self, List: ft.ListView, Tracks: list[Track], Shown: int, Context: str, Numbered: bool) -> None:
|
||||||
|
def More(e) -> None:
|
||||||
|
List.controls.pop()
|
||||||
|
Next = min(len(Tracks), Shown + PageSize)
|
||||||
|
List.controls += [self._TrackRow(Item, Index, Context, Numbered)
|
||||||
|
for Index, Item in enumerate(Tracks[Shown:Next], start=Shown)]
|
||||||
|
if Next < len(Tracks):
|
||||||
|
self._AddMoreButton(List, Tracks, Next, Context, Numbered)
|
||||||
|
self._MarkPlaying()
|
||||||
|
self._Show(List)
|
||||||
|
|
||||||
|
List.controls.append(ft.Container(
|
||||||
|
ChipButton(f"Show more ({len(Tracks) - Shown} left)", More), alignment=ft.Alignment.CENTER, height=RowHeight))
|
||||||
|
|
||||||
|
def _TrackRow(self, Item: Track, Index: int, Context: str, Numbered: bool) -> ft.Control:
|
||||||
|
Lead = ft.Text(str(Item.TrackNumber or Index + 1) if Numbered else "", size=12, color=Palette.Overlay0,
|
||||||
|
width=36, text_align=ft.TextAlign.CENTER)
|
||||||
|
Menu = [
|
||||||
|
ft.PopupMenuItem(content="Play next", icon=ft.Icons.PLAYLIST_PLAY, on_click=lambda e: self._PlayNext([Item.Path])),
|
||||||
|
ft.PopupMenuItem(content="Add to queue", icon=ft.Icons.QUEUE, on_click=lambda e: self._Enqueue([Item.Path])),
|
||||||
|
ft.PopupMenuItem(content="Add to a playlist...", icon=ft.Icons.PLAYLIST_ADD,
|
||||||
|
on_click=lambda e: self._AddToPlaylist([Item.Path])),
|
||||||
|
]
|
||||||
|
if Item.Album:
|
||||||
|
Menu.append(ft.PopupMenuItem(content="Go to album", icon=ft.Icons.ALBUM_OUTLINED,
|
||||||
|
on_click=lambda e: self.ShowView("Album", Item.AlbumKey)))
|
||||||
|
Menu.append(ft.PopupMenuItem(content="Open in Audio mode", icon=ft.Icons.HEADPHONES_OUTLINED,
|
||||||
|
on_click=lambda e: self.TheApp.OpenInAudio([Item.Path])))
|
||||||
|
if Context == "Queue":
|
||||||
|
Menu.append(ft.PopupMenuItem(content="Remove from queue", icon=ft.Icons.REMOVE_CIRCLE_OUTLINE,
|
||||||
|
on_click=lambda e: self._RemoveFromQueue(Index)))
|
||||||
|
elif Context == "Playlist":
|
||||||
|
Menu.append(ft.PopupMenuItem(content="Remove from playlist", icon=ft.Icons.REMOVE_CIRCLE_OUTLINE,
|
||||||
|
on_click=lambda e: self._RemoveFromPlaylist(Index)))
|
||||||
|
Missing = Context != "Library" and not Path(Item.Path).is_file()
|
||||||
|
TextColour = Palette.Overlay0 if Missing else Palette.Text
|
||||||
|
Cells: list[ft.Control] = [
|
||||||
|
Lead,
|
||||||
|
ft.Text(Item.DisplayTitle + (" (missing)" if Missing else ""), size=14, color=TextColour, expand=4,
|
||||||
|
max_lines=1, overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
ft.Text(Item.DisplayArtist, size=13, color=Palette.Overlay0, expand=3, max_lines=1,
|
||||||
|
overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
ft.Text(Item.DisplayAlbum if Item.Album else "", size=13, color=Palette.Overlay0, expand=3, max_lines=1,
|
||||||
|
overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
ft.Text(FormatTime(Item.Duration) if Item.Duration else "", size=12, color=Palette.Overlay0, width=56),
|
||||||
|
ft.PopupMenuButton(icon=ft.Icons.MORE_VERT, icon_color=Palette.Overlay0, icon_size=18, items=Menu),
|
||||||
|
]
|
||||||
|
if Context in ("Queue", "Playlist"):
|
||||||
|
Cells.append(ft.ReorderableDragHandle(ft.Icon(ft.Icons.DRAG_INDICATOR, color=Palette.Overlay0, size=18),
|
||||||
|
mouse_cursor=ft.MouseCursor.GRAB))
|
||||||
|
Row = ft.Container(
|
||||||
|
ft.Row(Cells, spacing=8), height=RowHeight, padding=ft.Padding.symmetric(horizontal=8), border_radius=4,
|
||||||
|
ink=True, on_click=lambda e: self.TheApp.Page.run_task(self._RowClicked, Context, Index),
|
||||||
|
data=(Context, Index),
|
||||||
|
)
|
||||||
|
self.RowsByPath.setdefault(Item.Path, []).append(Row)
|
||||||
|
return Row
|
||||||
|
|
||||||
|
def _MarkPlaying(self) -> None:
|
||||||
|
"""Highlights the current track's rows (in the queue: only its own entry)."""
|
||||||
|
Current = self.Queue.Current if self.ThePlayer.Source == self.Queue.Current else None
|
||||||
|
for PathText, Rows in self.RowsByPath.items():
|
||||||
|
for Row in Rows:
|
||||||
|
Context, Index = Row.data
|
||||||
|
IsCurrent = PathText == Current and (Context != "Queue" or Index == self.Queue.Index)
|
||||||
|
Row.bgcolor = Palette.Surface0 if IsCurrent else None
|
||||||
|
Lead = Row.content.controls[0]
|
||||||
|
if IsCurrent:
|
||||||
|
Row.content.controls[0] = ft.Container(
|
||||||
|
ft.Icon(ft.Icons.VOLUME_UP if self.ThePlayer.Playing else ft.Icons.PAUSE, size=16,
|
||||||
|
color=Palette.Mauve), width=36, data=Lead)
|
||||||
|
elif isinstance(Lead, ft.Container) and isinstance(Lead.data, ft.Text):
|
||||||
|
Row.content.controls[0] = Lead.data
|
||||||
|
|
||||||
|
async def _RowClicked(self, Context: str, Index: int) -> None:
|
||||||
|
if Context == "Queue":
|
||||||
|
self.Queue.Index = Index
|
||||||
|
await self._LoadCurrent()
|
||||||
|
else:
|
||||||
|
await self.PlayList([Item.Path for Item in self.Shown], Index)
|
||||||
|
|
||||||
|
# --- Playing ------------------------------------------------------------
|
||||||
|
|
||||||
|
async def PlayList(self, Paths: list[str], Start: int) -> None:
|
||||||
|
"""Replaces the queue with Paths and plays from Start (-1: shuffled)."""
|
||||||
|
if not Paths:
|
||||||
|
return
|
||||||
|
if Start < 0:
|
||||||
|
self.Queue.Shuffle = True
|
||||||
|
self.Queue.Set(Paths, random.randrange(len(Paths)))
|
||||||
|
self._ShowModes()
|
||||||
|
else:
|
||||||
|
self.Queue.Set(Paths, Start)
|
||||||
|
await self._LoadCurrent()
|
||||||
|
|
||||||
|
async def _LoadCurrent(self, Play: bool = True, Start: int = 0) -> None:
|
||||||
|
"""Loads the queue's current track, skipping ones whose file is gone."""
|
||||||
|
Tries = len(self.Queue.Paths)
|
||||||
|
while self.Queue.Current and Tries > 0:
|
||||||
|
Item = self.Library.Get(self.Queue.Current)
|
||||||
|
if await self.ThePlayer.Load(Item.Path, Start=Start, Play=Play, Duration=Item.Duration):
|
||||||
|
self._ShowNowPlaying(Item)
|
||||||
|
self.SaveSession()
|
||||||
|
return
|
||||||
|
Tries -= 1
|
||||||
|
Next = self.Queue.Next()
|
||||||
|
if Next is None or Next == self.Queue.Index:
|
||||||
|
break
|
||||||
|
self.Queue.Index, Start = Next, 0
|
||||||
|
self.TheApp.Notify("Couldn't play any song in the queue (missing or unplayable files).", "error")
|
||||||
|
|
||||||
|
def _ShowNowPlaying(self, Item: Track) -> None:
|
||||||
|
Subtitle = " - ".join(Part for Part in (Item.DisplayArtist, Item.Album) if Part)
|
||||||
|
self.Bar.ShowTrack(Item.DisplayTitle, Subtitle, None)
|
||||||
|
self.TheApp.Page.run_task(self._LoadBarCover, Item.Path)
|
||||||
|
if self.View == "Queue":
|
||||||
|
self._Rebuild()
|
||||||
|
else:
|
||||||
|
self._RefreshRows()
|
||||||
|
|
||||||
|
async def _LoadBarCover(self, FilePath: str) -> None:
|
||||||
|
Data = await asyncio.to_thread(CoverFor, FilePath, 112)
|
||||||
|
if self.ThePlayer.Source == FilePath:
|
||||||
|
self.Bar.ShowTrack(self.Bar.Title.value, self.Bar.Subtitle.value, Data)
|
||||||
|
|
||||||
|
def _RefreshRows(self) -> None:
|
||||||
|
self._MarkPlaying()
|
||||||
|
self._Show(self.Body)
|
||||||
|
|
||||||
|
def _PlayerChanged(self) -> None:
|
||||||
|
self.Bar.Refresh()
|
||||||
|
if not self.ThePlayer.Playing:
|
||||||
|
self.SaveSession()
|
||||||
|
self._RefreshRows()
|
||||||
|
|
||||||
|
async def Next(self) -> None:
|
||||||
|
Target = self.Queue.Next()
|
||||||
|
if Target is not None:
|
||||||
|
self.Queue.Index = Target
|
||||||
|
await self._LoadCurrent()
|
||||||
|
|
||||||
|
async def Previous(self) -> None:
|
||||||
|
if self.ThePlayer.Position > RestartAfterMs:
|
||||||
|
await self.ThePlayer.Seek(0)
|
||||||
|
return
|
||||||
|
Target = self.Queue.Previous()
|
||||||
|
if Target is not None:
|
||||||
|
self.Queue.Index = Target
|
||||||
|
await self._LoadCurrent()
|
||||||
|
|
||||||
|
async def _Ended(self) -> None:
|
||||||
|
Target = self.Queue.Next(Finished=True)
|
||||||
|
if Target is None:
|
||||||
|
self.SaveSession()
|
||||||
|
self.Bar.Refresh()
|
||||||
|
return
|
||||||
|
self.Queue.Index = Target
|
||||||
|
await self._LoadCurrent()
|
||||||
|
|
||||||
|
def _ShowModes(self) -> None:
|
||||||
|
self.ShuffleButton.icon_color = Palette.Mauve if self.Queue.Shuffle else Palette.Overlay0
|
||||||
|
self.RepeatButton.icon = ft.Icons.REPEAT_ONE if self.Queue.Repeat == "One" else ft.Icons.REPEAT
|
||||||
|
self.RepeatButton.icon_color = Palette.Overlay0 if self.Queue.Repeat == "Off" else Palette.Mauve
|
||||||
|
self.RepeatButton.tooltip = f"Repeat: {self.Queue.Repeat.lower()}"
|
||||||
|
|
||||||
|
def _ToggleShuffle(self, e) -> None:
|
||||||
|
self.Queue.SetShuffle(not self.Queue.Shuffle)
|
||||||
|
self._ShowModes()
|
||||||
|
self._Show(self.ShuffleButton)
|
||||||
|
self.SaveSession()
|
||||||
|
if self.View == "Queue":
|
||||||
|
self._Rebuild()
|
||||||
|
|
||||||
|
def _CycleRepeat(self, e) -> None:
|
||||||
|
self.Queue.CycleRepeat()
|
||||||
|
self._ShowModes()
|
||||||
|
self._Show(self.RepeatButton)
|
||||||
|
self.SaveSession()
|
||||||
|
|
||||||
|
# --- Queue editing ------------------------------------------------------
|
||||||
|
|
||||||
|
def _AfterQueueEdit(self, Message: str = "") -> None:
|
||||||
|
self.SaveSession()
|
||||||
|
if self.View == "Queue":
|
||||||
|
self._Rebuild()
|
||||||
|
if Message:
|
||||||
|
self.TheApp.Notify(Message, "success")
|
||||||
|
|
||||||
|
def _Plural(self, Count: int) -> str:
|
||||||
|
return f"{Count} song{'s' if Count != 1 else ''}"
|
||||||
|
|
||||||
|
def _PlayNext(self, Paths: list[str]) -> None:
|
||||||
|
Empty = not self.Queue.Paths
|
||||||
|
self.Queue.PlayNext(Paths)
|
||||||
|
self._AfterQueueEdit(f"{self._Plural(len(Paths))} will play next.")
|
||||||
|
if Empty:
|
||||||
|
self.TheApp.Page.run_task(self._LoadCurrent)
|
||||||
|
|
||||||
|
def _Enqueue(self, Paths: list[str]) -> None:
|
||||||
|
Empty = not self.Queue.Paths
|
||||||
|
self.Queue.Append(Paths)
|
||||||
|
self._AfterQueueEdit(f"Added {self._Plural(len(Paths))} to the queue.")
|
||||||
|
if Empty:
|
||||||
|
self.TheApp.Page.run_task(self._LoadCurrent)
|
||||||
|
|
||||||
|
def _RemoveFromQueue(self, Index: int) -> None:
|
||||||
|
WasCurrent = Index == self.Queue.Index
|
||||||
|
self.Queue.Remove(Index)
|
||||||
|
self._AfterQueueEdit()
|
||||||
|
if WasCurrent:
|
||||||
|
if self.Queue.Current:
|
||||||
|
self.TheApp.Page.run_task(self._LoadCurrent, self.ThePlayer.Playing)
|
||||||
|
else:
|
||||||
|
self.TheApp.Page.run_task(self.ThePlayer.Stop)
|
||||||
|
|
||||||
|
def _ClearQueue(self) -> None:
|
||||||
|
self.Queue.Clear()
|
||||||
|
self.TheApp.Page.run_task(self.ThePlayer.Stop)
|
||||||
|
self.Bar.ShowTrack("Nothing playing", "", None)
|
||||||
|
self._AfterQueueEdit()
|
||||||
|
|
||||||
|
def _Reordered(self, Context: str, Old: Optional[int], New: Optional[int]) -> None:
|
||||||
|
if Old is None or New is None or Old == New:
|
||||||
|
return
|
||||||
|
if Context == "Queue":
|
||||||
|
self.Queue.Move(Old, New)
|
||||||
|
self.SaveSession()
|
||||||
|
else:
|
||||||
|
Item = self._Playlist(self.ViewArgument)
|
||||||
|
if Item is None:
|
||||||
|
return
|
||||||
|
Item.Tracks.insert(New, Item.Tracks.pop(Old))
|
||||||
|
self._SavePlaylist(Item)
|
||||||
|
self._Rebuild() # row indices changed
|
||||||
|
|
||||||
|
# --- Playlists ----------------------------------------------------------
|
||||||
|
|
||||||
|
def _SavePlaylist(self, Item: Playlist) -> bool:
|
||||||
|
try:
|
||||||
|
Item.Save()
|
||||||
|
return True
|
||||||
|
except OSError as Error:
|
||||||
|
self.TheApp.Notify(f"Couldn't save {Item.Name}: {Error}", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _ReloadPlaylists(self) -> None:
|
||||||
|
self.Playlists = LoadPlaylists()
|
||||||
|
self._BuildSidebar()
|
||||||
|
self._Show(self.SidebarList)
|
||||||
|
|
||||||
|
def _NewPlaylist(self, Paths: Optional[list[str]] = None) -> None:
|
||||||
|
def Make(Name: str) -> None:
|
||||||
|
if NameTaken(Name):
|
||||||
|
self.TheApp.Notify(f"There's already a playlist called {Name}.", "warning")
|
||||||
|
return
|
||||||
|
Item = Playlist(Name, list(Paths or []))
|
||||||
|
if self._SavePlaylist(Item):
|
||||||
|
self._ReloadPlaylists()
|
||||||
|
if Paths:
|
||||||
|
self.TheApp.Notify(f"Made {Name} with {self._Plural(len(Paths))}.", "success")
|
||||||
|
else:
|
||||||
|
self.ShowView("Playlist", Name)
|
||||||
|
|
||||||
|
self.TheApp.Prompt("New playlist", "Name", Make, OkText="Make")
|
||||||
|
|
||||||
|
def _QueueToPlaylist(self) -> None:
|
||||||
|
self._NewPlaylist(list(self.Queue.Paths))
|
||||||
|
|
||||||
|
def _AddToPlaylist(self, Paths: list[str]) -> None:
|
||||||
|
if not Paths:
|
||||||
|
return
|
||||||
|
|
||||||
|
def Add(Item: Playlist) -> None:
|
||||||
|
self.TheApp.Close()
|
||||||
|
Item.Tracks.extend(Paths)
|
||||||
|
if self._SavePlaylist(Item):
|
||||||
|
self.TheApp.Notify(f"Added {self._Plural(len(Paths))} to {Item.Name}.", "success")
|
||||||
|
if self.View == "Playlist" and self.ViewArgument == Item.Name:
|
||||||
|
self._Rebuild()
|
||||||
|
|
||||||
|
def New(e) -> None:
|
||||||
|
self.TheApp.Close()
|
||||||
|
self._NewPlaylist(Paths)
|
||||||
|
|
||||||
|
Choices: list[ft.Control] = [
|
||||||
|
ft.ListTile(leading=ft.Icon(ft.Icons.PLAYLIST_PLAY, color=Palette.Mauve), title=Item.Name,
|
||||||
|
subtitle=self._Plural(len(Item.Tracks)), on_click=lambda e, I=Item: Add(I))
|
||||||
|
for Item in self.Playlists
|
||||||
|
]
|
||||||
|
Choices.append(ft.ListTile(leading=ft.Icon(ft.Icons.ADD, color=Palette.Mauve), title="New playlist...",
|
||||||
|
on_click=New))
|
||||||
|
self.TheApp.ShowDialog(f"Add {self._Plural(len(Paths))} to",
|
||||||
|
ft.Column(Choices, tight=True, scroll=ft.ScrollMode.AUTO, height=min(360, 64 * len(Choices))),
|
||||||
|
[ChipButton("Cancel", lambda e: self.TheApp.Close())], Width=360)
|
||||||
|
|
||||||
|
def _RemoveFromPlaylist(self, Index: int) -> None:
|
||||||
|
Item = self._Playlist(self.ViewArgument)
|
||||||
|
if Item is not None and 0 <= Index < len(Item.Tracks):
|
||||||
|
Item.Tracks.pop(Index)
|
||||||
|
self._SavePlaylist(Item)
|
||||||
|
self._Rebuild()
|
||||||
|
|
||||||
|
def _RenamePlaylist(self, Item: Playlist) -> None:
|
||||||
|
def Rename(Name: str) -> None:
|
||||||
|
if Name == Item.Name:
|
||||||
|
return
|
||||||
|
if NameTaken(Name, Except=Item.Name):
|
||||||
|
self.TheApp.Notify(f"There's already a playlist called {Name}.", "warning")
|
||||||
|
return
|
||||||
|
OldFile = Item.FilePath
|
||||||
|
Item.Name = Name
|
||||||
|
if self._SavePlaylist(Item):
|
||||||
|
if OldFile != Item.FilePath:
|
||||||
|
OldFile.unlink(missing_ok=True)
|
||||||
|
self._ReloadPlaylists()
|
||||||
|
self.ShowView("Playlist", Name)
|
||||||
|
|
||||||
|
self.TheApp.Prompt("Rename playlist", "Name", Rename, Value=Item.Name, OkText="Rename")
|
||||||
|
|
||||||
|
def _DeletePlaylist(self, Item: Playlist) -> None:
|
||||||
|
def Delete() -> None:
|
||||||
|
Item.Delete()
|
||||||
|
self._ReloadPlaylists()
|
||||||
|
self.ShowView("Songs")
|
||||||
|
self.TheApp.Notify(f"Deleted {Item.Name}.", "success")
|
||||||
|
|
||||||
|
self.TheApp.Confirm("Delete playlist", f"Delete {Item.Name}? The songs themselves stay where they are.", Delete)
|
||||||
|
|
||||||
|
async def _AddFilesToPlaylist(self, e) -> None:
|
||||||
|
Item = self._Playlist(self.ViewArgument)
|
||||||
|
if Item is None:
|
||||||
|
return
|
||||||
|
Picked = await self.TheApp.Picker.pick_files(dialog_title=f"Add to {Item.Name}", allow_multiple=True)
|
||||||
|
Paths = [P.path for P in Picked or [] if P.path and IsAudioFile(Path(P.path))]
|
||||||
|
if Paths:
|
||||||
|
Item.Tracks.extend(Paths)
|
||||||
|
if self._SavePlaylist(Item):
|
||||||
|
self._Rebuild()
|
||||||
|
|
||||||
|
async def _ExportM3U(self, Item: Playlist) -> None:
|
||||||
|
Destination = await self.TheApp.Picker.save_file(
|
||||||
|
dialog_title="Export playlist", file_name=f"{Item.Name}.m3u8", initial_directory=str(Home),
|
||||||
|
allowed_extensions=["m3u8", "m3u"], file_type=ft.FilePickerFileType.CUSTOM)
|
||||||
|
if not Destination:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ExportM3U(Item, Path(Destination))
|
||||||
|
except OSError as Error:
|
||||||
|
self.TheApp.Notify(f"Couldn't export: {Error}", "error")
|
||||||
|
return
|
||||||
|
self.TheApp.Notify(f"Exported {Item.Name}.", "success")
|
||||||
|
|
||||||
|
async def _ImportM3U(self, e) -> None:
|
||||||
|
Picked = await self.TheApp.Picker.pick_files(
|
||||||
|
dialog_title="Import playlist", file_type=ft.FilePickerFileType.CUSTOM, allowed_extensions=["m3u", "m3u8"])
|
||||||
|
if not Picked or not Picked[0].path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
Item = ReadM3U(Path(Picked[0].path))
|
||||||
|
except OSError as Error:
|
||||||
|
self.TheApp.Notify(f"Couldn't read it: {Error}", "error")
|
||||||
|
return
|
||||||
|
Base, Number = Item.Name, 2
|
||||||
|
while NameTaken(Item.Name):
|
||||||
|
Item.Name, Number = f"{Base} {Number}", Number + 1
|
||||||
|
if self._SavePlaylist(Item):
|
||||||
|
self._ReloadPlaylists()
|
||||||
|
self.ShowView("Playlist", Item.Name)
|
||||||
|
self.TheApp.Notify(f"Imported {Item.Name} ({self._Plural(len(Item.Tracks))}).", "success")
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Builds dist/SaraTune-x86_64.AppImage: a PyInstaller one-file build (with
|
||||||
|
# the font and icon bundled) wrapped by appimagetool, the same way SaraSuite's
|
||||||
|
# and SaraFem's AppImages are made. Needs appimagetool on PATH.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$(readlink -f "$0")")/.."
|
||||||
|
|
||||||
|
[ -d .venv ] || python3 -m venv .venv
|
||||||
|
.venv/bin/pip install --quiet --disable-pip-version-check -r requirements.txt pyinstaller
|
||||||
|
|
||||||
|
# --collect-data flet: without it ft.Icons crashes when frozen.
|
||||||
|
.venv/bin/pyinstaller --noconfirm --clean --log-level WARN \
|
||||||
|
--onefile --windowed --name SaraTune --collect-data flet \
|
||||||
|
--add-data "$PWD/assets:assets" \
|
||||||
|
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
|
||||||
|
"$PWD/Main.py"
|
||||||
|
|
||||||
|
AppDir=build/AppDir
|
||||||
|
rm -rf "$AppDir"
|
||||||
|
mkdir -p "$AppDir/usr/bin"
|
||||||
|
cp build/PyInstaller/dist/SaraTune "$AppDir/usr/bin/SaraTune"
|
||||||
|
ln -s usr/bin/SaraTune "$AppDir/AppRun"
|
||||||
|
cp assets/Icon.png "$AppDir/SaraTune.png"
|
||||||
|
ln -s SaraTune.png "$AppDir/.DirIcon"
|
||||||
|
cat > "$AppDir/SaraTune.desktop" << DESKTOP
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=SaraTune
|
||||||
|
Comment=Play audio files and your music library
|
||||||
|
Exec=SaraTune %F
|
||||||
|
Icon=SaraTune
|
||||||
|
Terminal=false
|
||||||
|
Categories=AudioVideo;Audio;Player;
|
||||||
|
DESKTOP
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
ARCH=x86_64 appimagetool --no-appstream "$AppDir" dist/SaraTune-x86_64.AppImage
|
||||||
|
echo "Built dist/SaraTune-x86_64.AppImage"
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs SaraTune from source: Packaging/Launch.sh [files...]
|
||||||
|
cd "$(dirname "$(readlink -f "$0")")/.." || exit 1
|
||||||
|
[ -d .venv ] || { python3 -m venv .venv && .venv/bin/pip install -r requirements.txt; }
|
||||||
|
exec .venv/bin/python Main.py "$@"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
# Kept in sync between SaraSuite/Programs/SaraTune/ and SaraTune/Packaging/SaraSuite/.
|
||||||
|
Name = "SaraTune"
|
||||||
|
Platforms = ["Linux", "Windows"]
|
||||||
|
Description = "A music player with two modes: Audio to open a file or folder and listen right away, Music for your library, a queue and saved playlists. Linux needs libmpv (Fedora: mpv-libs)."
|
||||||
|
|
||||||
|
[Source]
|
||||||
|
Git = "{GitHost}/SaraTune"
|
||||||
|
Include = ["*.py", "requirements.txt", "assets/**"]
|
||||||
|
Exclude = ["Packaging"]
|
||||||
|
|
||||||
|
[Python]
|
||||||
|
RequirementsFile = "requirements.txt"
|
||||||
|
|
||||||
|
# Linux: a launcher and a desktop entry (also offered for opening audio files).
|
||||||
|
[[Templates]]
|
||||||
|
Platform = "Linux"
|
||||||
|
Source = "Launch.sh"
|
||||||
|
Target = "{AppDir}/Packaging/Launch.sh"
|
||||||
|
Executable = true
|
||||||
|
|
||||||
|
[[Templates]]
|
||||||
|
Platform = "Linux"
|
||||||
|
Source = "SaraTune.desktop"
|
||||||
|
Target = "{ApplicationsDir}/SaraTune.desktop"
|
||||||
|
|
||||||
|
# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder).
|
||||||
|
[[Shortcuts]]
|
||||||
|
Platform = "Windows"
|
||||||
|
Name = "SaraTune"
|
||||||
|
Script = "Main.py"
|
||||||
|
Icon = "assets/Icon.png"
|
||||||
|
|
||||||
|
# Made by SaraTune itself.
|
||||||
|
[[Leftovers]]
|
||||||
|
Path = "{ConfigDir}"
|
||||||
|
Kind = "Config"
|
||||||
|
Note = "SaraTune's settings, saved queue and your playlists"
|
||||||
|
|
||||||
|
[[Leftovers]]
|
||||||
|
Path = "{CacheHome}/SaraSuite/SaraTune"
|
||||||
|
Kind = "Runtime"
|
||||||
|
Note = "library tag cache and cover thumbnails"
|
||||||
|
|
||||||
|
# Shared with the other Flet programs; see Programs/FletIdentity.py.
|
||||||
|
[[Templates]]
|
||||||
|
Platform = "Linux"
|
||||||
|
Source = "../../FletIdentity.py"
|
||||||
|
Target = "{ProgramDir}/FletIdentity.py"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Written by SaraSuite. Runs from the app folder so Flet finds assets/.
|
||||||
|
# Flet's audio player is only in its "full" desktop client.
|
||||||
|
export FLET_DESKTOP_FLAVOR=full
|
||||||
|
# A Flet client copy with the window app ID srhtnin.{Name}, so the desktop
|
||||||
|
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it
|
||||||
|
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
|
||||||
|
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
|
||||||
|
cd "{AppDir}" || exit 1
|
||||||
|
exec "{Python}" Main.py "$@"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=SaraTune
|
||||||
|
Comment=Play audio files and your music library
|
||||||
|
Exec={AppDir}/Packaging/Launch.sh %F
|
||||||
|
Icon={AppDir}/assets/Icon.png
|
||||||
|
Terminal=false
|
||||||
|
Categories=AudioVideo;Audio;Player;
|
||||||
|
MimeType=audio/mpeg;audio/flac;audio/x-flac;audio/ogg;audio/x-vorbis+ogg;audio/opus;audio/x-opus+ogg;audio/wav;audio/x-wav;audio/mp4;audio/x-m4a;audio/aac;audio/x-aiff;audio/x-ms-wma;audio/x-matroska;audio/webm;
|
||||||
|
StartupNotify=true
|
||||||
|
StartupWMClass=srhtnin.{Name}
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
"""Music mode's queue: what plays next, shuffle and repeat. Shuffling
|
||||||
|
reorders the queue itself (the current track first), so the Queue page
|
||||||
|
always shows the real play order; turning it off brings the original order
|
||||||
|
back. Saved between runs as Session.toml."""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from Core import ConfigDir, SessionPath
|
||||||
|
|
||||||
|
RepeatModes = ["Off", "All", "One"]
|
||||||
|
RestartAfterMs = 3000 # Previous restarts the track when it's further in than this
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlayQueue:
|
||||||
|
Paths: list[str] = field(default_factory=list)
|
||||||
|
Index: int = -1 # into Paths; -1 = nothing
|
||||||
|
Shuffle: bool = False
|
||||||
|
Repeat: str = "Off"
|
||||||
|
Unshuffled: list[str] = field(default_factory=list) # the order before shuffling
|
||||||
|
|
||||||
|
@property
|
||||||
|
def Current(self) -> Optional[str]:
|
||||||
|
return self.Paths[self.Index] if 0 <= self.Index < len(self.Paths) else None
|
||||||
|
|
||||||
|
def Set(self, Paths: list[str], Start: int = 0) -> None:
|
||||||
|
"""Replaces the queue, e.g. with an album, starting at Paths[Start]."""
|
||||||
|
self.Paths = list(Paths)
|
||||||
|
self.Index = Start if Paths else -1
|
||||||
|
self.Unshuffled = list(Paths)
|
||||||
|
if self.Shuffle and Paths:
|
||||||
|
self._ShuffleRest()
|
||||||
|
|
||||||
|
def _ShuffleRest(self) -> None:
|
||||||
|
Current = self.Paths[self.Index]
|
||||||
|
Rest = self.Paths[: self.Index] + self.Paths[self.Index + 1 :]
|
||||||
|
random.shuffle(Rest)
|
||||||
|
self.Paths = [Current] + Rest
|
||||||
|
self.Index = 0
|
||||||
|
|
||||||
|
def SetShuffle(self, Enabled: bool) -> None:
|
||||||
|
if Enabled == self.Shuffle:
|
||||||
|
return
|
||||||
|
self.Shuffle = Enabled
|
||||||
|
if not self.Paths:
|
||||||
|
return
|
||||||
|
if Enabled:
|
||||||
|
self.Unshuffled = list(self.Paths)
|
||||||
|
self._ShuffleRest()
|
||||||
|
else:
|
||||||
|
Current = self.Current
|
||||||
|
self.Paths = self.Unshuffled or self.Paths
|
||||||
|
self.Index = self.Paths.index(Current) if Current in self.Paths else 0
|
||||||
|
|
||||||
|
def CycleRepeat(self) -> str:
|
||||||
|
self.Repeat = RepeatModes[(RepeatModes.index(self.Repeat) + 1) % len(RepeatModes)]
|
||||||
|
return self.Repeat
|
||||||
|
|
||||||
|
def Next(self, Finished: bool = False) -> Optional[int]:
|
||||||
|
"""The index to play after the current one (None: stop). Finished means
|
||||||
|
the track ran out by itself, which is when Repeat One applies."""
|
||||||
|
if not self.Paths:
|
||||||
|
return None
|
||||||
|
if Finished and self.Repeat == "One":
|
||||||
|
return self.Index
|
||||||
|
if self.Index + 1 < len(self.Paths):
|
||||||
|
return self.Index + 1
|
||||||
|
return 0 if self.Repeat != "Off" or not Finished else None
|
||||||
|
|
||||||
|
def Previous(self) -> Optional[int]:
|
||||||
|
if not self.Paths:
|
||||||
|
return None
|
||||||
|
if self.Index > 0:
|
||||||
|
return self.Index - 1
|
||||||
|
return len(self.Paths) - 1 if self.Repeat == "All" else 0
|
||||||
|
|
||||||
|
# --- Editing ------------------------------------------------------------
|
||||||
|
|
||||||
|
def PlayNext(self, Paths: list[str]) -> None:
|
||||||
|
At = self.Index + 1 if self.Index >= 0 else 0
|
||||||
|
self.Paths[At:At] = Paths
|
||||||
|
self.Unshuffled.extend(Paths)
|
||||||
|
if self.Index < 0:
|
||||||
|
self.Index = 0
|
||||||
|
|
||||||
|
def Append(self, Paths: list[str]) -> None:
|
||||||
|
self.Paths.extend(Paths)
|
||||||
|
self.Unshuffled.extend(Paths)
|
||||||
|
if self.Index < 0 and self.Paths:
|
||||||
|
self.Index = 0
|
||||||
|
|
||||||
|
def Remove(self, At: int) -> None:
|
||||||
|
Removed = self.Paths.pop(At)
|
||||||
|
if Removed in self.Unshuffled:
|
||||||
|
self.Unshuffled.remove(Removed)
|
||||||
|
if At < self.Index or (At == self.Index and self.Index >= len(self.Paths)):
|
||||||
|
self.Index -= 1
|
||||||
|
if not self.Paths:
|
||||||
|
self.Index = -1
|
||||||
|
|
||||||
|
def Move(self, Old: int, New: int) -> None:
|
||||||
|
Item = self.Paths.pop(Old)
|
||||||
|
self.Paths.insert(New, Item)
|
||||||
|
if Old == self.Index:
|
||||||
|
self.Index = New
|
||||||
|
elif Old < self.Index <= New:
|
||||||
|
self.Index -= 1
|
||||||
|
elif New <= self.Index < Old:
|
||||||
|
self.Index += 1
|
||||||
|
|
||||||
|
def Clear(self) -> None:
|
||||||
|
self.Paths, self.Unshuffled, self.Index = [], [], -1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Session:
|
||||||
|
Queue: PlayQueue = field(default_factory=PlayQueue)
|
||||||
|
Position: int = 0 # milliseconds into the current track
|
||||||
|
View: str = "Songs"
|
||||||
|
|
||||||
|
def Save(self) -> None:
|
||||||
|
Document = tomlkit.document()
|
||||||
|
Document["View"] = self.View
|
||||||
|
Document["Position"] = int(self.Position)
|
||||||
|
Document["Index"] = self.Queue.Index
|
||||||
|
Document["Shuffle"] = self.Queue.Shuffle
|
||||||
|
Document["Repeat"] = self.Queue.Repeat
|
||||||
|
for Key, Paths in (("Paths", self.Queue.Paths), ("Unshuffled", self.Queue.Unshuffled)):
|
||||||
|
Document[Key] = tomlkit.array()
|
||||||
|
Document[Key].multiline(True)
|
||||||
|
Document[Key].extend(Paths)
|
||||||
|
ConfigDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
SessionPath.write_text(tomlkit.dumps(Document), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def LoadSession() -> Session:
|
||||||
|
try:
|
||||||
|
Data = tomlkit.parse(SessionPath.read_text(encoding="utf-8")).unwrap()
|
||||||
|
except (OSError, tomlkit.exceptions.TOMLKitError):
|
||||||
|
return Session()
|
||||||
|
Paths = [str(Item) for Item in Data.get("Paths", []) if isinstance(Item, str)]
|
||||||
|
Index = Data.get("Index", -1)
|
||||||
|
Queue = PlayQueue(
|
||||||
|
Paths=Paths,
|
||||||
|
Index=Index if isinstance(Index, int) and 0 <= Index < len(Paths) else (0 if Paths else -1),
|
||||||
|
Shuffle=bool(Data.get("Shuffle", False)),
|
||||||
|
Repeat=Data.get("Repeat") if Data.get("Repeat") in RepeatModes else "Off",
|
||||||
|
Unshuffled=[str(Item) for Item in Data.get("Unshuffled", []) if isinstance(Item, str)],
|
||||||
|
)
|
||||||
|
return Session(Queue=Queue, Position=int(Data.get("Position", 0) or 0), View=str(Data.get("View", "Songs")))
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""One audio player. Each mode has its own, so switching modes never loses
|
||||||
|
what the other was playing; Main pauses one when the other starts.
|
||||||
|
|
||||||
|
Every track gets a fresh flet_audio.Audio service: changing an existing
|
||||||
|
one's src only works the first time (later play calls never answer), and
|
||||||
|
sources are file:// URIs, since plain paths are read as URLs and break on
|
||||||
|
names with # or %. Events from a replaced service are ignored.
|
||||||
|
|
||||||
|
The Flet client only reports the position about once a second, so the
|
||||||
|
position is kept here and moved along with the clock in between."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
import flet_audio as fta
|
||||||
|
|
||||||
|
CallTimeout = 8 # seconds; a call the client never answers shouldn't hang the app
|
||||||
|
|
||||||
|
|
||||||
|
class Player:
|
||||||
|
def __init__(self, Page: ft.Page, Volume: float):
|
||||||
|
self.Page = Page
|
||||||
|
self.Volume = Volume
|
||||||
|
self.Audio: Optional[fta.Audio] = None
|
||||||
|
self.Source: Optional[str] = None
|
||||||
|
self.Playing = False
|
||||||
|
self.Duration = 0 # milliseconds; 0 until known
|
||||||
|
self._Position = 0
|
||||||
|
self._PositionAt = time.monotonic()
|
||||||
|
self._Ready = asyncio.Event() # the current service has loaded its source
|
||||||
|
self.Loop = False
|
||||||
|
# Set by the owner. OnEnded: the track ran out by itself.
|
||||||
|
self.OnStarted: Callable[[], None] = lambda: None
|
||||||
|
self.OnEnded: Callable[[], None] = lambda: None
|
||||||
|
self.OnChanged: Callable[[], None] = lambda: None # playing/paused, duration, source
|
||||||
|
self.OnError: Callable[[str], None] = lambda Message: None
|
||||||
|
|
||||||
|
# --- Position -----------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def Position(self) -> int:
|
||||||
|
if not self.Playing:
|
||||||
|
return self._Position
|
||||||
|
Moved = self._Position + (time.monotonic() - self._PositionAt) * 1000
|
||||||
|
return int(min(Moved, self.Duration) if self.Duration else Moved)
|
||||||
|
|
||||||
|
def _SetPosition(self, Milliseconds: int) -> None:
|
||||||
|
self._Position = max(0, int(Milliseconds))
|
||||||
|
self._PositionAt = time.monotonic()
|
||||||
|
|
||||||
|
# --- Events from the client ---------------------------------------------
|
||||||
|
|
||||||
|
def _StateChanged(self, Audio: fta.Audio, e: fta.AudioStateChangeEvent) -> None:
|
||||||
|
if Audio is not self.Audio:
|
||||||
|
return
|
||||||
|
if e.state == fta.AudioState.PLAYING:
|
||||||
|
if not self.Playing:
|
||||||
|
self._SetPosition(self._Position)
|
||||||
|
self.Playing = True
|
||||||
|
self.OnStarted()
|
||||||
|
elif e.state == fta.AudioState.COMPLETED:
|
||||||
|
self.Playing = False
|
||||||
|
self._SetPosition(0)
|
||||||
|
if self.Loop:
|
||||||
|
self.Page.run_task(self.Play, 0)
|
||||||
|
else:
|
||||||
|
self.OnEnded()
|
||||||
|
elif e.state in (fta.AudioState.PAUSED, fta.AudioState.STOPPED):
|
||||||
|
if self.Playing:
|
||||||
|
self._SetPosition(self.Position)
|
||||||
|
self.Playing = False
|
||||||
|
self.OnChanged()
|
||||||
|
|
||||||
|
def _PositionChanged(self, Audio: fta.Audio, e: fta.AudioPositionChangeEvent) -> None:
|
||||||
|
if Audio is self.Audio and self.Playing:
|
||||||
|
self._SetPosition(e.position)
|
||||||
|
|
||||||
|
def _DurationChanged(self, Audio: fta.Audio, e: fta.AudioDurationChangeEvent) -> None:
|
||||||
|
if Audio is not self.Audio:
|
||||||
|
return
|
||||||
|
Duration = e.duration
|
||||||
|
Value = int(Duration.in_milliseconds if isinstance(Duration, ft.Duration) else Duration or 0)
|
||||||
|
if Value > 0:
|
||||||
|
self.Duration = Value
|
||||||
|
self._Ready.set()
|
||||||
|
self.OnChanged()
|
||||||
|
|
||||||
|
def _Loaded(self, Audio: fta.Audio) -> None:
|
||||||
|
if Audio is self.Audio:
|
||||||
|
self._Ready.set()
|
||||||
|
|
||||||
|
# --- Control ------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _Call(self, Method, *Arguments) -> bool:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(Method(*Arguments), CallTimeout)
|
||||||
|
return True
|
||||||
|
except (asyncio.TimeoutError, RuntimeError) as Error:
|
||||||
|
# e.g. GStreamer can't decode it: the client raises a PlatformException.
|
||||||
|
Text = str(Error)
|
||||||
|
self.OnError("The player didn't answer." if isinstance(Error, asyncio.TimeoutError) else
|
||||||
|
Text.split(",")[1].strip() if "PlatformException" in Text and "," in Text else Text)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _Unload(self) -> None:
|
||||||
|
Old, self.Audio = self.Audio, None
|
||||||
|
if Old is None:
|
||||||
|
return
|
||||||
|
if Old in self.Page.services:
|
||||||
|
self.Page.services.remove(Old)
|
||||||
|
self.Page.update() # disposes the old service and its native player
|
||||||
|
|
||||||
|
async def Load(self, FilePath: str, Start: int = 0, Play: bool = True, Duration: int = 0) -> bool:
|
||||||
|
"""Switches to FilePath and (if Play) starts at Start (ms). Duration is
|
||||||
|
a hint (e.g. from the tags) until the client reports the real one.
|
||||||
|
False when the file doesn't exist or can't be played."""
|
||||||
|
if not Path(FilePath).is_file():
|
||||||
|
return False
|
||||||
|
await self._Unload()
|
||||||
|
self.Source = FilePath
|
||||||
|
self.Duration = Duration
|
||||||
|
self.Playing = False
|
||||||
|
self._SetPosition(Start)
|
||||||
|
self._Ready = asyncio.Event()
|
||||||
|
Audio = fta.Audio(
|
||||||
|
src=Path(FilePath).absolute().as_uri(),
|
||||||
|
volume=self.Volume,
|
||||||
|
release_mode=fta.ReleaseMode.STOP,
|
||||||
|
)
|
||||||
|
# Handlers know which service they belong to, so a replaced one's late events are dropped.
|
||||||
|
Audio.on_state_change = lambda e: self._StateChanged(Audio, e)
|
||||||
|
Audio.on_position_change = lambda e: self._PositionChanged(Audio, e)
|
||||||
|
Audio.on_duration_change = lambda e: self._DurationChanged(Audio, e)
|
||||||
|
Audio.on_loaded = lambda e: self._Loaded(Audio)
|
||||||
|
self.Audio = Audio
|
||||||
|
self.Page.services.append(Audio)
|
||||||
|
self.Page.update()
|
||||||
|
self.OnChanged()
|
||||||
|
if Play:
|
||||||
|
return await self.Play(Start)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _WaitReady(self) -> None:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._Ready.wait(), 3)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass # play anyway; it answers with an error if the file is bad
|
||||||
|
|
||||||
|
async def Play(self, Start: Optional[int] = None) -> bool:
|
||||||
|
"""From Start (ms), or from where it was."""
|
||||||
|
if not self.Source:
|
||||||
|
return False
|
||||||
|
if self.Audio is None: # stopped: load it again
|
||||||
|
return await self.Load(self.Source, self._Position if Start is None else Start, Duration=self.Duration)
|
||||||
|
Audio = self.Audio
|
||||||
|
await self._WaitReady()
|
||||||
|
Position = self._Position if Start is None else Start
|
||||||
|
self._SetPosition(Position)
|
||||||
|
if Position > 0:
|
||||||
|
# play()'s own position isn't reliable before the first play: start, then seek.
|
||||||
|
if not await self._Call(Audio.resume):
|
||||||
|
return False
|
||||||
|
if not await self._Call(Audio.seek, ft.Duration(milliseconds=int(Position))):
|
||||||
|
return False
|
||||||
|
self._SetPosition(Position) # it played from 0 until the seek landed
|
||||||
|
return True
|
||||||
|
return await self._Call(Audio.play, ft.Duration(milliseconds=0))
|
||||||
|
|
||||||
|
async def Pause(self) -> None:
|
||||||
|
if self.Playing and self.Audio is not None:
|
||||||
|
self._SetPosition(self.Position)
|
||||||
|
self.Playing = False
|
||||||
|
await self._Call(self.Audio.pause)
|
||||||
|
self.OnChanged()
|
||||||
|
|
||||||
|
async def Toggle(self) -> None:
|
||||||
|
if self.Playing:
|
||||||
|
await self.Pause()
|
||||||
|
else:
|
||||||
|
# A finished track (position at the end) starts over.
|
||||||
|
AtEnd = self.Duration and self._Position >= self.Duration - 250
|
||||||
|
await self.Play(0 if AtEnd else None)
|
||||||
|
|
||||||
|
async def Seek(self, Milliseconds: int) -> None:
|
||||||
|
if not self.Source:
|
||||||
|
return
|
||||||
|
Milliseconds = max(0, min(int(Milliseconds), self.Duration or int(Milliseconds)))
|
||||||
|
self._SetPosition(Milliseconds)
|
||||||
|
if self.Playing and self.Audio is not None:
|
||||||
|
await self._Call(self.Audio.seek, ft.Duration(milliseconds=Milliseconds))
|
||||||
|
else:
|
||||||
|
# Paused or not started: remember it, Play starts from there.
|
||||||
|
self.OnChanged()
|
||||||
|
|
||||||
|
async def Stop(self) -> None:
|
||||||
|
"""Back to nothing loaded."""
|
||||||
|
await self._Unload()
|
||||||
|
self.Source = None
|
||||||
|
self.Playing = False
|
||||||
|
self.Duration = 0
|
||||||
|
self._SetPosition(0)
|
||||||
|
self.OnChanged()
|
||||||
|
|
||||||
|
def SetVolume(self, Volume: float) -> None:
|
||||||
|
self.Volume = max(0.0, min(1.0, Volume))
|
||||||
|
if self.Audio is not None:
|
||||||
|
self.Audio.volume = self.Volume
|
||||||
|
self.Audio.update()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Saved playlists: one TOML file each in ~/.config/SaraSuite/SaraTune/Playlists/,
|
||||||
|
holding the playlist's name and its files' paths in order. Also reads and
|
||||||
|
writes .m3u/.m3u8, so playlists can move to and from other players."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from Core import IsAudioFile, IsWindows, PlaylistsDir
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Playlist:
|
||||||
|
Name: str
|
||||||
|
Tracks: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def FilePath(self) -> Path:
|
||||||
|
return PlaylistsDir / f"{SafeFileName(self.Name)}.toml"
|
||||||
|
|
||||||
|
def Save(self) -> None:
|
||||||
|
PlaylistsDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
Document = tomlkit.document()
|
||||||
|
Document["Name"] = self.Name
|
||||||
|
Document["Tracks"] = tomlkit.array()
|
||||||
|
Document["Tracks"].multiline(True)
|
||||||
|
Document["Tracks"].extend(self.Tracks)
|
||||||
|
self.FilePath.write_text(tomlkit.dumps(Document), encoding="utf-8")
|
||||||
|
|
||||||
|
def Delete(self) -> None:
|
||||||
|
self.FilePath.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def SafeFileName(Name: str) -> str:
|
||||||
|
"""A name that works as a file name on Linux and Windows."""
|
||||||
|
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", Name).strip(" .") or "Playlist"
|
||||||
|
|
||||||
|
|
||||||
|
def LoadPlaylists() -> list[Playlist]:
|
||||||
|
Result: list[Playlist] = []
|
||||||
|
if not PlaylistsDir.is_dir():
|
||||||
|
return Result
|
||||||
|
for FilePath in PlaylistsDir.glob("*.toml"):
|
||||||
|
try:
|
||||||
|
Data = tomlkit.parse(FilePath.read_text(encoding="utf-8")).unwrap()
|
||||||
|
except (OSError, tomlkit.exceptions.TOMLKitError):
|
||||||
|
continue
|
||||||
|
Tracks = [str(Item) for Item in Data.get("Tracks", []) if isinstance(Item, str)]
|
||||||
|
Result.append(Playlist(str(Data.get("Name") or FilePath.stem), Tracks))
|
||||||
|
return sorted(Result, key=lambda Item: Item.Name.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def NameTaken(Name: str, Except: str = "") -> bool:
|
||||||
|
"""Two names that make the same file count as the same playlist."""
|
||||||
|
Wanted = SafeFileName(Name).lower()
|
||||||
|
return any(SafeFileName(Item.Name).lower() == Wanted and Item.Name != Except for Item in LoadPlaylists())
|
||||||
|
|
||||||
|
|
||||||
|
def ExportM3U(Item: Playlist, Destination: Path) -> None:
|
||||||
|
Lines = ["#EXTM3U", f"#PLAYLIST:{Item.Name}"] + Item.Tracks
|
||||||
|
Destination.write_text("\n".join(Lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def ReadM3U(Source: Path) -> Playlist:
|
||||||
|
"""Relative entries count from the .m3u file's folder; URLs and missing
|
||||||
|
files are skipped."""
|
||||||
|
Name = Source.stem
|
||||||
|
Tracks: list[str] = []
|
||||||
|
for Line in Source.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
Line = Line.strip().lstrip("")
|
||||||
|
if Line.startswith("#PLAYLIST:"):
|
||||||
|
Name = Line.split(":", 1)[1].strip() or Name
|
||||||
|
continue
|
||||||
|
if not Line or Line.startswith("#") or "://" in Line:
|
||||||
|
continue
|
||||||
|
# Playlists made on Windows use backslashes, which Linux paths don't split on.
|
||||||
|
Entry = Path(Line if IsWindows else Line.replace("\\", "/"))
|
||||||
|
if not Entry.is_absolute():
|
||||||
|
Entry = Source.parent / Entry
|
||||||
|
if Entry.is_file() and IsAudioFile(Entry):
|
||||||
|
Tracks.append(str(Entry.resolve()))
|
||||||
|
return Playlist(Name, Tracks)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# SaraTune
|
||||||
|
|
||||||
|
A music player with two modes, switched in the header:
|
||||||
|
|
||||||
|
- **Audio**: open a file or a folder and listen right away. A folder browser on the left; the
|
||||||
|
playing file's cover, details (format, codec, bitrate, sample rate, ...) and every tag on the
|
||||||
|
right. Nothing is saved: no library, no queue, no history. Files opened with SaraTune (e.g.
|
||||||
|
"Open with" in a file manager, or `Main.py song.flac`) always open here.
|
||||||
|
Keys: space play/pause, left/right seek, up/down previous/next file. The play bar can loop the
|
||||||
|
file or carry on to the next one in the folder.
|
||||||
|
- **Music**: a library of your music folders (Songs, Albums, Artists, with search), a queue with
|
||||||
|
shuffle and repeat, and saved playlists. The queue and position are kept between runs.
|
||||||
|
|
||||||
|
Each mode has its own player, so switching modes doesn't lose your place; starting one pauses
|
||||||
|
the other.
|
||||||
|
|
||||||
|
## Where things live
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
| --- | --- |
|
||||||
|
| `~/.config/SaraSuite/SaraTune/Config.toml` | settings (Settings > General / Music) |
|
||||||
|
| `~/.config/SaraSuite/SaraTune/Playlists/<Name>.toml` | one file per playlist: `Name` and `Tracks` (paths, in order) |
|
||||||
|
| `~/.config/SaraSuite/SaraTune/Session.toml` | Music mode's queue, position, shuffle and repeat |
|
||||||
|
| `~/.cache/SaraSuite/SaraTune/` | the library's tag cache and cover thumbnails (safe to delete) |
|
||||||
|
|
||||||
|
On Windows: `%APPDATA%\SaraSuite\SaraTune\` and `%LOCALAPPDATA%\SaraSuite\SaraTune\`.
|
||||||
|
|
||||||
|
Playlists can be imported from and exported to `.m3u`/`.m3u8` (the buttons in the sidebar and on a
|
||||||
|
playlist's page). Like every SaraSuite program, the theme, font and title bar come from the shared
|
||||||
|
`~/.config/SaraSuite/Config.toml` unless "uses these shared settings" is off.
|
||||||
|
|
||||||
|
## Playback
|
||||||
|
|
||||||
|
Playback goes through `flet-audio` (audioplayers), which is only in Flet's **full** desktop
|
||||||
|
client; SaraTune asks for it (`FLET_DESKTOP_FLAVOR=full`). On Linux that client links against the
|
||||||
|
system's libmpv and decodes with GStreamer:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo dnf install mpv-libs
|
||||||
|
```
|
||||||
|
|
||||||
|
Without libmpv, SaraTune shows a page saying so instead of failing to start. What plays depends on
|
||||||
|
the system's codecs (GStreamer on Linux, Media Foundation on Windows); mp3, flac, ogg, opus, wav
|
||||||
|
and m4a are tested on Fedora.
|
||||||
|
|
||||||
|
Each track gets a fresh player service, and files are passed as `file://` URIs: changing an
|
||||||
|
existing service's source only works once, and plain paths break on names with `#` or `%`
|
||||||
|
(see `Player.py`).
|
||||||
|
|
||||||
|
## Running and building
|
||||||
|
|
||||||
|
- From source: `Packaging/Launch.sh [files...]` (makes `.venv` on the first run).
|
||||||
|
- Through SaraSuite: the manifest in `Packaging/SaraSuite/` is kept in sync with
|
||||||
|
`SaraSuite/Programs/SaraTune/`.
|
||||||
|
- AppImage: `Packaging/BuildAppImage.sh` builds `dist/SaraTune-x86_64.AppImage` (same recipe as
|
||||||
|
SaraSort's and SaraFem's; needs `appimagetool`).
|
||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
"""Settings dialog: the shared SaraSuite settings (with export/import),
|
||||||
|
SaraTune's own, and Music mode's library. Laid out like SaraFem's and
|
||||||
|
SaraSuite's Settings (a category list on the left, the chosen category on the
|
||||||
|
right)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from Core import (
|
||||||
|
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home,
|
||||||
|
ImportSettings, IsWindows, LoadSharedSettings, Palette, PlaylistsDir, SharedSettings,
|
||||||
|
)
|
||||||
|
from Widgets import App
|
||||||
|
|
||||||
|
DialogWidth = 700
|
||||||
|
DialogHeight = 520
|
||||||
|
CategoryListWidth = 150
|
||||||
|
RestartNote = "Theme, font and title bar changes apply the next time SaraTune starts."
|
||||||
|
Modes = ["Music", "Audio"]
|
||||||
|
|
||||||
|
|
||||||
|
def _Note(Text: str) -> ft.Text:
|
||||||
|
return ft.Text(Text, color=Palette.Overlay0, size=12)
|
||||||
|
|
||||||
|
|
||||||
|
def _WindowsFonts() -> set[str]:
|
||||||
|
"""Font families from the system and per-user Fonts registry keys
|
||||||
|
("Arial Bold (TrueType)" -> "Arial")."""
|
||||||
|
import re
|
||||||
|
import winreg
|
||||||
|
|
||||||
|
Families: set[str] = set()
|
||||||
|
Key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"
|
||||||
|
for Root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(Root, Key) as Fonts:
|
||||||
|
Index = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
Name = winreg.EnumValue(Fonts, Index)[0]
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
Index += 1
|
||||||
|
Name = re.sub(r"\s*\(.*\)$", "", Name)
|
||||||
|
Name = re.sub(r"\s+(Bold|Italic|Light|Semibold|SemiBold|Black|Regular|Medium|Thin)\b.*$", "", Name)
|
||||||
|
if Name and "&" not in Name:
|
||||||
|
Families.add(Name.strip())
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return Families
|
||||||
|
|
||||||
|
|
||||||
|
def _Fonts() -> list[str]:
|
||||||
|
"""Installed font families (fontconfig, or the registry on Windows), with
|
||||||
|
the bundled font first."""
|
||||||
|
if IsWindows:
|
||||||
|
Families = _WindowsFonts()
|
||||||
|
Families.discard(BundledFontFamily)
|
||||||
|
return [BundledFontFamily] + sorted(Families, key=str.lower)
|
||||||
|
try:
|
||||||
|
Result = subprocess.run(
|
||||||
|
["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment(),
|
||||||
|
)
|
||||||
|
Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()}
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
Families = set()
|
||||||
|
Families.discard(BundledFontFamily)
|
||||||
|
return [BundledFontFamily] + sorted(Families, key=str.lower)
|
||||||
|
|
||||||
|
|
||||||
|
def _NumberField(Label: str, Value: int) -> ft.TextField:
|
||||||
|
return ft.TextField(label=Label, value=str(Value), keyboard_type=ft.KeyboardType.NUMBER)
|
||||||
|
|
||||||
|
|
||||||
|
def _ReadInt(Field: ft.TextField, Fallback: int, Minimum: int) -> int:
|
||||||
|
try:
|
||||||
|
return max(Minimum, int(Field.value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Fallback # keep the previous value rather than crash on bad input
|
||||||
|
|
||||||
|
|
||||||
|
def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], OnClearCache: Callable[[], None]) -> None:
|
||||||
|
"""OnSaved(FoldersChanged) runs after saving; OnClearCache empties the
|
||||||
|
library cache (Music mode rescans)."""
|
||||||
|
Page = TheApp.Page
|
||||||
|
Config: AppConfig = TheApp.Config
|
||||||
|
Themes = AvailableThemes()
|
||||||
|
Fonts = _Fonts()
|
||||||
|
|
||||||
|
def ThemePicker(Value: str) -> ft.Dropdown:
|
||||||
|
Names = Themes if Value in Themes else [Value] + Themes
|
||||||
|
return ft.Dropdown(value=Value, label="Theme", options=[ft.DropdownOption(key=Name, text=Name) for Name in Names])
|
||||||
|
|
||||||
|
def FontPicker(Value: str) -> ft.Dropdown:
|
||||||
|
# Type to filter the long list.
|
||||||
|
Names = Fonts if Value in Fonts else [Value] + Fonts
|
||||||
|
return ft.Dropdown(
|
||||||
|
value=Value, label="Font", editable=True, enable_filter=True, menu_height=320,
|
||||||
|
options=[ft.DropdownOption(key=Name, text=Name) for Name in Names],
|
||||||
|
)
|
||||||
|
|
||||||
|
# SaraSuite: ~/.config/SaraSuite/Config.toml, read by every SaraSuite program.
|
||||||
|
Shared = LoadSharedSettings(Config)
|
||||||
|
UseSharedSwitch = ft.Switch(label="SaraTune uses these shared settings", value=Config.UseSharedSettings)
|
||||||
|
SharedTheme = ThemePicker(Shared.Theme)
|
||||||
|
SharedFont = FontPicker(Shared.FontFamily)
|
||||||
|
SharedTitleBar = ft.Switch(label="Hide window title bar", value=Shared.HideWindowTitleBar)
|
||||||
|
SettingsFileField = ft.TextField(value=str(Home / "SaraTune-Settings.toml"), label="Settings file path")
|
||||||
|
|
||||||
|
# SaraTune's own appearance, only used with shared settings off.
|
||||||
|
OwnTheme = ThemePicker(Config.Theme)
|
||||||
|
OwnFont = FontPicker(Config.FontFamily)
|
||||||
|
OwnTitleBar = ft.Switch(label="Hide window title bar", value=Config.HideWindowTitleBar)
|
||||||
|
OwnAppearance = ft.Column(
|
||||||
|
[_Note("SaraTune's own appearance, used when shared settings are off"), OwnTheme, OwnFont, OwnTitleBar],
|
||||||
|
spacing=12,
|
||||||
|
disabled=Config.UseSharedSettings,
|
||||||
|
opacity=0.5 if Config.UseSharedSettings else 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def OnUseSharedChanged(e) -> None:
|
||||||
|
OwnAppearance.disabled = UseSharedSwitch.value
|
||||||
|
OwnAppearance.opacity = 0.5 if UseSharedSwitch.value else 1
|
||||||
|
OwnAppearance.update()
|
||||||
|
|
||||||
|
UseSharedSwitch.on_change = OnUseSharedChanged
|
||||||
|
|
||||||
|
StartMode = ft.Dropdown(
|
||||||
|
label="Start in", value=Config.StartMode if Config.StartMode in Modes else "Music",
|
||||||
|
options=[ft.DropdownOption(key=Name, text=f"{Name} mode") for Name in Modes],
|
||||||
|
)
|
||||||
|
SeekField = _NumberField("Arrow keys seek by (seconds, Audio mode)", Config.SeekSeconds)
|
||||||
|
AdvanceSwitch = ft.Switch(label="Audio mode plays the next file in the folder when one ends",
|
||||||
|
value=Config.AudioAutoAdvance)
|
||||||
|
|
||||||
|
# Music: one folder per line, plus a Browse button that adds a line.
|
||||||
|
FoldersField = ft.TextField(label="Music folders (one per line)", value="\n".join(Config.MusicFolders),
|
||||||
|
multiline=True, min_lines=3, max_lines=6, expand=True)
|
||||||
|
|
||||||
|
async def AddFolder(e) -> None:
|
||||||
|
Chosen = await TheApp.Picker.get_directory_path(dialog_title="Add a music folder")
|
||||||
|
if Chosen:
|
||||||
|
Lines = [Line for Line in (FoldersField.value or "").splitlines() if Line.strip()]
|
||||||
|
if Chosen not in Lines:
|
||||||
|
FoldersField.value = "\n".join(Lines + [Chosen])
|
||||||
|
FoldersField.update()
|
||||||
|
|
||||||
|
ScanSwitch = ft.Switch(label="Look for new and changed files every start", value=Config.ScanOnStart)
|
||||||
|
SessionSwitch = ft.Switch(label="Remember the queue and position between runs", value=Config.RememberSession)
|
||||||
|
|
||||||
|
def ClearCache(e) -> None:
|
||||||
|
Page.pop_dialog()
|
||||||
|
OnClearCache()
|
||||||
|
|
||||||
|
def OnExport(e) -> None:
|
||||||
|
PathText = (SettingsFileField.value or "").strip()
|
||||||
|
if not PathText:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ExportSettings(Config, Path(PathText).expanduser())
|
||||||
|
except OSError as Error:
|
||||||
|
TheApp.Notify(f"Couldn't export settings: {Error}", "error")
|
||||||
|
return
|
||||||
|
TheApp.Notify("Settings exported.", "success")
|
||||||
|
|
||||||
|
def OnImport(e) -> None:
|
||||||
|
PathText = (SettingsFileField.value or "").strip()
|
||||||
|
if not PathText:
|
||||||
|
return
|
||||||
|
OldFolders = list(Config.MusicFolders)
|
||||||
|
try:
|
||||||
|
ImportSettings(Config, Path(PathText).expanduser())
|
||||||
|
except (OSError, tomlkit.exceptions.TOMLKitError) as Error:
|
||||||
|
TheApp.Notify(f"Couldn't import settings: {Error}", "error")
|
||||||
|
return
|
||||||
|
# The open dialog still shows the pre-import values; closing it
|
||||||
|
# rather than leaving a stale Save that would overwrite the import.
|
||||||
|
Page.pop_dialog()
|
||||||
|
OnSaved(Config.MusicFolders != OldFolders)
|
||||||
|
TheApp.Notify("Settings imported. Reopen Settings to see them; restart for Theme/Font/Title bar.", "success")
|
||||||
|
|
||||||
|
Categories: dict[str, ft.Control] = {
|
||||||
|
"SaraSuite": ft.Column(
|
||||||
|
[
|
||||||
|
_Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml). "
|
||||||
|
"Other programs pick changes up the next time they start."),
|
||||||
|
UseSharedSwitch,
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
SharedTheme,
|
||||||
|
SharedFont,
|
||||||
|
SharedTitleBar,
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
_Note("Export/import settings as one file: this tab's shared settings plus everything else "
|
||||||
|
"in Settings. Same format as SaraFem's."),
|
||||||
|
SettingsFileField,
|
||||||
|
ft.Row([
|
||||||
|
ft.OutlinedButton(content="Export", style=ChipButtonStyle(), on_click=OnExport),
|
||||||
|
ft.OutlinedButton(content="Import", style=ChipButtonStyle(), on_click=OnImport),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
spacing=12,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
),
|
||||||
|
"General": ft.Column(
|
||||||
|
[
|
||||||
|
StartMode,
|
||||||
|
_Note("Opening files with SaraTune (e.g. from a file manager) always starts in Audio mode."),
|
||||||
|
SeekField,
|
||||||
|
AdvanceSwitch,
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
OwnAppearance,
|
||||||
|
],
|
||||||
|
spacing=12,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
),
|
||||||
|
"Music": ft.Column(
|
||||||
|
[
|
||||||
|
_Note("Music mode's library is every audio file below these folders."),
|
||||||
|
ft.Row([FoldersField, ft.IconButton(ft.Icons.CREATE_NEW_FOLDER_OUTLINED, tooltip="Add a folder",
|
||||||
|
on_click=AddFolder)],
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.START),
|
||||||
|
ScanSwitch,
|
||||||
|
SessionSwitch,
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
_Note("Playlists are kept in ~/.config/SaraSuite/SaraTune/Playlists/, one TOML file each. "
|
||||||
|
"The library's tags and cover thumbnails are only a cache."),
|
||||||
|
ft.Row([
|
||||||
|
ft.OutlinedButton(content="Open playlists folder", style=ChipButtonStyle(),
|
||||||
|
on_click=lambda e: (PlaylistsDir.mkdir(parents=True, exist_ok=True),
|
||||||
|
TheApp.OpenFolder(str(PlaylistsDir)))),
|
||||||
|
ft.OutlinedButton(content="Clear cache and rescan", style=ChipButtonStyle(Palette.Yellow),
|
||||||
|
on_click=ClearCache),
|
||||||
|
], wrap=True),
|
||||||
|
],
|
||||||
|
spacing=12,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
DetailArea = ft.Container(Categories["General"], expand=True, padding=ft.Padding.only(left=12))
|
||||||
|
CategoryTiles: dict[str, ft.ListTile] = {}
|
||||||
|
|
||||||
|
def SelectCategory(Name: str) -> None:
|
||||||
|
for CategoryName, Tile in CategoryTiles.items():
|
||||||
|
Tile.selected = CategoryName == Name
|
||||||
|
DetailArea.content = Categories[Name]
|
||||||
|
DetailArea.update()
|
||||||
|
CategoryList.update()
|
||||||
|
|
||||||
|
for Name in Categories:
|
||||||
|
CategoryTiles[Name] = ft.ListTile(
|
||||||
|
title=Name,
|
||||||
|
selected=Name == "General",
|
||||||
|
selected_tile_color=Palette.Surface1,
|
||||||
|
selected_color=Palette.Mauve,
|
||||||
|
on_click=lambda e, n=Name: SelectCategory(n),
|
||||||
|
)
|
||||||
|
CategoryList = ft.Column(list(CategoryTiles.values()), spacing=0, scroll=ft.ScrollMode.AUTO)
|
||||||
|
|
||||||
|
def Save(e) -> None:
|
||||||
|
OldFolders = list(Config.MusicFolders)
|
||||||
|
Config.UseSharedSettings = UseSharedSwitch.value
|
||||||
|
Config.Theme = OwnTheme.value or Config.Theme
|
||||||
|
Config.FontFamily = OwnFont.value or Config.FontFamily
|
||||||
|
Config.HideWindowTitleBar = OwnTitleBar.value
|
||||||
|
Config.StartMode = StartMode.value or Config.StartMode
|
||||||
|
Config.SeekSeconds = _ReadInt(SeekField, Config.SeekSeconds, 1)
|
||||||
|
Config.AudioAutoAdvance = AdvanceSwitch.value
|
||||||
|
Config.MusicFolders = [os.path.expanduser(Line.strip()) for Line in (FoldersField.value or "").splitlines()
|
||||||
|
if Line.strip()]
|
||||||
|
Config.ScanOnStart = ScanSwitch.value
|
||||||
|
Config.RememberSession = SessionSwitch.value
|
||||||
|
Config.Save()
|
||||||
|
SharedSettings(
|
||||||
|
Theme=SharedTheme.value,
|
||||||
|
FontFamily=SharedFont.value or Shared.FontFamily,
|
||||||
|
HideWindowTitleBar=SharedTitleBar.value,
|
||||||
|
).Save()
|
||||||
|
Page.pop_dialog()
|
||||||
|
OnSaved(Config.MusicFolders != OldFolders)
|
||||||
|
TheApp.Notify("Settings saved.", "success")
|
||||||
|
|
||||||
|
Page.show_dialog(ft.AlertDialog(
|
||||||
|
title="Settings",
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
ft.Container(
|
||||||
|
CategoryList, width=CategoryListWidth, bgcolor=Palette.Surface0, border_radius=4, padding=4,
|
||||||
|
),
|
||||||
|
DetailArea,
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||||
|
),
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
_Note(RestartNote),
|
||||||
|
],
|
||||||
|
width=DialogWidth,
|
||||||
|
height=DialogHeight,
|
||||||
|
),
|
||||||
|
actions=[
|
||||||
|
ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: Page.pop_dialog()),
|
||||||
|
ft.OutlinedButton(content="Save", style=ChipButtonStyle(), on_click=Save),
|
||||||
|
],
|
||||||
|
))
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
"""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)
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
"""What both modes share: the App context (config, notifications, dialogs,
|
||||||
|
the file picker, switching modes) and small reusable controls."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from Core import AppConfig, ChildEnvironment, ChipButton, IsWindows, Note, NotificationStyle, Palette
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
def __init__(self, Page: ft.Page, Config: AppConfig):
|
||||||
|
self.Page = Page
|
||||||
|
self.Config = Config
|
||||||
|
self.Picker = ft.FilePicker() # registers itself with the page
|
||||||
|
# Set by Main.
|
||||||
|
self.SwitchMode: Callable[[str], None] = lambda Name: None
|
||||||
|
self.OpenInAudio: Callable[[list[str]], None] = lambda Paths: None
|
||||||
|
|
||||||
|
def Notify(self, Text: str, Kind: str = "info") -> None:
|
||||||
|
Background, Accent = NotificationStyle(Kind)
|
||||||
|
self.Page.show_dialog(ft.SnackBar(
|
||||||
|
content=ft.Text(Text, color=Accent),
|
||||||
|
bgcolor=Background,
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=4, side=ft.BorderSide(1, Accent)),
|
||||||
|
open=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
def ShowDialog(self, Title: str, Content: ft.Control, Actions: list[ft.Control], Width: int = 480) -> ft.AlertDialog:
|
||||||
|
Dialog = ft.AlertDialog(title=Title, content=ft.Container(Content, width=Width), actions=Actions)
|
||||||
|
self.Page.show_dialog(Dialog)
|
||||||
|
return Dialog
|
||||||
|
|
||||||
|
def Close(self) -> None:
|
||||||
|
self.Page.pop_dialog()
|
||||||
|
|
||||||
|
def Confirm(self, Title: str, Text: str, OnYes: Callable[[], None], YesText: str = "Delete") -> None:
|
||||||
|
def Yes(e) -> None:
|
||||||
|
self.Close()
|
||||||
|
OnYes()
|
||||||
|
|
||||||
|
self.ShowDialog(Title, ft.Text(Text), [
|
||||||
|
ChipButton("Cancel", lambda e: self.Close()),
|
||||||
|
ChipButton(YesText, Yes, Accent=Palette.Red),
|
||||||
|
], Width=420)
|
||||||
|
|
||||||
|
def Prompt(self, Title: str, Label: str, OnOk: Callable[[str], None], Value: str = "", OkText: str = "OK") -> None:
|
||||||
|
"""Asks for one line of text; OnOk gets it stripped (never empty)."""
|
||||||
|
Field = ft.TextField(label=Label, value=Value, autofocus=True)
|
||||||
|
|
||||||
|
def Ok(e) -> None:
|
||||||
|
Text = (Field.value or "").strip()
|
||||||
|
if Text:
|
||||||
|
self.Close()
|
||||||
|
OnOk(Text)
|
||||||
|
|
||||||
|
Field.on_submit = Ok
|
||||||
|
self.ShowDialog(Title, Field, [ChipButton("Cancel", lambda e: self.Close()), ChipButton(OkText, Ok)], Width=380)
|
||||||
|
|
||||||
|
def OpenFolder(self, PathText: str) -> None:
|
||||||
|
try:
|
||||||
|
if IsWindows:
|
||||||
|
os.startfile(PathText) # Explorer
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["xdg-open", PathText], env=ChildEnvironment(), start_new_session=True)
|
||||||
|
except OSError as Error:
|
||||||
|
self.Notify(f"Couldn't open {PathText}: {Error}", "error")
|
||||||
|
|
||||||
|
|
||||||
|
def EmptyState(Text: str, Detail: str = "", Icon=ft.Icons.MUSIC_OFF_OUTLINED) -> ft.Control:
|
||||||
|
return ft.Container(
|
||||||
|
ft.Column([
|
||||||
|
ft.Icon(Icon, color=Palette.Surface1, size=56),
|
||||||
|
ft.Text(Text, color=Palette.Overlay0, size=16),
|
||||||
|
Note(Detail),
|
||||||
|
], horizontal_alignment=ft.CrossAxisAlignment.CENTER, tight=True, spacing=8),
|
||||||
|
alignment=ft.Alignment.CENTER, expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def InfoTable(Rows: list[tuple[str, str]], LabelWidth: int = 110) -> ft.Column:
|
||||||
|
"""Label/value rows; values are selectable so tags can be copied."""
|
||||||
|
return ft.Column([
|
||||||
|
ft.Row([
|
||||||
|
ft.Text(Label, color=Palette.Overlay0, size=13, width=LabelWidth),
|
||||||
|
ft.Text(Value, color=Palette.Text, size=13, selectable=True, expand=True),
|
||||||
|
], vertical_alignment=ft.CrossAxisAlignment.START)
|
||||||
|
for Label, Value in Rows
|
||||||
|
], spacing=4)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
flet>=1.0,<2.0
|
||||||
|
flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first launch and quits once
|
||||||
|
flet-audio>=1.0,<2.0 # playback; needs Flet's "full" desktop client (Main.py picks it)
|
||||||
|
mutagen # tags, details and cover art
|
||||||
|
pillow # cover thumbnails
|
||||||
|
tomlkit
|
||||||
Reference in new issue
Block a user