From 857fefb173cb6a9c46538fe952297ef604299e0a Mon Sep 17 00:00:00 2001 From: cosven Date: Wed, 18 Dec 2024 10:01:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=BB=AD=E6=AD=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- feeluown/gui/components/player_playlist.py | 15 +++++- feeluown/gui/uimain/playlist_overlay.py | 28 ++++++++-- feeluown/player/__init__.py | 3 +- feeluown/player/fm.py | 25 ++++----- feeluown/player/playlist.py | 5 ++ feeluown/player/radio.py | 60 ++++++++++++++++------ tests/player/test_playlist.py | 2 + 7 files changed, 101 insertions(+), 37 deletions(-) diff --git a/feeluown/gui/components/player_playlist.py b/feeluown/gui/components/player_playlist.py index 90b8fa0439..d4f83dd9a0 100644 --- a/feeluown/gui/components/player_playlist.py +++ b/feeluown/gui/components/player_playlist.py @@ -1,6 +1,9 @@ +from typing import TYPE_CHECKING + from PyQt5.QtCore import Qt, QModelIndex, QItemSelectionModel from PyQt5.QtWidgets import QMenu, QAbstractItemView +from feeluown.player import PlaylistMode from feeluown.gui.components import SongMenuInitializer from feeluown.gui.helpers import fetch_cover_wrapper from feeluown.gui.widgets.song_minicard_list import ( @@ -10,6 +13,10 @@ from feeluown.utils.reader import create_reader +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp + + class PlayerPlaylistModel(SongMiniCardListModel): """ this is a singleton class (ensured by PlayerPlaylistView) @@ -51,7 +58,7 @@ class PlayerPlaylistView(SongMiniCardListView): _model = None - def __init__(self, app, *args, **kwargs): + def __init__(self, app: 'GuiApp', *args, **kwargs): super().__init__(*args, **kwargs) self._app = app @@ -70,7 +77,11 @@ def contextMenuEvent(self, e): songs = [index.data(Qt.UserRole)[0] for index in indexes] menu = QMenu() - action = menu.addAction('从播放队列中移除') + if self._app.playlist.mode is PlaylistMode.fm: + btn_text = '不想听' + else: + btn_text = '从播放队列中移除' + action = menu.addAction(btn_text) action.triggered.connect(lambda: self._remove_songs(songs)) if len(songs) == 1: menu.addSeparator() diff --git a/feeluown/gui/uimain/playlist_overlay.py b/feeluown/gui/uimain/playlist_overlay.py index 128ff54fee..b7ac7fffa0 100644 --- a/feeluown/gui/uimain/playlist_overlay.py +++ b/feeluown/gui/uimain/playlist_overlay.py @@ -7,7 +7,7 @@ QColor, QLinearGradient, QPalette, QPainter, ) -from feeluown.player import PlaybackMode +from feeluown.player import PlaybackMode, SongsRadio from feeluown.gui.helpers import fetch_cover_wrapper, esc_hide_widget from feeluown.gui.components.player_playlist import PlayerPlaylistView from feeluown.gui.widgets.textbtn import TextButton @@ -45,10 +45,14 @@ def __init__(self, app, *args, **kwargs): self._clear_playlist_btn = TextButton('清空播放队列') self._playback_mode_switch = PlaybackModeSwitch(app) self._goto_current_song_btn = TextButton('跳转到当前歌曲') + self._songs_radio_btn = TextButton('自动续歌') # Please update the list when you add new buttons. - self._btns = [self._clear_playlist_btn, - self._playback_mode_switch, - self._goto_current_song_btn] + self._btns = [ + self._clear_playlist_btn, + self._playback_mode_switch, + self._goto_current_song_btn, + self._songs_radio_btn, + ] self._stacked_layout = QStackedLayout() self._shadow_width = 15 self._view_options = dict(row_height=60, no_scroll_v=False) @@ -60,6 +64,7 @@ def __init__(self, app, *args, **kwargs): self._clear_playlist_btn.clicked.connect(self._app.playlist.clear) self._goto_current_song_btn.clicked.connect(self.goto_current_song) + self._songs_radio_btn.clicked.connect(self.enter_songs_radio) esc_hide_widget(self) q_app = QApplication.instance() assert q_app is not None # make type checker happy. @@ -72,22 +77,28 @@ def __init__(self, app, *args, **kwargs): def setup_ui(self): self._layout = QVBoxLayout(self) self._btn_layout = QHBoxLayout() + self._btn_layout2 = QHBoxLayout() self._layout.setContentsMargins(self._shadow_width, 0, 0, 0) self._layout.setSpacing(0) self._btn_layout.setContentsMargins(7, 7, 7, 7) self._btn_layout.setSpacing(7) + self._btn_layout2.setContentsMargins(7, 0, 7, 7) + self._btn_layout2.setSpacing(7) self._tabbar.setDocumentMode(True) self._tabbar.addTab('播放列表') self._tabbar.addTab('最近播放') self._layout.addWidget(self._tabbar) self._layout.addLayout(self._btn_layout) + self._layout.addLayout(self._btn_layout2) self._layout.addLayout(self._stacked_layout) self._btn_layout.addWidget(self._clear_playlist_btn) self._btn_layout.addWidget(self._playback_mode_switch) self._btn_layout.addWidget(self._goto_current_song_btn) + self._btn_layout2.addWidget(self._songs_radio_btn) self._btn_layout.addStretch(0) + self._btn_layout2.addStretch(0) def on_focus_changed(self, _, new): """ @@ -105,6 +116,15 @@ def goto_current_song(self): assert isinstance(view, PlayerPlaylistView) view.scroll_to_current_song() + def enter_songs_radio(self): + songs = self._app.playlist.list() + if not songs: + self._app.show_msg('播放队列为空,不能激活“自动续歌”功能') + else: + radio = SongsRadio(self._app, songs) + self._app.fm.activate(radio.fetch_songs_func, reset=False) + self._app.show_msg('“自动续歌”功能已激活') + def show_tab(self, index): if not self.isVisible(): return diff --git a/feeluown/player/__init__.py b/feeluown/player/__init__.py index b3e51d3b1c..90ed4a4b45 100644 --- a/feeluown/player/__init__.py +++ b/feeluown/player/__init__.py @@ -6,7 +6,7 @@ from .playlist import PlaylistMode, Playlist from .metadata_assembler import MetadataAssembler from .fm import FM -from .radio import SongRadio +from .radio import SongRadio, SongsRadio from .lyric import LiveLyric, parse_lyric_text, Line as LyricLine, Lyric from .recently_played import RecentlyPlayed from .delegate import PlayerPositionDelegate @@ -22,6 +22,7 @@ 'FM', 'PlaylistMode', 'SongRadio', + 'SongsRadio', 'Player', 'Playlist', diff --git a/feeluown/player/fm.py b/feeluown/player/fm.py index d7b7239bf8..5f888e8e38 100644 --- a/feeluown/player/fm.py +++ b/feeluown/player/fm.py @@ -1,10 +1,14 @@ +from typing import TYPE_CHECKING + import asyncio import logging -from collections import deque from feeluown.excs import ProviderIOError from feeluown.player import PlaylistMode +if TYPE_CHECKING: + from feeluown.app import App + logger = logging.getLogger(__name__) @@ -23,18 +27,16 @@ class FM: maybe a bit confusing. """ - def __init__(self, app): + def __init__(self, app: 'App'): """ :type app: feeluown.app.App """ self._app = app - # store songs that are going to be added to playlist - self._queue = deque() self._activated = False self._is_fetching_songs = False self._fetch_songs_task_name = 'fm-fetch-songs' - self._fetch_songs_func = None + self._fetch_songs_func = None # fn(number_to_fetch) self._minimum_per_fetch = 3 self._app.playlist.mode_changed.connect(self._on_playlist_mode_changed) @@ -78,10 +80,6 @@ def is_active(self): return self._app.playlist.mode is PlaylistMode.fm def _on_playlist_eof_reached(self): - if self._queue: - self._feed_playlist() - return - if self._is_fetching_songs: return @@ -102,9 +100,8 @@ def _on_playlist_fm_mode_exited(self): self._fetch_songs_func = None logger.info('fm mode deactivated') - def _feed_playlist(self): - while self._queue: - song = self._queue.popleft() + def _feed_playlist(self, songs): + for song in songs: self._app.playlist.fm_add(song) self._app.playlist.next() @@ -120,8 +117,6 @@ def _on_songs_fetched(self, future): logger.info('No enough songs, exit fm mode now') self.deactivate() else: - for song in songs: - self._queue.append(song) - self._feed_playlist() + self._feed_playlist(songs) finally: self._is_fetching_songs = False diff --git a/feeluown/player/playlist.py b/feeluown/player/playlist.py index f03e0ae53b..a9dd61ee5a 100644 --- a/feeluown/player/playlist.py +++ b/feeluown/player/playlist.py @@ -104,6 +104,8 @@ def __init__(self, app: 'App', songs=None, playback_mode=PlaybackMode.loop, #: playlist mode changed signal self.mode_changed = Signal() + #: playback mode before changed to fm mode + self._normal_mode_playback_mode = playback_mode #: store value for ``current_song`` property self._current_song = None @@ -161,7 +163,10 @@ def mode(self, mode): """set playlist mode""" if self._mode is not mode: if mode is PlaylistMode.fm: + self._normal_mode_playback_mode = self.playback_mode self.playback_mode = PlaybackMode.sequential + else: + self.playback_mode = self._normal_mode_playback_mode # we should change _mode at the very end self._mode = mode self.mode_changed.emit(mode) diff --git a/feeluown/player/radio.py b/feeluown/player/radio.py index fdaed35c0f..a3e5d7469f 100644 --- a/feeluown/player/radio.py +++ b/feeluown/player/radio.py @@ -1,25 +1,23 @@ from collections import deque +from typing import TYPE_CHECKING, List -from feeluown.library import SupportsSongSimilar +from feeluown.library import SupportsSongSimilar, BriefSongModel + +if TYPE_CHECKING: + from feeluown.app import App def calc_song_similarity(base, song): return 10 -class SongRadio: - def __init__(self, app, song): +class Radio: + def __init__(self, app: 'App', songs: List[BriefSongModel]): self._app = app - self.root_song = song - self._stack = deque([song]) - self._songs_set = set({}) - - @classmethod - def create(cls, app, song): - provider = app.library.get(song.source) - if provider is not None and isinstance(provider, SupportsSongSimilar): - return cls(app, song) - raise ValueError('the provider must support list similar song') + self._stack = deque(songs) + # B is a similar song of A. Also, A may be a similar song of B. + # The songs_set store all songs to avoid fetching duplicate songs. + self._songs_set = set(songs) def fetch_songs_func(self, number): """implement fm.fetch_songs_func @@ -40,9 +38,13 @@ def fetch_songs_func(self, number): if not self._stack: break song = self._stack.popleft() + # User can mark a song as 'dislike' by removing it from playlist. + if song not in self._app.playlist.list(): + continue provider = self._app.library.get(song.source) - # Provider is ensure to SupportsSongsimilar during creating. - assert isinstance(provider, SupportsSongSimilar) + # Provider is ensured to SupportsSongsimilar during creating. + if not isinstance(provider, SupportsSongSimilar): + continue songs = provider.song_list_similar(song) for song in songs: if song not in self._songs_set: @@ -53,3 +55,31 @@ def fetch_songs_func(self, number): self._stack.append(song) self._songs_set.add(song) return valid_songs + + +class SongRadio: + """SongRadio recommend songs based on a song.""" + + def __init__(self, app: 'App', song): + self._app = app + self.root_song = song + self._radio = Radio(app, [song]) + + @classmethod + def create(cls, app, song): + provider = app.library.get(song.source) + if provider is not None and isinstance(provider, SupportsSongSimilar): + return cls(app, song) + raise ValueError('the provider must support list similar song') + + def fetch_songs_func(self, number): + return self._radio.fetch_songs_func(number) + + +class SongsRadio: + def __init__(self, app: 'App', songs: List[BriefSongModel]): + self._app = app + self._radio = Radio(self._app, songs) + + def fetch_songs_func(self, number): + return self._radio.fetch_songs_func(number) diff --git a/tests/player/test_playlist.py b/tests/player/test_playlist.py index 29f1cd03f0..f320a63ff0 100644 --- a/tests/player/test_playlist.py +++ b/tests/player/test_playlist.py @@ -190,11 +190,13 @@ async def test_playlist_change_mode(app_mock, mocker): # from normal to fm pl = Playlist(app_mock) pl.mode = PlaylistMode.fm + old_playback_mode = pl.playback_mode assert pl.playback_mode is PlaybackMode.sequential # from fm to normal pl.mode = PlaylistMode.normal assert pl.mode is PlaylistMode.normal + assert pl.playback_mode == old_playback_mode @pytest.mark.asyncio