Files
SaraTune/Core.py
T

350 lines
14 KiB
Python

"""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
# Show up in the system's media controls (MPRIS on Linux, the media
# overlay on Windows) and answer media keys.
MediaControls: 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(".")