Files
SaraTune/MusicMode.py
T
2026-10-09 15:33:43 +02:00

812 lines
38 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 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")