Skip to content

Commit

Permalink
自动续歌
Browse files Browse the repository at this point in the history
  • Loading branch information
cosven committed Dec 18, 2024
1 parent 5cf00cc commit 857fefb
Show file tree
Hide file tree
Showing 7 changed files with 101 additions and 37 deletions.
15 changes: 13 additions & 2 deletions feeluown/gui/components/player_playlist.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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()
Expand Down
28 changes: 24 additions & 4 deletions feeluown/gui/uimain/playlist_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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):
"""
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion feeluown/player/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +22,7 @@
'FM',
'PlaylistMode',
'SongRadio',
'SongsRadio',

'Player',
'Playlist',
Expand Down
25 changes: 10 additions & 15 deletions feeluown/player/fm.py
Original file line number Diff line number Diff line change
@@ -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__)


Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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()

Expand All @@ -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
5 changes: 5 additions & 0 deletions feeluown/player/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
60 changes: 45 additions & 15 deletions feeluown/player/radio.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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)
2 changes: 2 additions & 0 deletions tests/player/test_playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 857fefb

Please sign in to comment.