Files

264 lines
10 KiB
Python

"""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))