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