From fa30b3f15e2bc5e80142dd8f0b563ed64326cdd0 Mon Sep 17 00:00:00 2001 From: Shaowen Yin Date: Sun, 10 Dec 2023 22:57:52 +0800 Subject: [PATCH] player: some improvements about the player (#739) 1. stop the player immediately when user plays a song, so that user knows the action is handling 2. remove some misleading logs from the playlist module 3. fill the cover_label with random color when cover is not available --- feeluown/gui/components/line_song.py | 14 ++++++-- feeluown/gui/drawers.py | 54 ++++++++++++++++++++-------- feeluown/gui/helpers.py | 4 +++ feeluown/gui/uimain/player_bar.py | 12 ++++--- feeluown/gui/widgets/cover_label.py | 21 ++++++----- feeluown/library/models.py | 3 ++ feeluown/player/playlist.py | 36 ++++++++++++++----- tests/player/test_playlist.py | 1 + 8 files changed, 105 insertions(+), 40 deletions(-) diff --git a/feeluown/gui/components/line_song.py b/feeluown/gui/components/line_song.py index c88185ecd9..a551298774 100644 --- a/feeluown/gui/components/line_song.py +++ b/feeluown/gui/components/line_song.py @@ -1,16 +1,21 @@ +from typing import TYPE_CHECKING + from PyQt5.QtCore import QTimer, QRect, Qt from PyQt5.QtGui import QFontMetrics, QPainter, QPalette from PyQt5.QtWidgets import QApplication, QLabel, QSizePolicy, QMenu from feeluown.gui.components import SongMenuInitializer +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp + class LineSongLabel(QLabel): """Show song info in one line (with limited width).""" default_text = '...' - def __init__(self, app, parent=None): + def __init__(self, app: 'GuiApp', parent=None): super().__init__(text=self.default_text, parent=parent) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) @@ -28,10 +33,12 @@ def __init__(self, app, parent=None): self._app.player.metadata_changed.connect( self.on_metadata_changed, aioqueue=True) + self._app.playlist.play_model_handling.connect( + self.on_play_model_handling, aioqueue=True) def on_metadata_changed(self, metadata): if not metadata: - self.setText('') + self.setText('...') return # Set main text. @@ -43,6 +50,9 @@ def on_metadata_changed(self, metadata): text += f" - {','.join(artists)}" self.setText(text) + def on_play_model_handling(self): + self.setText('正在加载歌曲...') + def change_text_position(self): if not self.parent().isVisible(): # type: ignore self._timer.stop() diff --git a/feeluown/gui/drawers.py b/feeluown/gui/drawers.py index eaac6a5ff1..2336352ab2 100644 --- a/feeluown/gui/drawers.py +++ b/feeluown/gui/drawers.py @@ -1,7 +1,11 @@ +from typing import Optional + from PyQt5.QtCore import Qt, QRect, QPoint, QPointF from PyQt5.QtGui import QPainter, QBrush, QPixmap, QImage, QColor, QPolygonF from PyQt5.QtWidgets import QWidget +from feeluown.gui.helpers import random_solarized_color + class PixmapDrawer: """Draw pixmap on a widget with radius. @@ -12,25 +16,30 @@ def __init__(self, img, widget: QWidget, radius: int = 0): """ :param widget: a object which has width() and height() method. """ - self._img = img self._widget_last_width = widget.width() - - new_img = img.scaledToWidth(self._widget_last_width, Qt.SmoothTransformation) - self._pixmap = QPixmap(new_img) - self._widget = widget self._radius = radius + if img is None: + self._color = random_solarized_color() + self._img = None + self._pixmap = None + else: + self._img = img + self._color = None + new_img = img.scaledToWidth(self._widget_last_width, Qt.SmoothTransformation) + self._pixmap = QPixmap(new_img) + @classmethod def from_img_data(cls, img_data, *args, **kwargs): img = QImage() img.loadFromData(img_data) return cls(img, *args, **kwargs) - def get_img(self) -> QImage: + def get_img(self) -> Optional[QImage]: return self._img - def get_pixmap(self) -> QPixmap: + def get_pixmap(self) -> Optional[QPixmap]: return self._pixmap def maybe_update_pixmap(self): @@ -41,21 +50,39 @@ def maybe_update_pixmap(self): self._pixmap = QPixmap(new_img) def draw(self, painter): - if self._pixmap is None: - return - - self.maybe_update_pixmap() - painter.save() painter.setRenderHint(QPainter.Antialiasing) painter.setRenderHint(QPainter.SmoothPixmapTransform) + if self._pixmap is None: + self._draw_random_color(painter) + else: + self._draw_pixmap(painter) + painter.restore() + + def _get_radius(self): + return self._radius if self._radius >= 1 else self._widget.width() * self._radius + + def _draw_random_color(self, painter: QPainter): + brush = QBrush(self._color) + painter.setBrush(brush) + painter.setPen(Qt.NoPen) + rect = self._widget.rect() + if self._radius == 0: + painter.drawRect(rect) + else: + radius = self._get_radius() + painter.drawRoundedRect(rect, radius, radius) + + def _draw_pixmap(self, painter: QPainter): + assert self._pixmap is not None + + self.maybe_update_pixmap() brush = QBrush(self._pixmap) painter.setBrush(brush) painter.setPen(Qt.NoPen) radius = self._radius size = self._pixmap.size() y = (size.height() - self._widget.height()) // 2 - painter.save() painter.translate(0, -y) rect = QRect(0, y, self._widget.width(), self._widget.height()) @@ -65,7 +92,6 @@ def draw(self, painter): radius = radius if self._radius >= 1 else self._widget.width() * self._radius painter.drawRoundedRect(rect, radius, radius) painter.restore() - painter.restore() class AvatarIconDrawer: diff --git a/feeluown/gui/helpers.py b/feeluown/gui/helpers.py index 64af99f014..db35186380 100644 --- a/feeluown/gui/helpers.py +++ b/feeluown/gui/helpers.py @@ -553,6 +553,10 @@ async def fetch_model_cover(model, cb): return fetch_model_cover +def random_solarized_color(): + return QColor(random.choice(list(SOLARIZED_COLORS.values()))) + + # https://ethanschoonover.com/solarized/ SOLARIZED_COLORS = { 'yellow': '#b58900', diff --git a/feeluown/gui/uimain/player_bar.py b/feeluown/gui/uimain/player_bar.py index 4c5eda1871..0200f45580 100644 --- a/feeluown/gui/uimain/player_bar.py +++ b/feeluown/gui/uimain/player_bar.py @@ -1,4 +1,5 @@ import logging +from typing import TYPE_CHECKING from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QPushButton, QSizePolicy @@ -15,12 +16,15 @@ ) from feeluown.gui.helpers import IS_MACOS +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp + logger = logging.getLogger(__name__) class PlayerControlPanel(QFrame): - def __init__(self, app, parent=None): + def __init__(self, app: 'GuiApp', parent=None): super().__init__(parent) self._app = app @@ -167,9 +171,7 @@ def _setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) def on_metadata_changed(self, metadata): - if not metadata: - return - + metadata = metadata or {} released = metadata.get('released', '') if released: self.cover_label.setToolTip(f'专辑发行日期:{released}') @@ -180,6 +182,8 @@ def on_metadata_changed(self, metadata): artwork_uid = metadata.get('uri', artwork) if artwork: run_afn(self.cover_label.show_cover, artwork, artwork_uid) + else: + self.cover_label.show_img(None) class TopPanel(QFrame): diff --git a/feeluown/gui/widgets/cover_label.py b/feeluown/gui/widgets/cover_label.py index 9de7bef403..a13c49fbf4 100644 --- a/feeluown/gui/widgets/cover_label.py +++ b/feeluown/gui/widgets/cover_label.py @@ -1,4 +1,5 @@ import warnings +from typing import Optional from PyQt5.QtCore import QSize from PyQt5.QtGui import QPainter, QImage @@ -13,7 +14,7 @@ def __init__(self, parent=None, pixmap=None, radius=3): super().__init__(parent=parent) self._radius = radius - self.drawer = None + self.drawer = PixmapDrawer(None, self, self._radius) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.MinimumExpanding) def show_pixmap(self, pixmap): @@ -24,12 +25,11 @@ def show_pixmap(self, pixmap): self.updateGeometry() self.update() # Schedule a repaint to refresh the UI ASAP. - def show_img(self, img: QImage): + def show_img(self, img: Optional[QImage]): if not img or img.isNull(): - self.drawer = None - return - - self.drawer = PixmapDrawer(img, self, self._radius) + self.drawer = PixmapDrawer(None, self, self._radius) + else: + self.drawer = PixmapDrawer(img, self, self._radius) self.updateGeometry() self.update() @@ -41,12 +41,11 @@ def paintEvent(self, e): one is as follow, the other way is using bitmap mask, but in our practice, the mask way has poor render effects """ - if self.drawer: - painter = QPainter(self) - self.drawer.draw(painter) + painter = QPainter(self) + self.drawer.draw(painter) def contextMenuEvent(self, e): - if self.drawer is None: + if self.drawer.get_img() is None: return menu = QMenu() action = menu.addAction('查看原图') @@ -60,7 +59,7 @@ def resizeEvent(self, e): def sizeHint(self): super_size = super().sizeHint() - if self.drawer is None: + if self.drawer.get_pixmap() is None: return super_size h = (self.width() * self.drawer.get_pixmap().height()) \ // self.drawer.get_pixmap().width() diff --git a/feeluown/library/models.py b/feeluown/library/models.py index 5b52bc185e..06b9a7c1f6 100644 --- a/feeluown/library/models.py +++ b/feeluown/library/models.py @@ -267,6 +267,9 @@ class BriefSongModel(BaseBriefModel): album_name: str = '' duration_ms: str = '' + def __str__(self): + return f'{self.title} - {self.artists_name}' + class BriefVideoModel(BaseBriefModel): meta: Any = ModelMeta.create(ModelType.video, is_brief=True) diff --git a/feeluown/player/playlist.py b/feeluown/player/playlist.py index f7389ebef1..d89ce9075a 100644 --- a/feeluown/player/playlist.py +++ b/feeluown/player/playlist.py @@ -3,11 +3,11 @@ import logging import random from enum import IntEnum, Enum -from typing import Optional +from typing import Optional, TYPE_CHECKING from feeluown.excs import ProviderIOError from feeluown.utils import aio -from feeluown.utils.aio import run_fn +from feeluown.utils.aio import run_fn, run_afn from feeluown.utils.dispatch import Signal from feeluown.utils.utils import DedupList from feeluown.player import Metadata, MetadataFields @@ -17,6 +17,9 @@ from feeluown.media import Media from feeluown.models.uri import reverse +if TYPE_CHECKING: + from feeluown.app import App + logger = logging.getLogger(__name__) @@ -71,7 +74,7 @@ class PlaylistMode(IntEnum): class Playlist: - def __init__(self, app, songs=None, playback_mode=PlaybackMode.loop, + def __init__(self, app: 'App', songs=None, playback_mode=PlaybackMode.loop, audio_select_policy='hq<>'): """ :param songs: list of :class:`feeluown.models.SongModel` @@ -126,6 +129,9 @@ def __init__(self, app, songs=None, playback_mode=PlaybackMode.loop, # The *songs_removed* and *songs_added* signal. self.songs_removed = Signal() # (index, count) self.songs_added = Signal() # (index, count) + # .. versionadded:: 3.9.0 + # The *play_model_handling* signal. + self.play_model_handling = Signal() self._app.player.media_finished.connect(self._on_media_finished) @@ -397,6 +403,9 @@ def previous_song(self): previous_song = self._get_good_song(base=current_index - 1, direction=-1) return previous_song + async def a_next(self): + self.next() + def next(self) -> Optional[asyncio.Task]: if self.next_song is None: self.eof_reached.emit() @@ -473,7 +482,7 @@ async def a_set_current_song(self, song): self.batch_add(song.children) await self.a_set_current_song(song.children[0]) else: - self.next() + run_afn(self.a_next) return logger.info(f'{song_str} has no valid media, mark it as bad') @@ -482,7 +491,7 @@ async def a_set_current_song(self, song): # if mode is fm mode, do not find standby song, # just skip the song if self.mode is PlaylistMode.fm: - self.next() + run_afn(self.a_next) return self._app.show_msg(f'{song_str} is invalid, try to find standby') @@ -493,7 +502,9 @@ async def a_set_current_song(self, song): ) if standby_candidates: standby, media = standby_candidates[0] - self._app.show_msg(f'Song standby was found in {standby.source} ✅') + msg = f'Song standby was found in {standby.source} ✅' + logger.info(msg) + self._app.show_msg(msg) # Insert the standby song after the song if song in self._songs and standby not in self._songs: index = self._songs.index(song) @@ -501,7 +512,9 @@ async def a_set_current_song(self, song): self.songs_added.emit(index + 1, 1) target_song = standby else: - self._app.show_msg('Song standby not found') + msg = 'Song standby not found' + logger.info(msg) + self._app.show_msg(msg) except ProviderIOError as e: # FIXME: This may cause infinite loop when the prepare media always fails logger.error(f'prepare media failed: {e}, try next song') @@ -511,7 +524,7 @@ async def a_set_current_song(self, song): logger.exception('prepare media failed due to unknown error, ' 'so we mark the song as a bad one') self.mark_as_bad(song) - self.next() + run_afn(self.a_next) return else: assert media, "media must not be empty" @@ -534,7 +547,7 @@ def pure_set_current_song(self, song, media, metadata=None): if song is not None: if media is None: - self.next() + run_afn(self.a_next) else: # Note that the value of model v1 {}_display may be None. kwargs = {} @@ -607,6 +620,7 @@ async def _prepare_metadata_for_video(self, video): async def _prepare_media(self, song): task_spec = self._app.task_mgr.get_or_create('prepare-media') + task_spec.disable_default_cb() if self.watch_mode is True: try: mv_media = await task_spec.bind_blocking_io( @@ -671,6 +685,9 @@ def play_model(self, model): .. versionadded: 3.7.14 """ + # Stop the player so that user know the action is working. + self._app.player.stop() + self.play_model_handling.emit() task = self.set_current_model(model) if task is not None: def cb(future): @@ -680,4 +697,5 @@ def cb(future): logger.exception('play model failed') else: self._app.player.resume() + logger.info(f'play a model ({model}) succeed') task.add_done_callback(cb) diff --git a/tests/player/test_playlist.py b/tests/player/test_playlist.py index 02af5ff708..6b9a768d53 100644 --- a/tests/player/test_playlist.py +++ b/tests/player/test_playlist.py @@ -286,6 +286,7 @@ async def test_play_next_bad_song(app_mock, song, song1, mocker): pl._current_song = song await pl.a_set_current_song(pl.next_song) assert mock_mark_as_bad.called + await asyncio.sleep(0.1) assert mock_next.called