mirror of
https://git.srhtnin.garden/sarah/SaraTune.git
synced 2026-10-13 18:18:11 +02:00
247 lines
9.3 KiB
Python
247 lines
9.3 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 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))
|