diff --git a/feeluown/gui/components/menu.py b/feeluown/gui/components/menu.py index 8bc251606..7582d642e 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 @@ -66,6 +69,29 @@ async def goto_song_album(song): lambda: enter_song_radio(song)) menu.addAction('歌曲详情').triggered.connect( lambda: goto_song_explore(song)) + menu.addAction('搜索相似资源').triggered.connect( + lambda: self.show_similar_resource(song)) + + def show_similar_resource(self, song): + from feeluown.gui.components.search import SearchResultView + + q = f'{song.title} {song.artists_name}' + view = SearchResultView(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): """ diff --git a/feeluown/gui/components/search.py b/feeluown/gui/components/search.py new file mode 100644 index 000000000..68c85ca86 --- /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 10373f3cb..6c8e7c305 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/gui/ui.py b/feeluown/gui/ui.py index e86cc9758..75bbcd956 100644 --- a/feeluown/gui/ui.py +++ b/feeluown/gui/ui.py @@ -53,7 +53,6 @@ def __init__(self, app): self.forward_btn = self.bottom_panel.forward_btn self.toolbar.settings_btn.clicked.connect(self._open_settings_dialog) - self.songs_table.about_to_show_menu.connect(self._open_standby_list_overlay) self._setup_ui() @@ -86,20 +85,6 @@ def _open_settings_dialog(self): dialog = SettingsDialog(self._app, self._app) dialog.exec() - def _open_standby_list_overlay(self, ctx): - from feeluown.gui.uimain.standby import StandbyListOverlay - - models = ctx['models'] - add_action = ctx['add_action'] - song = models[0] - - def callback(_): - overlay = StandbyListOverlay(self._app) - overlay.show_song(song) - overlay.exec() - - add_action('寻找可播放资源', callback) - def toggle_player_bar(self): if self.top_panel.isVisible(): self.top_panel.hide() diff --git a/feeluown/gui/uimain/standby.py b/feeluown/gui/uimain/standby.py deleted file mode 100644 index fe3ba456a..000000000 --- a/feeluown/gui/uimain/standby.py +++ /dev/null @@ -1,63 +0,0 @@ -from typing import TYPE_CHECKING - -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QVBoxLayout, QWidget, QDialog - -from feeluown.utils.aio import run_afn -from feeluown.gui.helpers import fetch_cover_wrapper -from feeluown.gui.widgets.song_minicard_list import ( - SongMiniCardListView, - SongMiniCardListModel, - SongMiniCardListDelegate, -) -from feeluown.utils.reader import create_reader - - -if TYPE_CHECKING: - from feeluown.app.gui_app import GuiApp - - -class StandbyListOverlay(QDialog): - def __init__(self, app: 'GuiApp', *args, **kwargs): - super().__init__(parent=app, *args, **kwargs) - - self._app = app - self.setAttribute(Qt.WA_DeleteOnClose); - - self._layout = QVBoxLayout(self) - - view_options = dict(row_height=60, no_scroll_v=False) - view = SongMiniCardListView(**view_options) - view.play_song_needed.connect(self._app.playlist.play_model) - delegate = SongMiniCardListDelegate( - view, - card_min_width=self.width() - self.width()//6, - card_height=40, - card_padding=(5 + SongMiniCardListDelegate.img_padding, 5, 0, 5), - card_right_spacing=10, - ) - view.setItemDelegate(delegate) - self._view = view - - self.setup_ui() - - def setup_ui(self): - self._layout.addWidget(self._view) - - def show_song(self, song): - - async def impl(): - results = await self._app.library.a_list_song_standby_v2(song) - songs = [p[0] for p in results] - self.show_songs(songs) - - self._app.task_mgr.run_afn_preemptive(impl, name='standby-list-overlay-show-song') - - def show_songs(self, songs): - """ - """ - model = SongMiniCardListModel( - create_reader(songs), - fetch_cover_wrapper(self._app) - ) - self._view.setModel(model) diff --git a/feeluown/library/library.py b/feeluown/library/library.py index c1ee51ff8..d1912f74c 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