Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat](player) add new feature “自动续歌” and enhance "歌曲电台" #894

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading