Initial commit

This commit is contained in:
sarah committed 2026-10-09 15:33:43 +02:00
commit 0eeff23589
25 files changed
+3328

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
build/
.flet/
dist/
*.AppImage
+293
View File
@@ -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()
+346
View File
@@ -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(".")
+58
View File
@@ -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
View File
@@ -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())
+246
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+38
View File
@@ -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"
+5
View File
@@ -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

+48
View File
@@ -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"
+10
View File
@@ -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
View File
@@ -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")))
+211
View File
@@ -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()
+84
View File
@@ -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)
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.
+6
View File
@@ -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