"""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 MediaControls import Remote 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 Quit() -> None: """The window's close button and the media controls' Quit. Only once: destroying the window sends another close event.""" if State.get("Quitting"): return State["Quitting"] = True ModeViews["Music"].SaveSession() Config.Save() # the volume await Controls.Stop() await Page.window.destroy() async def OnWindowEvent(e: ft.WindowEvent) -> None: if e.type == ft.WindowEventType.CLOSE: await Quit() 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") # The system's media controls follow whichever mode played last. Controls = Remote(Page, Players, ModeViews, State["Mode"], SetVolume, Quit) if Config.MediaControls: TheApp.MediaChanged = Controls.MediaChanged TheApp.MediaModesChanged = Controls.ModesChanged Page.run_task(Controls.Start) 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))