diff --git a/desktop/Pill.qml b/desktop/Pill.qml new file mode 100644 index 0000000..dc0f530 --- /dev/null +++ b/desktop/Pill.qml @@ -0,0 +1,8 @@ +import QtQuick 2.0 + +Image { + property int leftCount: 0 + property int rightCount: 0 + source: 'image://python/pill/' + leftCount + '/' + rightCount + cache: true +} diff --git a/desktop/gpodder.qml b/desktop/gpodder.qml index f411fee..54a822e 100644 --- a/desktop/gpodder.qml +++ b/desktop/gpodder.qml @@ -1,4 +1,4 @@ -import QtQuick 2.0 +import QtQuick 2.3 import QtQuick.Controls 1.0 import QtQuick.Layouts 1.0 import QtQuick.Dialogs 1.2 @@ -7,6 +7,7 @@ import 'dialogs' import 'common' import 'common/util.js' as Util +import 'common/constants.js' as Constants ApplicationWindow { id: appWindow @@ -63,87 +64,282 @@ ApplicationWindow { SplitView { anchors.fill: parent - TableView { - id: podcastListView + ColumnLayout { + TableView { + Layout.fillHeight: true + Layout.fillWidth: true - width: 200 - model: GPodderPodcastListModel { id: podcastListModel } - GPodderPodcastListModelConnections {} - headerVisible: false - alternatingRowColors: false + id: podcastListView - Menu { - id: podcastContextMenu - MenuItem { - text: 'Unsubscribe' - onTriggered: { - var podcast_id = podcastListModel.get(podcastListView.currentRow).id; - py.call('main.unsubscribe', [podcast_id]); + model: GPodderPodcastListModel { id: podcastListModel } + GPodderPodcastListModelConnections {} + headerVisible: false + alternatingRowColors: false + horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff + + Menu { + id: podcastContextMenu + + MenuItem { + text: 'Unsubscribe' + onTriggered: { + var podcast_id = podcastListModel.get(podcastListView.currentRow).id; + py.call('main.unsubscribe', [podcast_id]); + } } - } - } - rowDelegate: Rectangle { - height: 40 - color: styleData.selected ? '#eee' : '#fff' - - MouseArea { - acceptedButtons: Qt.RightButton - anchors.fill: parent - onClicked: podcastContextMenu.popup() - } - } - - TableViewColumn { - role: 'coverart' - title: 'Image' - delegate: Item { - height: 32 - width: 32 - Image { - source: styleData.value - width: 32 - height: 32 - anchors.centerIn: parent + MenuItem { + text: 'Mark episodes as old' + onTriggered: { + var podcast_id = podcastListModel.get(podcastListView.currentRow).id; + py.call('main.mark_episodes_as_old', [podcast_id]); + } } } - width: 40 - } - - TableViewColumn { - role: 'title' - title: 'Podcast' - delegate: Item { + rowDelegate: Rectangle { height: 40 - Text { - text: styleData.value - anchors.verticalCenter: parent.verticalCenter + color: styleData.selected ? Constants.colors.select : 'transparent' + + MouseArea { + acceptedButtons: Qt.RightButton + anchors.fill: parent + onClicked: podcastContextMenu.popup() } } + + TableViewColumn { + id: coverartColumn + width: 40 + + role: 'coverart' + title: 'Image' + delegate: Item { + height: 32 + width: 32 + Image { + mipmap: true + source: styleData.value + width: 32 + height: 32 + anchors.centerIn: parent + } + } + } + + TableViewColumn { + role: 'title' + title: 'Podcast' + width: podcastListView.width - coverartColumn.width - indicatorColumn.width - 2 * 5 + + delegate: Item { + property var row: podcastListModel.get(styleData.row) + + width: parent.width + height: 40 + + Column { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + spacing: -5 + + Text { + width: parent.width + color: styleData.selected ? 'white' : 'black' + + font.bold: row.newEpisodes + text: styleData.value + elide: styleData.elideMode + } + + Text { + width: parent.width + font.pointSize: 10 + color: styleData.selected ? 'white' : 'black' + + text: row.description + elide: styleData.elideMode + } + } + } + } + + TableViewColumn { + id: indicatorColumn + width: 50 + + role: 'indicator' + title: 'Indicator' + + delegate: Item { + height: 32 + width: 50 + property var row: podcastListModel.get(styleData.row) + + Rectangle { + anchors.centerIn: parent + visible: row.updating + + color: styleData.selected ? '#ffffff' : '#000000' + + property real phase: 0 + + width: 15 + 3 * Math.sin(phase) + height: width + + PropertyAnimation on phase { + loops: Animation.Infinite + duration: 2000 + running: parent.visible + from: 0 + to: 2*Math.PI + } + } + + Pill { + anchors.centerIn: parent + + visible: !row.updating + + leftCount: row.unplayed + rightCount: row.downloaded + } + } + } + + onCurrentRowChanged: { + var id = podcastListModel.get(currentRow).id; + episodeListModel.loadEpisodes(id); + } } - onCurrentRowChanged: { - var id = podcastListModel.get(currentRow).id; - episodeListModel.loadEpisodes(id); + + Button { + Layout.fillWidth: true + Layout.margins: 3 + text: 'Check for new episodes' + onClicked: py.call('main.check_for_episodes'); + enabled: !py.refreshing } } - TableView { - Layout.fillWidth: true - model: GPodderEpisodeListModel { id: episodeListModel } - GPodderEpisodeListModelConnections {} + SplitView { + orientation: Orientation.Vertical - TableViewColumn { role: 'title'; title: 'Title' } + TableView { + id: episodeListView - onActivated: { - var episode_id = episodeListModel.get(currentRow).id; + Layout.fillWidth: true + model: GPodderEpisodeListModel { id: episodeListModel } + GPodderEpisodeListModelConnections {} + selectionMode: SelectionMode.MultiSelection - openDialog('dialogs/EpisodeDetailsDialog.qml', function (dialog) { - py.call('main.show_episode', [episode_id], function (episode) { - dialog.episode = episode; + function forEachSelectedEpisode(callback) { + episodeListView.selection.forEach(function(rowIndex) { + var episode_id = episodeListModel.get(rowIndex).id; + callback(episode_id); }); - }); + } + + Menu { + id: episodeContextMenu + + MenuItem { + text: 'Toggle new' + onTriggered: { + episodeListView.forEachSelectedEpisode(function (episode_id) { + py.call('main.toggle_new', [episode_id]); + }); + } + } + + MenuItem { + text: 'Download' + onTriggered: { + episodeListView.forEachSelectedEpisode(function (episode_id) { + py.call('main.download_episode', [episode_id]); + }); + } + } + + MenuItem { + text: 'Delete' + onTriggered: { + episodeListView.forEachSelectedEpisode(function (episode_id) { + py.call('main.delete_episode', [episode_id]); + }); + } + } + } + + rowDelegate: Rectangle { + height: 40 + color: styleData.selected ? Constants.colors.select : 'transparent' + + MouseArea { + acceptedButtons: Qt.RightButton + anchors.fill: parent + onClicked: episodeContextMenu.popup() + } + } + + TableViewColumn { + role: 'title' + title: 'Episode' + + delegate: Row { + property var row: episodeListModel.get(styleData.row) + height: 32 + + Item { + width: 32 + height: 32 + + Rectangle { + anchors.centerIn: parent + width: 10 + height: 10 + color: episodeTitle.color + } + + anchors.verticalCenter: parent.verticalCenter + opacity: row.downloadState === Constants.state.downloaded + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: -5 + + Text { + id: episodeTitle + text: styleData.value + elide: styleData.elideMode + color: styleData.selected ? 'white' : 'black' + font.bold: row.isNew + } + + Text { + text: row.progress ? ('Downloading: ' + parseInt(100*row.progress) + '%') : row.subtitle + elide: styleData.elideMode + color: styleData.selected ? 'white' : 'black' + } + } + } + } + + onActivated: { + var episode_id = episodeListModel.get(currentRow).id; + + openDialog('dialogs/EpisodeDetailsDialog.qml', function (dialog) { + py.call('main.show_episode', [episode_id], function (episode) { + dialog.episode = episode; + }); + }); + } + } + + Label { } } } diff --git a/main.py b/main.py index eb03f31..f7ec09e 100644 --- a/main.py +++ b/main.py @@ -37,6 +37,7 @@ import logging import functools import time import datetime +import re logger = logging.getLogger(__name__) @@ -113,6 +114,7 @@ class gPotherSide: 'episodes': total, 'newEpisodes': new, 'downloaded': downloaded, + 'unplayed': unplayed, } def _get_cover(self, podcast): @@ -133,8 +135,10 @@ class gPotherSide: return { 'id': podcast.id, 'title': podcast.title, + 'description': podcast.one_line_description(), 'newEpisodes': new, 'downloaded': downloaded, + 'unplayed': unplayed, 'coverart': self._get_cover(podcast), 'updating': podcast._updating, 'section': podcast.section, @@ -420,6 +424,116 @@ class gPotherSide: return [] + +PILL_TEMPLATE = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {left_text} + {left_text} + + + + + + + {right_text} + {right_text} + + + +""" + + +class PillExpression(object): + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __call__(self, matchobj): + return str(eval(matchobj.group(1), self.kwargs)) + + +def pill_image_provider(image_id, requested_size): + left_text, right_text = (int(x) for x in image_id.split('/')) + + width = 44 + height = 24 + radius = 6 + font_size = 13 + + text_lx = width / 4 + text_rx = width * 3 / 4 + + charheight = font_size + charwidth = font_size / 1.3 + + if left_text: + text_lx -= charwidth * len(str(left_text)) / 2 + if right_text: + text_rx -= charwidth * len(str(right_text)) / 2 + + outer_style = 'stroke: #333333; stroke-width: 1; fill-opacity: 0; stroke-opacity: 0.6;' + inner_style = 'stroke: #ffffff; stroke-width: 1; fill-opacity: 0; stroke-opacity: 0.3;' + + expression = PillExpression(height=height, width=width, left_text=left_text, + right_text=right_text, radius=radius, + lx=text_lx, rx=text_rx, font_size=font_size, + outer_style=outer_style, inner_style=inner_style) + svg = re.sub(r'[{]([^}]+)[}]', expression, PILL_TEMPLATE) + return bytearray(svg.encode('utf-8')), (width, height), pyotherside.format_data + + +@pyotherside.set_image_provider +def gpotherside_image_provider(image_id, requested_size): + provider, args = image_id.split('/', 1) + if provider == 'pill': + return pill_image_provider(args, requested_size) + + raise ValueError('Unknown provider: %s' % (provider,)) + + gpotherside = gPotherSide() pyotherside.atexit(gpotherside.atexit)