From e6fa024f7bd8e23a57fba5cec868172092524c59 Mon Sep 17 00:00:00 2001 From: Shaowen Yin Date: Tue, 12 Nov 2024 23:26:59 +0800 Subject: [PATCH] =?UTF-8?q?gui:=20add=20menu=20action=20'=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E7=9B=B8=E4=BC=BC=E8=B5=84=E6=BA=90'=20(#876)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- feeluown/gui/components/menu.py | 35 ++++++- feeluown/gui/components/search.py | 153 ++++++++++++++++++++++++++++++ feeluown/gui/pages/search.py | 140 +-------------------------- feeluown/library/library.py | 34 ++++--- 4 files changed, 214 insertions(+), 148 deletions(-) create mode 100644 feeluown/gui/components/search.py diff --git a/feeluown/gui/components/menu.py b/feeluown/gui/components/menu.py index 8bc2516065..8455145023 100644 --- a/feeluown/gui/components/menu.py +++ b/feeluown/gui/components/menu.py @@ -1,10 +1,13 @@ import logging from typing import Optional, TYPE_CHECKING +from PyQt5.QtCore import Qt, QPoint + from feeluown.excs import ProviderIOError from feeluown.utils.aio import run_fn, run_afn from feeluown.player import SongRadio -from feeluown.library import SongModel, VideoModel +from feeluown.library import SongModel, VideoModel, SearchType +from feeluown.gui.widgets.magicbox import KeySourceIn if TYPE_CHECKING: from feeluown.app.gui_app import GuiApp @@ -55,6 +58,8 @@ async def goto_song_album(song): self._app.show_msg('该歌曲没有专辑信息') menu.hovered.connect(self.on_action_hovered) + menu.addAction('搜索相似资源').triggered.connect( + lambda: self.show_similar_resource(song)) artist_menu = menu.addMenu('查看歌手') artist_menu.menuAction().setData({'artists': None, 'song': song}) mv_menu = menu.addMenu(MV_BTN_TEXT) @@ -67,6 +72,34 @@ async def goto_song_album(song): menu.addAction('歌曲详情').triggered.connect( lambda: goto_song_explore(song)) + def show_similar_resource(self, song): + from feeluown.gui.components.search import SearchResultView + + class SearchResultViewWithEsc(SearchResultView): + def keyPressEvent(self, event): + if event.key() == Qt.Key_Escape: + self.close() + else: + super().keyPressEvent(event) + + q = f'{song.title} {song.artists_name}' + view = SearchResultViewWithEsc(self._app, parent=self._app) + source_in = self._app.browser.local_storage.get(KeySourceIn, None) + run_afn(view.search_and_render, q, SearchType.so, source_in) + + width = self._app.width() - self._app.ui.sidebar.width() + height = self._app.height() * 3 // 5 + x = self._app.ui.sidebar.width() + y = self._app.height() - height - self._app.ui.player_bar.height() + + # Set the size using resize() and position using move() + view.resize(width, height) + pos = self._app.mapToGlobal(QPoint(0, 0)) + view.move(pos.x() + x, pos.y() + y) + view.setWindowFlags(Qt.Popup) + view.show() + view.raise_() + def on_action_hovered(self, action): """ Fetch song.artists when artists_action is hovered. If it is diff --git a/feeluown/gui/components/search.py b/feeluown/gui/components/search.py new file mode 100644 index 0000000000..68c85ca867 --- /dev/null +++ b/feeluown/gui/components/search.py @@ -0,0 +1,153 @@ +from datetime import datetime + +from PyQt5.QtWidgets import QAbstractItemView, QFrame, QVBoxLayout + +from feeluown.library import SearchType +from feeluown.gui.page_containers.table import TableContainer, Renderer +from feeluown.gui.page_containers.scroll_area import ScrollArea +from feeluown.gui.widgets.img_card_list import ImgCardListDelegate +from feeluown.gui.widgets.songs import SongsTableView, ColumnsMode +from feeluown.gui.base_renderer import TabBarRendererMixin +from feeluown.gui.helpers import BgTransparentMixin +from feeluown.gui.widgets.magicbox import KeySourceIn, KeyType +from feeluown.gui.widgets.header import LargeHeader, MidHeader +from feeluown.gui.widgets.accordion import Accordion +from feeluown.gui.widgets.labels import MessageLabel +from feeluown.utils.reader import create_reader + + +Tabs = [('歌曲', SearchType.so), + ('专辑', SearchType.al), + ('歌手', SearchType.ar), + ('歌单', SearchType.pl), + ('视频', SearchType.vi)] + + +def get_tab_idx(search_type): + for i, tab in enumerate(Tabs): + if tab[1] == search_type: + return i + raise ValueError("unknown search type") + + +class SearchResultView(ScrollArea): + def __init__(self, app, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.body = Body(app) + self.setWidget(self.body) + + def fillable_bg_height(self): + """Implement VFillableBg protocol""" + return self.body.height() - self.body.accordion.height() + + async def search_and_render(self, *args, **kwargs): + await self.body.search_and_render(*args, **kwargs) + + +class Body(QFrame, BgTransparentMixin): + """ + view = SearchResultView(app, q) + await view.render() + """ + def __init__(self, app, **kwargs): + super().__init__(**kwargs) + + self._app = app + + self.title = LargeHeader() + self.hint = MessageLabel() + self.accordion = Accordion() + + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(20, 0, 20, 0) + self._layout.setSpacing(0) + self._layout.addSpacing(30) + self._layout.addWidget(self.title) + self._layout.addSpacing(10) + self._layout.addWidget(self.hint) + self._layout.addSpacing(10) + self._layout.addWidget(self.accordion) + self._layout.addStretch(0) + + async def search_and_render(self, q, search_type, source_in): + # pylint: disable=too-many-locals + view = self + app = self._app + + self.title.setText(f'搜索“{q}”') + + tab_index = get_tab_idx(search_type) + succeed = 0 + start = datetime.now() + is_first = True # Is first search result. + view.hint.show_msg('正在搜索...') + async for result in app.library.a_search( + q, type_in=search_type, source_in=source_in): + table_container = TableContainer(app, view.accordion) + table_container.layout().setContentsMargins(0, 0, 0, 0) + + # HACK: set fixed row for tables. + # pylint: disable=protected-access + for table in table_container._tables: + assert isinstance(table, QAbstractItemView) + delegate = table.itemDelegate() + if isinstance(delegate, ImgCardListDelegate): + # FIXME: set fixed_row_count in better way. + table._fixed_row_count = 2 # type: ignore[attr-defined] + delegate.update_settings("card_min_width", 140) + elif isinstance(table, SongsTableView): + table._fixed_row_count = 8 + table._row_height = table.verticalHeader().defaultSectionSize() + + renderer = SearchResultRenderer(q, tab_index, source_in=source_in) + await table_container.set_renderer(renderer) + _, search_type, attrname, show_handler = renderer.tabs[tab_index] + objects = getattr(result, attrname) or [] + if not objects: # Result is empty. + continue + + succeed += 1 + if search_type is SearchType.so: + show_handler( # type: ignore[operator] + create_reader(objects), columns_mode=ColumnsMode.playlist) + else: + show_handler(create_reader(objects)) # type: ignore[operator] + source = objects[0].source + provider = app.library.get(source) + provider_name = provider.name + if is_first is False: + table_container.hide() + view.accordion.add_section(MidHeader(provider_name), table_container, 6, 12) + renderer.meta_widget.hide() + renderer.toolbar.hide() + is_first = False + time_cost = (datetime.now() - start).total_seconds() + view.hint.show_msg(f'搜索完成,共有 {succeed} 个有效的结果,花费 {time_cost:.2f}s') + + +class SearchResultRenderer(Renderer, TabBarRendererMixin): + def __init__(self, q, tab_index, source_in=None): + self.q = q + self.tab_index = tab_index + self.source_in = source_in + + self.tabs = [ + (*Tabs[0], 'songs', self.show_songs), + (*Tabs[1], 'albums', self.show_albums), + (*Tabs[2], 'artists', self.show_artists), + (*Tabs[3], 'playlists', self.show_playlists), + (*Tabs[4], 'videos', self.show_videos), + ] + + async def render(self): + self.render_tab_bar() + + def render_by_tab_index(self, tab_index): + search_type = self.tabs[tab_index][1] + self._app.browser.local_storage[KeyType] = search_type.value + query = {'q': self.q, 'type': search_type.value} + source_in = self._app.browser.local_storage.get(KeySourceIn, None) + if source_in is not None: + query['source_in'] = source_in + self._app.browser.goto(page='/search', query=query) diff --git a/feeluown/gui/pages/search.py b/feeluown/gui/pages/search.py index 10373f3cb2..6c8e7c3052 100644 --- a/feeluown/gui/pages/search.py +++ b/feeluown/gui/pages/search.py @@ -1,33 +1,7 @@ -from datetime import datetime -from PyQt5.QtWidgets import QAbstractItemView, QFrame, QVBoxLayout - from feeluown.library import SearchType -from feeluown.gui.page_containers.table import TableContainer, Renderer -from feeluown.gui.page_containers.scroll_area import ScrollArea -from feeluown.gui.widgets.img_card_list import ImgCardListDelegate -from feeluown.gui.widgets.songs import SongsTableView, ColumnsMode -from feeluown.gui.base_renderer import TabBarRendererMixin -from feeluown.gui.helpers import BgTransparentMixin -from feeluown.gui.widgets.magicbox import KeySourceIn, KeyType -from feeluown.gui.widgets.header import LargeHeader, MidHeader -from feeluown.gui.widgets.accordion import Accordion -from feeluown.gui.widgets.labels import MessageLabel -from feeluown.utils.reader import create_reader from feeluown.utils.router import Request from feeluown.app.gui_app import GuiApp - -Tabs = [('歌曲', SearchType.so), - ('专辑', SearchType.al), - ('歌手', SearchType.ar), - ('歌单', SearchType.pl), - ('视频', SearchType.vi)] - - -def get_tab_idx(search_type): - for i, tab in enumerate(Tabs): - if tab[1] == search_type: - return i - raise ValueError("unknown search type") +from feeluown.gui.components.search import SearchResultView def get_source_in(req: Request): @@ -53,112 +27,6 @@ async def render(req: Request, **kwargs): source_in = get_source_in(req) search_type = SearchType(req.query.get('type', SearchType.so.value)) - body = Body() - view = View(app, q) - body.setWidget(view) - app.ui.right_panel.set_body(body) - - tab_index = get_tab_idx(search_type) - succeed = 0 - start = datetime.now() - is_first = True # Is first search result. - view.hint.show_msg('正在搜索...') - async for result in app.library.a_search( - q, type_in=search_type, source_in=source_in): - table_container = TableContainer(app, view.accordion) - table_container.layout().setContentsMargins(0, 0, 0, 0) - - # HACK: set fixed row for tables. - # pylint: disable=protected-access - for table in table_container._tables: - assert isinstance(table, QAbstractItemView) - delegate = table.itemDelegate() - if isinstance(delegate, ImgCardListDelegate): - # FIXME: set fixed_row_count in better way. - table._fixed_row_count = 2 # type: ignore[attr-defined] - delegate.update_settings("card_min_width", 140) - elif isinstance(table, SongsTableView): - table._fixed_row_count = 8 - table._row_height = table.verticalHeader().defaultSectionSize() - - renderer = SearchResultRenderer(q, tab_index, source_in=source_in) - await table_container.set_renderer(renderer) - _, search_type, attrname, show_handler = renderer.tabs[tab_index] - objects = getattr(result, attrname) or [] - if not objects: # Result is empty. - continue - - succeed += 1 - if search_type is SearchType.so: - show_handler( # type: ignore[operator] - create_reader(objects), columns_mode=ColumnsMode.playlist) - else: - show_handler(create_reader(objects)) # type: ignore[operator] - source = objects[0].source - provider = app.library.get(source) - provider_name = provider.name - if is_first is False: - table_container.hide() - view.accordion.add_section(MidHeader(provider_name), table_container, 6, 12) - renderer.meta_widget.hide() - renderer.toolbar.hide() - is_first = False - time_cost = (datetime.now() - start).total_seconds() - view.hint.show_msg(f'搜索完成,共有 {succeed} 个有效的结果,花费 {time_cost:.2f}s') - - -class SearchResultRenderer(Renderer, TabBarRendererMixin): - def __init__(self, q, tab_index, source_in=None): - self.q = q - self.tab_index = tab_index - self.source_in = source_in - - self.tabs = [ - (*Tabs[0], 'songs', self.show_songs), - (*Tabs[1], 'albums', self.show_albums), - (*Tabs[2], 'artists', self.show_artists), - (*Tabs[3], 'playlists', self.show_playlists), - (*Tabs[4], 'videos', self.show_videos), - ] - - async def render(self): - self.render_tab_bar() - - def render_by_tab_index(self, tab_index): - search_type = self.tabs[tab_index][1] - self._app.browser.local_storage[KeyType] = search_type.value - query = {'q': self.q, 'type': search_type.value} - source_in = self._app.browser.local_storage.get(KeySourceIn, None) - if source_in is not None: - query['source_in'] = source_in - self._app.browser.goto(page='/search', query=query) - - -class Body(ScrollArea): - def fillable_bg_height(self): - """Implement VFillableBg protocol""" - view = self.widget() - assert isinstance(view, View) # make type chckign happy. - return view.height() - view.accordion.height() - - -class View(QFrame, BgTransparentMixin): - def __init__(self, app, q): - super().__init__() - - self._app = app - - self.title = LargeHeader(f'搜索“{q}”') - self.hint = MessageLabel() - self.accordion = Accordion() - - self._layout = QVBoxLayout(self) - self._layout.setContentsMargins(20, 0, 20, 0) - self._layout.setSpacing(0) - self._layout.addSpacing(30) - self._layout.addWidget(self.title) - self._layout.addSpacing(10) - self._layout.addWidget(self.hint) - self._layout.addSpacing(10) - self._layout.addWidget(self.accordion) - self._layout.addStretch(0) + view = SearchResultView(app) + app.ui.right_panel.set_body(view) + await view.search_and_render(q, search_type, source_in) diff --git a/feeluown/library/library.py b/feeluown/library/library.py index c1ee51ff83..d1912f74c2 100644 --- a/feeluown/library/library.py +++ b/feeluown/library/library.py @@ -44,27 +44,39 @@ def default_score_fn(origin, standby): def duration_ms_to_duration(ms): if not ms: # ms is empty return 0 - m, s = ms.split(':') - return int(m) * 60 + int(s) + parts = ms.split(':') + assert len(parts) in (2, 3), f'invalid duration format: {ms}' + if len(parts) == 3: + h, m, s = parts + else: + m, s = parts + h = 0 + return int(h) * 3600 + int(m) * 60 + int(s) score = FULL_SCORE - if origin.artists_name_display != standby.artists_name_display: + if origin.artists_name != standby.artists_name: score -= 3 - if origin.title_display != standby.title_display: + if origin.title != standby.title: score -= 2 - if origin.album_name_display != standby.album_name_display: + if origin.album_name != standby.album_name: score -= 2 - origin_duration = duration_ms_to_duration(origin.duration_ms_display) - standby_duration = duration_ms_to_duration(standby.duration_ms_display) + if isinstance(origin, SongModel): + origin_duration = origin.duration + else: + origin_duration = duration_ms_to_duration(origin.duration_ms) + if isinstance(standby, SongModel): + standby_duration = standby.duration + else: + standby_duration = duration_ms_to_duration(standby.duration_ms) if abs(origin_duration - standby_duration) / max(origin_duration, 1) > 0.1: score -= 3 # Debug code for score function - # print(f"{score}\t('{standby.title_display}', " - # f"'{standby.artists_name_display}', " - # f"'{standby.album_name_display}', " - # f"'{standby.duration_ms_display}')") + # print(f"{score}\t('{standby.title}', " + # f"'{standby.artists_name}', " + # f"'{standby.album_name}', " + # f"'{standby.duration_ms}')") return score