Skip to content

Commit

Permalink
[feat](gui) add and remove collection on the GUI (#717)
Browse files Browse the repository at this point in the history
  • Loading branch information
cosven authored Jul 17, 2023
1 parent ec77ed4 commit 35ae814
Show file tree
Hide file tree
Showing 20 changed files with 403 additions and 229 deletions.
9 changes: 3 additions & 6 deletions feeluown/app/gui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from feeluown.gui.uimodels.provider import ProviderUiManager
from feeluown.gui.uimodels.playlist import PlaylistUiManager
from feeluown.gui.uimodels.my_music import MyMusicUiManager
from feeluown.gui.uimodels.collection import CollectionUiManager

from feeluown.collection import CollectionManager

Expand Down Expand Up @@ -76,7 +75,6 @@ def __init__(self, *args, **kwargs):
self.pvd_uimgr = ProviderUiManager(self)
self.pl_uimgr = PlaylistUiManager(self)
self.mymusic_uimgr = MyMusicUiManager(self)
self.coll_uimgr = CollectionUiManager(self)

self.browser = Browser(self)
self.ui = Ui(self)
Expand All @@ -92,7 +90,7 @@ def initialize(self):
if self.config.ENABLE_TRAY:
self.tray.initialize()
self.tray.show()
self.coll_uimgr.initialize()
self.coll_mgr.scan()
self.watch_mgr.initialize()
self.browser.initialize()
QApplication.instance().aboutToQuit.connect(self.about_to_exit)
Expand All @@ -103,9 +101,8 @@ def run(self):

def apply_state(self, state):
super().apply_state(state)
coll_library = self.coll_uimgr.get_coll_library()
coll_id = self.coll_uimgr.get_coll_id(coll_library)
self.browser.goto(page=f'/colls/{coll_id}')
coll_library = self.coll_mgr.get_coll_library()
self.browser.goto(page=f'/colls/{coll_library.identifier}')

gui = state.get('gui', {})
lyric = gui.get('lyric', {})
Expand Down
112 changes: 102 additions & 10 deletions feeluown/collection.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import base64
import itertools
import logging
import os
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Dict, Iterable, List

import tomlkit

from feeluown.consts import COLLECTIONS_DIR
from feeluown.utils.dispatch import Signal
from feeluown.models.uri import resolve, reverse, ResolverNotFound, \
ResolveFailed, ModelExistence
from feeluown.consts import COLLECTIONS_DIR
from feeluown.utils.utils import elfhash

logger = logging.getLogger(__name__)

Expand All @@ -23,6 +27,10 @@
TOML_DELIMLF = "+++\n"


class CollectionAlreadyExists(Exception):
pass


class CollectionType(Enum):
sys_library = 16

Expand All @@ -34,11 +42,14 @@ class Collection:
def __init__(self, fpath):
# TODO: 以后考虑添加 identifier 字段,identifier
# 字段应该尽量设计成可以跨电脑使用
self.fpath = fpath
self.fpath = str(fpath)
# TODO: 目前还没想好 collection identifier 计算方法,故添加这个函数
# 现在把 fpath 当作 identifier 使用,但对外透明
self.identifier = elfhash(base64.b64encode(bytes(self.fpath, 'utf-8')))

# these variables should be inited during loading
self.type = None
self.name = None
self.name = None # Collection title.
self.models = []
self.updated_at = None
self.created_at = None
Expand Down Expand Up @@ -100,6 +111,22 @@ def load(self):
self._has_nonexistent_models = True
self.models.append(model)

@classmethod
def create_empty(cls, fpath, title=''):
"""Create an empty collection."""
if os.path.exists(fpath):
raise CollectionAlreadyExists()

doc = tomlkit.document()
if title:
doc.add('title', title)
doc.add('created', datetime.now())
doc.add('updated', datetime.now())
with open(fpath, 'w', encoding='utf-8') as f:
f.write(TOML_DELIMLF)
f.write(tomlkit.dumps(doc))
f.write(TOML_DELIMLF)

def add(self, model):
"""add model to collection
Expand Down Expand Up @@ -179,26 +206,91 @@ def _write_metadata_if_needed(self, f):


class CollectionManager:

def __init__(self, app):
self._app = app
self.scan_finished = Signal()
self._library = app.library
self.default_dir = COLLECTIONS_DIR

def scan(self):
"""Scan collections directories for valid fuo files, yield
Collection instance for each file.
"""
default_fpaths = []
self._id_coll_mapping: Dict[str, Collection] = {}

def get(self, identifier):
return self._id_coll_mapping.get(int(identifier), None)

def get_coll_library(self):
for coll in self._id_coll_mapping.values():
if coll.type == CollectionType.sys_library:
return coll
assert False, "collection 'library' must exists."

def create(self, fname, title) -> Collection:
first_valid_dir = ''
for d, exists in self._get_dirs():
if exists:
first_valid_dir = d
break

assert first_valid_dir, 'there must be a valid collection dir'
normalized_name = fname.replace(' ', '_')
fpath = os.path.join(first_valid_dir, normalized_name)
filepath = f'{fpath}.fuo'
logger.info(f'Create collection:{title} at {filepath}')
return Collection.create_empty(filepath, title)

def remove(self, collection: Collection):
coll_id = collection.identifier
if coll_id in self._id_coll_mapping:
self._id_coll_mapping.pop(coll_id)
os.remove(collection.fpath)

def _get_dirs(self, ):
directorys = [self.default_dir]
if self._app.config.COLLECTIONS_DIR:
if isinstance(self._app.config.COLLECTIONS_DIR, list):
directorys += self._app.config.COLLECTIONS_DIR
else:
directorys.append(self._app.config.COLLECTIONS_DIR)
expanded_dirs = []
for directory in directorys:
directory = os.path.expanduser(directory)
if not os.path.exists(directory):
logger.warning(f'Collection Dir:{directory} does not exist.')
expanded_dirs.append((directory, os.path.exists(directory)))
return expanded_dirs

def scan(self):
colls: List[Collection] = []
library_coll = None
for coll in self._scan():
if coll.type == CollectionType.sys_library:
library_coll = coll
continue
colls.append(coll)
colls.insert(0, library_coll)
for collection in colls:
coll_id = collection.identifier
assert coll_id not in self._id_coll_mapping, collection.fpath
self._id_coll_mapping[coll_id] = collection
self.scan_finished.emit()

def refresh(self):
self.clear()
self.scan()

def listall(self):
return self._id_coll_mapping.values()

def clear(self):
self._id_coll_mapping.clear()

def _scan(self) -> Iterable[Collection]:
"""Scan collections directories for valid fuo files, yield
Collection instance for each file.
"""
default_fpaths = []
valid_dirs = self._get_dirs()
for directory, exists in valid_dirs:
if not exists:
logger.warning('Collection directory %s does not exist', directory)
continue
for filename in os.listdir(directory):
if not filename.endswith('.fuo'):
Expand Down
2 changes: 0 additions & 2 deletions feeluown/gui/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
from .uimodels.collection import CollectionUiManager
from .uimodels.provider import ProviderUiManager
from .uimodels.my_music import MyMusicUiManager
from .uimodels.playlist import PlaylistUiManager


__all__ = (
'CollectionUiManager',
'ProviderUiManager',
'MyMusicUiManager',
'PlaylistUiManager',
Expand Down
2 changes: 1 addition & 1 deletion feeluown/gui/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ def can_forward(self):
# --------------

def _render_coll(self, _, identifier):
coll = self._app.coll_uimgr.get(int(identifier))
coll = self._app.coll_mgr.get(identifier)
self._app.ui.right_panel.show_collection(coll)

def on_history_changed(self):
Expand Down
1 change: 1 addition & 0 deletions feeluown/gui/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
from .playlist_btn import PlaylistButton # noqa
from .volume_slider import * # noqa
from .song_tag import SongSourceTag # noqa
from .collections import CollectionListView # noqa
4 changes: 2 additions & 2 deletions feeluown/gui/components/btns.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def on_song_changed(self, song, media):

def toggle_liked(self):
song = self._app.playlist.current_song
coll_library = self._app.coll_uimgr.get_coll_library()
coll_library = self._app.coll_mgr.get_coll_library()
if self.is_song_liked(song):
coll_library.remove(song)
self._app.show_msg('歌曲已经从“本地收藏”中移除')
Expand All @@ -108,7 +108,7 @@ def on_toggled(self):
self.setToolTip('从“本地收藏”中移除')

def is_song_liked(self, song):
coll_library = self._app.coll_uimgr.get_coll_library()
coll_library = self._app.coll_mgr.get_coll_library()
return song in coll_library.models


Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,54 @@
import logging
from typing import TYPE_CHECKING

from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtWidgets import QAbstractItemView
from PyQt5.QtWidgets import QAbstractItemView, QMenu

from feeluown.collection import CollectionType
from .textlist import TextlistModel, TextlistView
from feeluown.collection import CollectionType, Collection
from feeluown.gui.widgets.textlist import TextlistModel, TextlistView

if TYPE_CHECKING:
from feeluown.app.gui_app import GuiApp

logger = logging.getLogger(__name__)


class CollectionsModel(TextlistModel):
def flags(self, index):
if not index.isValid():
return 0
flags = Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsDropEnabled
return flags

def data(self, index, role=Qt.DisplayRole):
row = index.row()
item = self._items[row]
if role == Qt.DisplayRole:
icon = '◎ '
if item.type == CollectionType.sys_library:
icon = '◉ '
return icon + item.name
if role == Qt.ToolTipRole:
return item.fpath
return super().data(index, role)


class CollectionsView(TextlistView):
class CollectionListView(TextlistView):
"""
Maybe make this a component instead of a widget.
"""
show_collection = pyqtSignal([object])
remove_collection = pyqtSignal([object])

def __init__(self, parent):
super().__init__(parent)
def __init__(self, app: 'GuiApp', **kwargs):
super().__init__(**kwargs)
self._app = app
self.setDragDropMode(QAbstractItemView.DropOnly)
self.setModel(CollectionListModel(self))

self.clicked.connect(self._on_clicked)
self._app.coll_mgr.scan_finished.connect(self.on_scan_finished)

def on_scan_finished(self):
self.model().clear()
for coll in self._app.coll_mgr.listall():
self.model().add(coll)

def _on_clicked(self, index):
collection = index.data(role=Qt.UserRole)
self.show_collection.emit(collection)

def contextMenuEvent(self, event):
indexes = self.selectionModel().selectedIndexes()
if len(indexes) != 1:
return

collection: Collection = self.model().data(indexes[0], Qt.UserRole)
menu = QMenu()
action = menu.addAction('删除此收藏集')
action.triggered.connect(lambda: self.remove_collection.emit(collection))
menu.exec(event.globalPos())

# dragEnterEvent -> dragMoveEvent -> dropEvent
def dragEnterEvent(self, e):
"""
Expand Down Expand Up @@ -78,3 +86,23 @@ def dropEvent(self, e):
self.viewport().update()
self._result_timer.start(2000)
e.accept()


class CollectionListModel(TextlistModel):
def flags(self, index):
if not index.isValid():
return 0
flags = Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsDropEnabled
return flags

def data(self, index, role=Qt.DisplayRole):
row = index.row()
item = self._items[row]
if role == Qt.DisplayRole:
icon = '◎ '
if item.type == CollectionType.sys_library:
icon = '◉ '
return icon + item.name
if role == Qt.ToolTipRole:
return item.fpath
return super().data(index, role)
Loading

0 comments on commit 35ae814

Please sign in to comment.