Desktop UI: Port pill for podcast list
This commit is contained in:
parent
f3fb2585dc
commit
1386245b50
3 changed files with 381 additions and 63 deletions
8
desktop/Pill.qml
Normal file
8
desktop/Pill.qml
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import QtQuick 2.0
|
import QtQuick 2.3
|
||||||
import QtQuick.Controls 1.0
|
import QtQuick.Controls 1.0
|
||||||
import QtQuick.Layouts 1.0
|
import QtQuick.Layouts 1.0
|
||||||
import QtQuick.Dialogs 1.2
|
import QtQuick.Dialogs 1.2
|
||||||
|
@ -7,6 +7,7 @@ import 'dialogs'
|
||||||
|
|
||||||
import 'common'
|
import 'common'
|
||||||
import 'common/util.js' as Util
|
import 'common/util.js' as Util
|
||||||
|
import 'common/constants.js' as Constants
|
||||||
|
|
||||||
ApplicationWindow {
|
ApplicationWindow {
|
||||||
id: appWindow
|
id: appWindow
|
||||||
|
@ -63,87 +64,282 @@ ApplicationWindow {
|
||||||
SplitView {
|
SplitView {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
||||||
TableView {
|
ColumnLayout {
|
||||||
id: podcastListView
|
TableView {
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
width: 200
|
id: podcastListView
|
||||||
model: GPodderPodcastListModel { id: podcastListModel }
|
|
||||||
GPodderPodcastListModelConnections {}
|
|
||||||
headerVisible: false
|
|
||||||
alternatingRowColors: false
|
|
||||||
|
|
||||||
Menu {
|
model: GPodderPodcastListModel { id: podcastListModel }
|
||||||
id: podcastContextMenu
|
GPodderPodcastListModelConnections {}
|
||||||
MenuItem {
|
headerVisible: false
|
||||||
text: 'Unsubscribe'
|
alternatingRowColors: false
|
||||||
onTriggered: {
|
horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff
|
||||||
var podcast_id = podcastListModel.get(podcastListView.currentRow).id;
|
|
||||||
py.call('main.unsubscribe', [podcast_id]);
|
Menu {
|
||||||
|
id: podcastContextMenu
|
||||||
|
|
||||||
|
MenuItem {
|
||||||
|
text: 'Unsubscribe'
|
||||||
|
onTriggered: {
|
||||||
|
var podcast_id = podcastListModel.get(podcastListView.currentRow).id;
|
||||||
|
py.call('main.unsubscribe', [podcast_id]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rowDelegate: Rectangle {
|
MenuItem {
|
||||||
height: 40
|
text: 'Mark episodes as old'
|
||||||
color: styleData.selected ? '#eee' : '#fff'
|
onTriggered: {
|
||||||
|
var podcast_id = podcastListModel.get(podcastListView.currentRow).id;
|
||||||
MouseArea {
|
py.call('main.mark_episodes_as_old', [podcast_id]);
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
width: 40
|
rowDelegate: Rectangle {
|
||||||
}
|
|
||||||
|
|
||||||
TableViewColumn {
|
|
||||||
role: 'title'
|
|
||||||
title: 'Podcast'
|
|
||||||
delegate: Item {
|
|
||||||
height: 40
|
height: 40
|
||||||
Text {
|
color: styleData.selected ? Constants.colors.select : 'transparent'
|
||||||
text: styleData.value
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
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;
|
Button {
|
||||||
episodeListModel.loadEpisodes(id);
|
Layout.fillWidth: true
|
||||||
|
Layout.margins: 3
|
||||||
|
text: 'Check for new episodes'
|
||||||
|
onClicked: py.call('main.check_for_episodes');
|
||||||
|
enabled: !py.refreshing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TableView {
|
SplitView {
|
||||||
Layout.fillWidth: true
|
orientation: Orientation.Vertical
|
||||||
model: GPodderEpisodeListModel { id: episodeListModel }
|
|
||||||
GPodderEpisodeListModelConnections {}
|
|
||||||
|
|
||||||
TableViewColumn { role: 'title'; title: 'Title' }
|
TableView {
|
||||||
|
id: episodeListView
|
||||||
|
|
||||||
onActivated: {
|
Layout.fillWidth: true
|
||||||
var episode_id = episodeListModel.get(currentRow).id;
|
model: GPodderEpisodeListModel { id: episodeListModel }
|
||||||
|
GPodderEpisodeListModelConnections {}
|
||||||
|
selectionMode: SelectionMode.MultiSelection
|
||||||
|
|
||||||
openDialog('dialogs/EpisodeDetailsDialog.qml', function (dialog) {
|
function forEachSelectedEpisode(callback) {
|
||||||
py.call('main.show_episode', [episode_id], function (episode) {
|
episodeListView.selection.forEach(function(rowIndex) {
|
||||||
dialog.episode = episode;
|
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 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
114
main.py
114
main.py
|
@ -37,6 +37,7 @@ import logging
|
||||||
import functools
|
import functools
|
||||||
import time
|
import time
|
||||||
import datetime
|
import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -113,6 +114,7 @@ class gPotherSide:
|
||||||
'episodes': total,
|
'episodes': total,
|
||||||
'newEpisodes': new,
|
'newEpisodes': new,
|
||||||
'downloaded': downloaded,
|
'downloaded': downloaded,
|
||||||
|
'unplayed': unplayed,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_cover(self, podcast):
|
def _get_cover(self, podcast):
|
||||||
|
@ -133,8 +135,10 @@ class gPotherSide:
|
||||||
return {
|
return {
|
||||||
'id': podcast.id,
|
'id': podcast.id,
|
||||||
'title': podcast.title,
|
'title': podcast.title,
|
||||||
|
'description': podcast.one_line_description(),
|
||||||
'newEpisodes': new,
|
'newEpisodes': new,
|
||||||
'downloaded': downloaded,
|
'downloaded': downloaded,
|
||||||
|
'unplayed': unplayed,
|
||||||
'coverart': self._get_cover(podcast),
|
'coverart': self._get_cover(podcast),
|
||||||
'updating': podcast._updating,
|
'updating': podcast._updating,
|
||||||
'section': podcast.section,
|
'section': podcast.section,
|
||||||
|
@ -420,6 +424,116 @@ class gPotherSide:
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
PILL_TEMPLATE = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" height="{height}" width="{width}"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<defs>
|
||||||
|
<linearGradient x1="0" y1="0" x2="1" y2="1" id="rightGradient">
|
||||||
|
<stop offset="0.0" style="stop-color: #333333; stop-opacity: 0.9;" />
|
||||||
|
<stop offset="0.4" style="stop-color: #333333; stop-opacity: 0.8;" />
|
||||||
|
<stop offset="0.6" style="stop-color: #333333; stop-opacity: 0.6;" />
|
||||||
|
<stop offset="0.9" style="stop-color: #333333; stop-opacity: 0.7;" />
|
||||||
|
<stop offset="1.0" style="stop-color: #333333; stop-opacity: 0.5;" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient x1="0" y1="0" x2="1" y2="1" id="leftGradient">
|
||||||
|
<stop offset="0.0" style="stop-color: #cccccc; stop-opacity: 0.5;" />
|
||||||
|
<stop offset="0.4" style="stop-color: #cccccc; stop-opacity: 0.7;" />
|
||||||
|
<stop offset="0.6" style="stop-color: #cccccc; stop-opacity: 0.6;" />
|
||||||
|
<stop offset="0.9" style="stop-color: #cccccc; stop-opacity: 0.8;" />
|
||||||
|
<stop offset="1.0" style="stop-color: #cccccc; stop-opacity: 0.9;" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<path id="rightPath" d="M {width/2} 0 l {width/2-radius-1} 0
|
||||||
|
s {radius} 0 {radius} {radius} l 0 {height-radius*2}
|
||||||
|
s 0 {radius} {-radius} {radius} l {-(width/2-radius-1)} 0 z" />
|
||||||
|
<path id="rightPathOuter" d="M {width/2+0.5} {0.5} l {width/2-radius-2} 0
|
||||||
|
s {radius} 0 {radius} {radius} l 0 {height-radius*2-1}
|
||||||
|
s 0 {radius} {-radius} {radius} l {-(width/2-radius-2)} 0 z" />
|
||||||
|
<path id="rightPathInner" d="M {width/2+1.5} {1.5} l {width/2-radius-4} 0
|
||||||
|
s {radius} 0 {radius} {radius} l 0 {height-radius*2-3}
|
||||||
|
s 0 {radius} {-radius} {radius} l {-(width/2-radius-4)} 0 z" />
|
||||||
|
|
||||||
|
<path id="leftPath" d="M {width/2} 0 l {-(width/2-radius-1)} 0
|
||||||
|
s {-radius} 0 {-radius} {radius} l 0 {height-radius*2}
|
||||||
|
s 0 {radius} {radius} {radius} l {width/2-radius-1} 0 z" />
|
||||||
|
<path id="leftPathOuter" d="M {width/2-0.5} {0.5} l {-(width/2-radius-2)} 0
|
||||||
|
s {-radius} 0 {-radius} {radius} l 0 {height-radius*2-1}
|
||||||
|
s 0 {radius} {radius} {radius} l {width/2-radius-2} 0 z" />
|
||||||
|
<path id="leftPathInner" d="M {width/2-1.5} {1.5} l {-(width/2-radius-4)} 0
|
||||||
|
s {-radius} 0 {-radius} {radius} l 0 {height-radius*2-3}
|
||||||
|
s 0 {radius} {radius} {radius} l {width/2-radius-4} 0 z" />
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g style="font-family: sans-serif; font-size: {font_size}px; font-weight: bold;">
|
||||||
|
<g style="display: {'inline' if left_text else 'none'};">
|
||||||
|
<use xlink:href="#leftPath" style="fill:url(#leftGradient);"/>
|
||||||
|
<use xlink:href="#leftPathOuter" style="{outer_style}" />
|
||||||
|
<use xlink:href="#leftPathInner" style="{inner_style}" />
|
||||||
|
<text x="{lx+1}" y="{height/2+font_size/3+1}" fill="black">{left_text}</text>
|
||||||
|
<text x="{lx}" y="{height/2+font_size/3}" fill="white">{left_text}</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g style="display: {'inline' if right_text else 'none'};">
|
||||||
|
<use xlink:href="#rightPath" style="fill:url(#rightGradient);"/>
|
||||||
|
<use xlink:href="#rightPathOuter" style="{outer_style}" />
|
||||||
|
<use xlink:href="#rightPathInner" style="{inner_style}" />
|
||||||
|
<text x="{rx+1}" y="{height/2+font_size/3+1}" fill="black">{right_text}</text>
|
||||||
|
<text x="{rx}" y="{height/2+font_size/3}" fill="white">{right_text}</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
gpotherside = gPotherSide()
|
||||||
pyotherside.atexit(gpotherside.atexit)
|
pyotherside.atexit(gpotherside.atexit)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue