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