From e8d16eec096ff101a0152c2a50342152acdd0b23 Mon Sep 17 00:00:00 2001 From: Shaowen Yin Date: Wed, 3 Jan 2024 22:08:24 +0800 Subject: [PATCH] gui: add recommendation page (#742) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. daily recommended songs 2. daily recommended playlists 3. show rank(排行榜) button (but not implemented) --- feeluown/gui/browser.py | 5 + feeluown/gui/drawers.py | 45 +++++++++ feeluown/gui/pages/recommendation.py | 98 +++++++++++++++++++ .../gui/pages/recommendation_daily_songs.py | 33 +++++++ feeluown/gui/pages/template.py | 14 +++ feeluown/gui/uimain/sidebar.py | 12 ++- feeluown/gui/widgets/__init__.py | 1 + feeluown/gui/widgets/header.py | 2 +- feeluown/gui/widgets/selfpaint_btn.py | 25 ++++- feeluown/library/provider_protocol.py | 29 +++++- 10 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 feeluown/gui/pages/recommendation.py create mode 100644 feeluown/gui/pages/recommendation_daily_songs.py create mode 100644 feeluown/gui/pages/template.py diff --git a/feeluown/gui/browser.py b/feeluown/gui/browser.py index d75f2b09a7..548f5d8a4f 100644 --- a/feeluown/gui/browser.py +++ b/feeluown/gui/browser.py @@ -193,6 +193,9 @@ def initialize(self): from feeluown.gui.pages.song_explore import render as render_song_explore from feeluown.gui.pages.provider_home import render as render_provider_home from feeluown.gui.pages.recently_played import render as render_recently_played + from feeluown.gui.pages.recommendation import render as render_rec + from feeluown.gui.pages.recommendation_daily_songs import \ + render as render_rec_daily_songs model_prefix = f'{MODEL_PAGE_PREFIX}' @@ -209,6 +212,8 @@ async def dummy_render(req, *args, **kwargs): ('/providers/', render_provider_home), ('/recently_played', render_recently_played), ('/search', render_search), + ('/rec', render_rec), + ('/rec/daily_songs', render_rec_daily_songs), ] for url, renderer in urlpatterns: self.route(url)(renderer) diff --git a/feeluown/gui/drawers.py b/feeluown/gui/drawers.py index 2336352ab2..49a448e88d 100644 --- a/feeluown/gui/drawers.py +++ b/feeluown/gui/drawers.py @@ -210,3 +210,48 @@ def paint(self, painter): painter.drawLine(self._body_bottom_left, self._body_bottom_right) painter.drawLine(self._body_top_left, self._body_bottom_left) painter.drawLine(self._body_top_right, self._body_bottom_right) + + +class CalendarIconDrawer: + def __init__(self, length, padding): + self._body_x = self._body_y = padding + self._body_width = length - 2 * padding + self._radius = 3 + self._h_line_y = self._body_y + self._body_width // 4 + + def paint(self, painter: QPainter): + pen = painter.pen() + pen.setWidthF(1.5) + painter.setPen(pen) + body_rect = QRect(self._body_x, self._body_x, self._body_width, self._body_width) + painter.drawRoundedRect(body_rect, self._radius, self._radius) + painter.drawLine(QPoint(self._body_x, self._h_line_y), + QPoint(self._body_x + self._body_width, self._h_line_y)) + + +class RankIconDrawer: + def __init__(self, length, padding): + body = length - 2*padding + body_2 = body // 2 + body_8 = body // 8 + body_3 = body // 3 + _top_right_x = length - padding + _top_right_y = padding + body_8 + _bottom_left_y = padding + body - body_8 + + self.p1 = QPoint(padding, _bottom_left_y) + self.p2 = QPoint(padding + body_3, padding + body_3) + self.p3 = QPoint(padding + body_2, padding + body_3 * 2) + self.p4 = QPoint(_top_right_x, _top_right_y) + self.p5 = QPoint(_top_right_x - body_3, _top_right_y) + self.p6 = QPoint(_top_right_x, _top_right_y + body_3) + + def paint(self, painter: QPainter): + pen = painter.pen() + pen.setWidthF(1.5) + painter.setPen(pen) + painter.drawLine(self.p1, self.p2) + painter.drawLine(self.p2, self.p3) + painter.drawLine(self.p3, self.p4) + painter.drawLine(self.p4, self.p5) + painter.drawLine(self.p4, self.p6) diff --git a/feeluown/gui/pages/recommendation.py b/feeluown/gui/pages/recommendation.py new file mode 100644 index 0000000000..3d4944f1ea --- /dev/null +++ b/feeluown/gui/pages/recommendation.py @@ -0,0 +1,98 @@ +from typing import TYPE_CHECKING + +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout + +from feeluown.utils.reader import create_reader +from feeluown.utils.aio import run_fn +from feeluown.gui.widgets.header import LargeHeader, MidHeader +from feeluown.gui.widgets.img_card_list import ( + PlaylistCardListView, PlaylistCardListModel, PlaylistFilterProxyModel, + PlaylistCardListDelegate, +) + +from feeluown.library import SupportsRecListDailyPlaylists, SupportsRecListDailySongs + +from feeluown.gui.widgets import CalendarButton, RankButton +from feeluown.gui.helpers import fetch_cover_wrapper + + +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp + + +async def render(req, **kwargs): + app: 'GuiApp' = req.ctx['app'] + + view = View(app) + app.ui.right_panel.set_body(view) + await view.render() + + +class View(QWidget): + def __init__(self, app: 'GuiApp'): + super().__init__(parent=None) + self._app = app + + self.header_title = LargeHeader() + self.header_playlist_list = MidHeader() + self.playlist_list_view = PlaylistCardListView(fixed_row_count=1) + self.playlist_list_view.setItemDelegate( + PlaylistCardListDelegate(self.playlist_list_view, + card_min_width=100,)) + self.daily_songs_btn = CalendarButton('每日推荐', parent=self) + self.rank_btn = RankButton(parent=self) + self.daily_songs_btn.setMinimumWidth(150) + self.rank_btn.setMinimumWidth(150) + + self.header_title.setText('发现音乐') + self.header_playlist_list.setText('个性化推荐') + self.rank_btn.setDisabled(True) + self.rank_btn.setToolTip('未实现,欢迎 PR!') + + self._layout = QVBoxLayout(self) + self._setup_ui() + + self.playlist_list_view.show_playlist_needed.connect( + lambda model: self._app.browser.goto(model=model)) + self.daily_songs_btn.clicked.connect( + lambda: self._app.browser.goto(page='/rec/daily_songs')) + self.rank_btn.clicked.connect( + lambda: self._app.show_msg('未实现,欢迎 PR!')) + + def _setup_ui(self): + self._h_layout = QHBoxLayout() + self._h_layout.addWidget(self.daily_songs_btn) + self._h_layout.addSpacing(10) + self._h_layout.addWidget(self.rank_btn) + self._h_layout.addStretch(0) + + self._layout.setContentsMargins(20, 10, 20, 0) + self._layout.setSpacing(0) + self._layout.addWidget(self.header_title) + self._layout.addSpacing(10) + self._layout.addLayout(self._h_layout) + self._layout.addSpacing(30) + self._layout.addWidget(self.header_playlist_list) + self._layout.addSpacing(10) + self._layout.addWidget(self.playlist_list_view) + self._layout.addStretch(0) + + async def render(self): + pvd_ui = self._app.current_pvd_ui_mgr.get() + if pvd_ui is None: + self._app.show_msg('wtf!') + return + + provider = pvd_ui.provider + if isinstance(provider, SupportsRecListDailyPlaylists): + playlists = await run_fn(provider.rec_list_daily_playlists) + model = PlaylistCardListModel( + create_reader(playlists), + fetch_cover_wrapper(self._app), + {p.identifier: p.name for p in self._app.library.list()}) + filter_model = PlaylistFilterProxyModel() + filter_model.setSourceModel(model) + self.playlist_list_view.setModel(filter_model) + + if not isinstance(provider, SupportsRecListDailySongs): + self.daily_songs_btn.setDisabled(True) diff --git a/feeluown/gui/pages/recommendation_daily_songs.py b/feeluown/gui/pages/recommendation_daily_songs.py new file mode 100644 index 0000000000..4e150e8e97 --- /dev/null +++ b/feeluown/gui/pages/recommendation_daily_songs.py @@ -0,0 +1,33 @@ +from typing import TYPE_CHECKING + +from feeluown.library import SupportsRecListDailySongs +from feeluown.gui.page_containers.table import TableContainer, Renderer +from feeluown.gui.page_containers.scroll_area import ScrollArea +from feeluown.utils.aio import run_fn +from feeluown.utils.reader import create_reader +from .template import render_error_message + + +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp + + +async def render(req, **_): + app: 'GuiApp' = req.ctx['app'] + pvd_ui = app.current_pvd_ui_mgr.get() + if pvd_ui is None: + return await render_error_message(app, '当前资源提供方未知,无法浏览该页面') + + provider = pvd_ui.provider + + scroll_area = ScrollArea() + body = TableContainer(app, scroll_area) + scroll_area.setWidget(body) + app.ui.right_panel.set_body(scroll_area) + if isinstance(provider, SupportsRecListDailySongs): + songs = await run_fn(provider.rec_list_daily_songs) + renderer = Renderer() + await body.set_renderer(renderer) + renderer.show_songs(create_reader(songs)) + renderer.meta_widget.show() + renderer.meta_widget.title = '每日推荐歌曲' diff --git a/feeluown/gui/pages/template.py b/feeluown/gui/pages/template.py new file mode 100644 index 0000000000..b01e003916 --- /dev/null +++ b/feeluown/gui/pages/template.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QLabel + +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp + + +async def render_error_message(app: 'GuiApp', msg: str): + label = QLabel(f"错误提示:{msg}") + label.setTextFormat(Qt.RichText) + label.setAlignment(Qt.AlignCenter) + app.ui.page_view.set_body(label) diff --git a/feeluown/gui/uimain/sidebar.py b/feeluown/gui/uimain/sidebar.py index ffc3da9170..e6b590b563 100644 --- a/feeluown/gui/uimain/sidebar.py +++ b/feeluown/gui/uimain/sidebar.py @@ -19,11 +19,10 @@ PlusButton, TriagleButton, ) -from feeluown.gui.provider_ui import UISupportsDiscovery + from feeluown.gui.widgets.playlists import PlaylistsView from feeluown.gui.components import CollectionListView from feeluown.gui.widgets.my_music import MyMusicView -from feeluown.gui.helpers import disconnect_slots_if_has if TYPE_CHECKING: from feeluown.app.gui_app import GuiApp @@ -156,6 +155,8 @@ def __init__(self, app: 'GuiApp', parent=None): # 让各个音乐库来决定是否显示这些组件 self.playlists_con.hide() self.my_music_con.hide() + self.discovery_btn.setDisabled(True) + self.discovery_btn.setToolTip('当前资源提供方未知') self.home_btn.clicked.connect(self.show_library) self.discovery_btn.clicked.connect(self.show_pool) @@ -170,6 +171,8 @@ def __init__(self, app: 'GuiApp', parent=None): self.playlists_con.create_btn.clicked.connect(self._create_playlist) self._app.current_pvd_ui_mgr.changed.connect( self.on_current_pvd_ui_changed) + self.discovery_btn.clicked.connect( + lambda: self._app.browser.goto(page='/rec')) def popup_collection_adding_dialog(self): dialog = QDialog(self) @@ -279,6 +282,5 @@ def do(): box.open() def on_current_pvd_ui_changed(self, pvd_ui, _): - disconnect_slots_if_has(self.discovery_btn) - if isinstance(pvd_ui, UISupportsDiscovery): - self.discovery_btn.clicked.connect(pvd_ui.discovery) + self.discovery_btn.setEnabled(True) + self.discovery_btn.setToolTip(f'点击进入 {pvd_ui.provider.name} 推荐页') diff --git a/feeluown/gui/widgets/__init__.py b/feeluown/gui/widgets/__init__.py index 437a349604..690815aecd 100644 --- a/feeluown/gui/widgets/__init__.py +++ b/feeluown/gui/widgets/__init__.py @@ -4,4 +4,5 @@ SelfPaintAbstractSquareButton, RecentlyPlayedButton, HomeButton, LeftArrowButton, RightArrowButton, SearchButton, SettingsButton, PlusButton, TriagleButton, DiscoveryButton, + SelfPaintAbstractIconTextButton, CalendarButton, RankButton, ) diff --git a/feeluown/gui/widgets/header.py b/feeluown/gui/widgets/header.py index 65a49e60cc..7a591da954 100644 --- a/feeluown/gui/widgets/header.py +++ b/feeluown/gui/widgets/header.py @@ -22,4 +22,4 @@ def __init__(self, *args, **kwargs): class MidHeader(BaseHeader): def __init__(self, *args, **kwargs): - super().__init__(15, *args, **kwargs) + super().__init__(16, *args, **kwargs) diff --git a/feeluown/gui/widgets/selfpaint_btn.py b/feeluown/gui/widgets/selfpaint_btn.py index 1bd5537024..a1b906f25d 100644 --- a/feeluown/gui/widgets/selfpaint_btn.py +++ b/feeluown/gui/widgets/selfpaint_btn.py @@ -2,7 +2,10 @@ from PyQt5.QtWidgets import QPushButton, QStyle, QStyleOptionButton from PyQt5.QtGui import QPainter, QPalette, QPainterPath -from feeluown.gui.drawers import HomeIconDrawer, PlusIconDrawer, TriangleIconDrawer +from feeluown.gui.drawers import ( + HomeIconDrawer, PlusIconDrawer, TriangleIconDrawer, CalendarIconDrawer, + RankIconDrawer, +) from feeluown.gui.helpers import darker_or_lighter @@ -275,6 +278,24 @@ def draw_icon(self, painter): self.home_icon.paint(painter) +class CalendarButton(SelfPaintAbstractIconTextButton): + def __init__(self, text='日历', *args, **kwargs): + super().__init__(text, *args, **kwargs) + self.calendar_icon = CalendarIconDrawer(self.height(), self._padding) + + def draw_icon(self, painter): + self.calendar_icon.paint(painter) + + +class RankButton(SelfPaintAbstractIconTextButton): + def __init__(self, text='排行榜', *args, **kwargs): + super().__init__(text, *args, **kwargs) + self.rank_icon = RankIconDrawer(self.height(), self._padding) + + def draw_icon(self, painter): + self.rank_icon.paint(painter) + + if __name__ == '__main__': from feeluown.gui.debug import simple_layout @@ -292,3 +313,5 @@ def draw_icon(self, painter): layout.addWidget(DiscoveryButton(height=length)) layout.addWidget(TriagleButton(length=length, direction='up')) + layout.addWidget(CalendarButton(height=length)) + layout.addWidget(RankButton(height=length)) diff --git a/feeluown/library/provider_protocol.py b/feeluown/library/provider_protocol.py index 2e61dc60d4..21c00195da 100644 --- a/feeluown/library/provider_protocol.py +++ b/feeluown/library/provider_protocol.py @@ -1,6 +1,5 @@ from typing import runtime_checkable, Protocol, List, Tuple, Optional, Dict from abc import abstractmethod - from feeluown.media import Quality, Media from .models import ( BriefCommentModel, SongModel, VideoModel, AlbumModel, ArtistModel, @@ -41,6 +40,10 @@ 'SupportsVideoGet', 'SupportsVideoMultiQuality', + + 'SupportsRecListDailySongs', + 'SupportsRecListDailyAlbums', + 'SupportsRecListDailyPlaylists', ) @@ -344,3 +347,27 @@ def get_current_user(self) -> UserModel: :raises NoUserLoggedIn: there is no logged in user. """ + + +# +# Protocols for recommendation. +# +@runtime_checkable +class SupportsRecListDailySongs(Protocol): + @abstractmethod + def rec_list_daily_songs(self) -> List[SongModel]: + pass + + +@runtime_checkable +class SupportsRecListDailyPlaylists(Protocol): + @abstractmethod + def rec_list_daily_playlists(self) -> List[PlaylistModel]: + pass + + +@runtime_checkable +class SupportsRecListDailyAlbums(Protocol): + @abstractmethod + def rec_list_daily_albums(self) -> List[AlbumModel]: + pass