commit 0eeff235893cac1851e2d18432a01d0c637307a5 Author: SRHTNIN Date: Fri Oct 9 15:33:43 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b48d2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +build/ +.flet/ +dist/ +*.AppImage diff --git a/AudioMode.py b/AudioMode.py new file mode 100644 index 0000000..3f78486 --- /dev/null +++ b/AudioMode.py @@ -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() diff --git a/Core.py b/Core.py new file mode 100644 index 0000000..5634775 --- /dev/null +++ b/Core.py @@ -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 .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/.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(".") diff --git a/Covers.py b/Covers.py new file mode 100644 index 0000000..6827b05 --- /dev/null +++ b/Covers.py @@ -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 diff --git a/Library.py b/Library.py new file mode 100644 index 0000000..12514df --- /dev/null +++ b/Library.py @@ -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()) diff --git a/Main.py b/Main.py new file mode 100644 index 0000000..e7fba7a --- /dev/null +++ b/Main.py @@ -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)) diff --git a/Metadata.py b/Metadata.py new file mode 100644 index 0000000..129ef31 --- /dev/null +++ b/Metadata.py @@ -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" diff --git a/MusicMode.py b/MusicMode.py new file mode 100644 index 0000000..a66bfad --- /dev/null +++ b/MusicMode.py @@ -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") diff --git a/Packaging/BuildAppImage.sh b/Packaging/BuildAppImage.sh new file mode 100755 index 0000000..53bc75b --- /dev/null +++ b/Packaging/BuildAppImage.sh @@ -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" diff --git a/Packaging/Launch.sh b/Packaging/Launch.sh new file mode 100755 index 0000000..f81f2a1 --- /dev/null +++ b/Packaging/Launch.sh @@ -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 "$@" diff --git a/Packaging/SaraSuite/Icon.png b/Packaging/SaraSuite/Icon.png new file mode 100644 index 0000000..f4b9eb9 Binary files /dev/null and b/Packaging/SaraSuite/Icon.png differ diff --git a/Packaging/SaraSuite/Manifest.toml b/Packaging/SaraSuite/Manifest.toml new file mode 100644 index 0000000..b453311 --- /dev/null +++ b/Packaging/SaraSuite/Manifest.toml @@ -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" diff --git a/Packaging/SaraSuite/Templates/Launch.sh b/Packaging/SaraSuite/Templates/Launch.sh new file mode 100644 index 0000000..d8142dc --- /dev/null +++ b/Packaging/SaraSuite/Templates/Launch.sh @@ -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 "$@" diff --git a/Packaging/SaraSuite/Templates/SaraTune.desktop b/Packaging/SaraSuite/Templates/SaraTune.desktop new file mode 100644 index 0000000..0824605 --- /dev/null +++ b/Packaging/SaraSuite/Templates/SaraTune.desktop @@ -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} diff --git a/PlayQueue.py b/PlayQueue.py new file mode 100644 index 0000000..fe0da6e --- /dev/null +++ b/PlayQueue.py @@ -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"))) diff --git a/Player.py b/Player.py new file mode 100644 index 0000000..9773593 --- /dev/null +++ b/Player.py @@ -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() diff --git a/Playlists.py b/Playlists.py new file mode 100644 index 0000000..d7ebf09 --- /dev/null +++ b/Playlists.py @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5abf2b --- /dev/null +++ b/README.md @@ -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/.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`). diff --git a/Settings.py b/Settings.py new file mode 100644 index 0000000..af57821 --- /dev/null +++ b/Settings.py @@ -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), + ], + )) diff --git a/Transport.py b/Transport.py new file mode 100644 index 0000000..0fb614a --- /dev/null +++ b/Transport.py @@ -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) diff --git a/Widgets.py b/Widgets.py new file mode 100644 index 0000000..ea4a8ff --- /dev/null +++ b/Widgets.py @@ -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) diff --git a/assets/Icon.ico b/assets/Icon.ico new file mode 100644 index 0000000..0343f8c Binary files /dev/null and b/assets/Icon.ico differ diff --git a/assets/Icon.png b/assets/Icon.png new file mode 100644 index 0000000..2ccd352 Binary files /dev/null and b/assets/Icon.png differ diff --git a/assets/fonts/Fixedsys_Excelsior_Regular.ttf b/assets/fonts/Fixedsys_Excelsior_Regular.ttf new file mode 100644 index 0000000..a3dbddd Binary files /dev/null and b/assets/fonts/Fixedsys_Excelsior_Regular.ttf differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..555dfca --- /dev/null +++ b/requirements.txt @@ -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