mirror of
https://git.srhtnin.garden/sarah/SaraTune.git
synced 2026-10-14 18:18:11 +02:00
828 lines
39 KiB
Python
828 lines
39 KiB
Python
"""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 RepeatModes, 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]] = {}
|
|
self.NowPlaying: Optional[Track] = None # for the system media controls
|
|
|
|
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 isinstance(Lead, ft.Container) and isinstance(Lead.data, ft.Text):
|
|
Lead = Lead.data # already showing the icon: start from the number again
|
|
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)
|
|
else:
|
|
Row.content.controls[0] = Lead
|
|
|
|
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:
|
|
self.NowPlaying = Item
|
|
self.TheApp.MediaChanged()
|
|
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.SetShuffle(not self.Queue.Shuffle)
|
|
|
|
def SetShuffle(self, Enabled: bool) -> None:
|
|
self.Queue.SetShuffle(Enabled)
|
|
self.TheApp.MediaModesChanged()
|
|
self._ShowModes()
|
|
self._Show(self.ShuffleButton)
|
|
self.SaveSession()
|
|
if self.View == "Queue":
|
|
self._Rebuild()
|
|
|
|
def _CycleRepeat(self, e) -> None:
|
|
self.SetRepeat(RepeatModes[(RepeatModes.index(self.Queue.Repeat) + 1) % len(RepeatModes)])
|
|
|
|
def SetRepeat(self, Mode: str) -> None:
|
|
"""Off, All or One."""
|
|
self.Queue.Repeat = Mode if Mode in RepeatModes else "Off"
|
|
self.TheApp.MediaModesChanged()
|
|
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.NowPlaying = None
|
|
self.TheApp.MediaChanged()
|
|
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")
|