Compare commits

...
Sign in to create a new pull request.

15 commits

Author SHA1 Message Date
Jeena
8aab9adb4d Implement OPML import in the desktop and touch ui
The core has already implemented parsing OPML files from
URLs and files, it only needs to be exposed by the UI which
this parch does.
2016-02-22 04:51:05 +01:00
Thomas Perl
1386245b50 Desktop UI: Port pill for podcast list 2016-02-04 20:42:27 +01:00
Thomas Perl
f3fb2585dc desktop: Add/remove podcasts 2016-02-03 22:54:33 +01:00
Thomas Perl
cfed87916b Welcome to 2015 2015-07-08 11:00:33 +02:00
Thomas Perl
5186558174 gPodder QML UI 4.6.0 2015-05-24 20:41:56 +02:00
Thomas Perl
fa280e9ef5 PListView: Fix layout issues on resize 2015-05-24 18:44:33 +02:00
Thomas Perl
4a0b3f1b12 Revert "Use QML WorkerScript for loading and updating models"
This reverts commit d8fd1ff3dd.
2015-05-24 17:44:39 +02:00
Thomas Perl
ab4dfc611b Settings page 2015-05-23 15:36:11 +02:00
Thomas Perl
ad275cf1fb Improve section header 2015-03-16 21:59:29 +01:00
Thomas Perl
bfb22d0d3f IconContextMenu: Import fix 2015-03-16 21:51:08 +01:00
Thomas Perl
1fb4c0df40 PEP-8 fixes 2015-03-16 21:48:04 +01:00
Thomas Perl
7a1c0c6173 Platform style: Use toolbar on top by default 2015-03-16 21:42:35 +01:00
Thomas Perl
e576909235 Use stack icon instead of vellipsis for menu
vellipsis doesn't seem to be available under Android 4.0.
2015-03-16 21:36:53 +01:00
Thomas Perl
953905ba3b Use rectangle indicator instead of left-bar indicator 2015-03-16 21:30:23 +01:00
Thomas Perl
d4ca82e6d3 IconContextMenu: Right-align icons 2015-03-16 20:20:38 +01:00
31 changed files with 869 additions and 232 deletions

View file

@ -77,6 +77,16 @@ Python {
});
}
function setConfig(key, value) {
py.call('main.set_config_value', [key, value]);
}
function getConfig(key, callback) {
py.call('main.get_config_value', [key], function (result) {
callback(result);
});
}
onReceived: {
console.log('unhandled message: ' + data);
}

View file

@ -62,10 +62,6 @@ ListModel {
});
}
property var worker: ModelWorkerScript {
id: modelWorker
}
function forEachEpisode(callback) {
// Go from bottom up (= chronological order)
for (var i=count-1; i>=0; i--) {
@ -126,12 +122,11 @@ ListModel {
ready = false;
py.call('main.load_episodes', [podcast_id, query], function (episodes) {
modelWorker.updateModelFrom(episodeListModel, episodes, function () {
episodeListModel.ready = true;
if (callback !== undefined) {
callback();
}
});
Util.updateModelFrom(episodeListModel, episodes);
episodeListModel.ready = true;
if (callback !== undefined) {
callback();
}
});
}
}

View file

@ -26,11 +26,11 @@ Connections {
target: py
onDownloadProgress: {
episodeListModel.worker.updateModelWith(episodeListModel, 'id', episode_id,
Util.updateModelWith(episodeListModel, 'id', episode_id,
{'progress': progress});
}
onPlaybackProgress: {
episodeListModel.worker.updateModelWith(episodeListModel, 'id', episode_id,
Util.updateModelWith(episodeListModel, 'id', episode_id,
{'playbackProgress': progress});
}
onUpdatedEpisode: {

View file

@ -26,9 +26,11 @@ Item {
property bool android: (typeof(gpodderAndroid) !== 'undefined') || emulatingAndroid
property bool needsBackButton: !android
property bool toolbarOnTop: android
property bool toolbarOnTop: true
property bool invertedToolbar: toolbarOnTop
property bool titleInToolbar: toolbarOnTop
property bool floatingPlayButton: android
property bool hideDisabledMenu: android
property bool floatingPlayButton: true
property bool hideDisabledMenu: true
}

View file

@ -25,13 +25,9 @@ import 'util.js' as Util
ListModel {
id: podcastListModel
property var worker: ModelWorkerScript {
id: modelWorker
}
function reload() {
py.call('main.load_podcasts', [], function (podcasts) {
modelWorker.updateModelFrom(podcastListModel, podcasts);
Util.updateModelFrom(podcastListModel, podcasts);
});
}
}

View file

@ -1,67 +0,0 @@
/**
*
* gPodder QML UI Reference Implementation
* Copyright (c) 2015, Thomas Perl <m@thp.io>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*
*/
import QtQuick 2.0
WorkerScript {
source: 'modelworker.js'
property int callbacks_seq: 1
property var callbacks: ({})
function refCallback(callback) {
var id = callbacks_seq++;
callbacks[id] = callback;
return id;
}
function unrefCallback(callback) {
var result = callbacks[callback];
delete callbacks[callback];
return result;
}
function updateModelFrom(model, data, callback) {
sendMessage({
action: 'updateModelFrom',
model: model,
data: data,
callback: refCallback(callback),
});
}
function updateModelWith(model, key, value, update, callback) {
sendMessage({
action: 'updateModelWith',
model: model,
key: key,
value: value,
update: update,
callback: refCallback(callback),
});
}
onMessage: {
if (messageObject.callback !== undefined) {
unrefCallback(messageObject.callback)();
}
}
}

View file

@ -1,42 +0,0 @@
function updateModelFrom(model, data) {
// TODO: This is very naive at the moment, we should do proper remove(),
// move(), set() and insert() calls, so that the UI can animate changes.
for (var i=0; i<data.length; i++) {
if (model.count < i) {
model.append(data[i]);
} else {
model.set(i, data[i]);
}
}
while (model.count > data.length) {
model.remove(model.count-1);
}
model.sync();
}
function updateModelWith(model, key, value, update) {
for (var row=0; row<model.count; row++) {
var current = model.get(row);
if (current[key] == value) {
for (var key in update) {
model.setProperty(row, key, update[key]);
}
}
}
model.sync();
}
WorkerScript.onMessage = function (msg) {
if (msg.action === 'updateModelFrom') {
updateModelFrom(msg.model, msg.data);
WorkerScript.sendMessage({callback: msg.callback});
} else if (msg.action === 'updateModelWith') {
updateModelWith(msg.model, msg.key, msg.value, msg.update);
WorkerScript.sendMessage({callback: msg.callback});
} else {
console.log('Unknown action: ' + msg.action);
}
}

View file

@ -18,6 +18,31 @@
*
*/
function updateModelFrom(model, data) {
for (var i=0; i<data.length; i++) {
if (model.count < i) {
model.append(data[i]);
} else {
model.set(i, data[i]);
}
}
while (model.count > data.length) {
model.remove(model.count-1);
}
}
function updateModelWith(model, key, value, update) {
for (var row=0; row<model.count; row++) {
var current = model.get(row);
if (current[key] == value) {
for (var key in update) {
model.setProperty(row, key, update[key]);
}
}
}
}
function formatDuration(duration) {
if (duration !== 0 && !duration) {
return ''

8
desktop/Pill.qml Normal file
View 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
}

View file

@ -0,0 +1,38 @@
import QtQuick 2.0
import QtQuick.Controls 1.0
import QtQuick.Layouts 1.0
import QtQuick.Dialogs 1.2
import '../common'
import '../common/util.js' as Util
Dialog {
property alias labelText: urlEntyLabel.text
signal addUrl(string url)
width: 300
height: 100
title: 'Add new podcast'
standardButtons: StandardButton.Open | StandardButton.Cancel
RowLayout {
anchors.fill: parent
Label {
id: urlEntyLabel
text: 'URL:'
}
TextField {
id: urlEntry
focus: true
Layout.fillWidth: true
}
}
onAccepted: {
addUrl(urlEntry.text);
visible = false;
}
}

View file

@ -0,0 +1,26 @@
import QtQuick 2.0
import QtQuick.Controls 1.0
import QtQuick.Layouts 1.0
import QtQuick.Dialogs 1.2
import '../common'
import '../common/util.js' as Util
Rectangle {
property var episode
color: '#aa000000'
anchors.fill: parent
MouseArea {
anchors.fill: parent
onClicked: parent.destroy();
}
TextArea {
anchors.fill: parent
anchors.margins: 50
readOnly: true
text: episode ? episode.description : '...'
}
}

View file

@ -1,11 +1,17 @@
import QtQuick 2.0
import QtQuick 2.3
import QtQuick.Controls 1.0
import QtQuick.Layouts 1.0
import QtQuick.Dialogs 1.2
import 'dialogs'
import 'common'
import 'common/util.js' as Util
import 'common/constants.js' as Constants
ApplicationWindow {
id: appWindow
width: 500
height: 400
@ -15,66 +21,339 @@ ApplicationWindow {
id: py
}
function openDialog(filename, callback) {
var component = Qt.createComponent(filename);
function createDialog() {
if (component.status === Component.Ready) {
var dialog = component.createObject(appWindow, {});
dialog.visible = true;
callback(dialog);
}
}
if (component.status == Component.Ready) {
createDialog();
} else {
component.statusChanged.connect(createDialog);
}
}
menuBar: MenuBar {
Menu { title: 'File'; MenuItem { text: 'Quit' } }
Menu {
title: 'File'
MenuItem {
text: 'Add podcast'
onTriggered: {
openDialog('dialogs/AddPodcastDialog.qml', function (dialog) {
dialog.addUrl.connect(function (url) {
py.call('main.subscribe', [url]);
});
});
}
}
MenuItem {
text: 'Add from OPML'
onTriggered: {
openDialog('dialogs/AddPodcastDialog.qml', function(dialog) {
dialog.title = "Add from OPML"
dialog.labelText = "OPML URL:"
dialog.addUrl.connect(function (url) {
py.call('main.import_opml', [url]);
})
})
}
}
MenuItem {
text: 'Quit'
onTriggered: Qt.quit()
}
}
}
SplitView {
anchors.fill: parent
TableView {
width: 200
model: GPodderPodcastListModel { id: podcastListModel }
GPodderPodcastListModelConnections {}
headerVisible: false
alternatingRowColors: false
ColumnLayout {
TableView {
Layout.fillHeight: true
Layout.fillWidth: true
rowDelegate: Rectangle {
height: 60
color: styleData.selected ? '#eee' : '#fff'
}
id: podcastListView
TableViewColumn {
role: 'coverart'
title: 'Image'
delegate: Item {
height: 60
width: 60
Image {
source: styleData.value
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]);
}
}
MenuItem {
text: 'Mark episodes as old'
onTriggered: {
var podcast_id = podcastListModel.get(podcastListView.currentRow).id;
py.call('main.mark_episodes_as_old', [podcast_id]);
}
}
}
rowDelegate: Rectangle {
height: 40
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
height: 50
anchors.centerIn: parent
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
}
}
}
width: 60
}
TableViewColumn {
role: 'title'
title: 'Podcast'
delegate: Item {
height: 60
Text {
text: styleData.value
anchors.verticalCenter: parent.verticalCenter
}
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
Layout.fillWidth: true
model: GPodderEpisodeListModel { id: episodeListModel }
GPodderEpisodeListModelConnections {}
selectionMode: SelectionMode.MultiSelection
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 {
}
}
}
}

151
main.py
View file

@ -19,7 +19,7 @@
# of gpodder-core, but we might have a different release schedule later on. If
# we decide to have parallel releases, we can at least start using this version
# to check if the core version is compatible with the QML UI version.
__version__ = '4.4.0'
__version__ = '4.6.0'
import sys
import os
@ -32,14 +32,17 @@ from gpodder.api import core
from gpodder.api import util
from gpodder.api import query
from gpodder.api import registry
from gpodder import opml
import logging
import functools
import time
import datetime
import re
logger = logging.getLogger(__name__)
def run_in_background_thread(f):
"""Decorator for functions that take longer to finish
@ -112,6 +115,7 @@ class gPotherSide:
'episodes': total,
'newEpisodes': new,
'downloaded': downloaded,
'unplayed': unplayed,
}
def _get_cover(self, podcast):
@ -132,8 +136,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,
@ -142,8 +148,7 @@ class gPotherSide:
def _get_podcasts_sorted(self):
sort_key = self.core.model.podcast_sort_key
return sorted(self.core.model.get_podcasts(),
key=lambda podcast: (podcast.section, sort_key(podcast)))
return sorted(self.core.model.get_podcasts(), key=lambda podcast: (podcast.section, sort_key(podcast)))
def load_podcasts(self):
podcasts = self._get_podcasts_sorted()
@ -217,6 +222,21 @@ class gPotherSide:
summary.sort(key=lambda e: e['newEpisodes'], reverse=True)
return summary[:int(count)]
@run_in_background_thread
def import_opml(self, url):
"""Import subscriptions from an OPML file
import http://example.com/subscriptions.opml
Import subscriptions from the given URL
import ./feeds.opml
Import subscriptions from a local file
"""
for channel in opml.Importer(url).items:
self.subscribe(channel['url'])
@run_in_background_thread
def subscribe(self, url):
url = self.core.model.normalize_feed_url(url)
@ -319,8 +339,7 @@ class gPotherSide:
try:
podcast.update()
except Exception as e:
logger.warn('Could not update %s: %s', podcast.url,
e, exc_info=True)
logger.warn('Could not update %s: %s', podcast.url, e, exc_info=True)
pyotherside.send('updated-podcast', self.convert_podcast(podcast))
pyotherside.send('update-stats')
@ -336,9 +355,7 @@ class gPotherSide:
return {
'title': episode.title,
'podcast_title': episode.podcast.title,
'source': episode.local_filename(False)
if episode.state == gpodder.STATE_DOWNLOADED
else episode.url,
'source': episode.local_filename(False) if episode.state == gpodder.STATE_DOWNLOADED else episode.url,
'position': episode.current_position,
'total': episode.total_time,
'video': episode.file_type() == 'video',
@ -372,7 +389,9 @@ class gPotherSide:
yield '%.2f MiB' % (episode.file_size / (1024 * 1024))
if episode.total_time > 0:
yield '%02d:%02d:%02d' % (episode.total_time / (60 * 60), (episode.total_time / 60) % 60, episode.total_time % 60)
yield '%02d:%02d:%02d' % (episode.total_time / (60 * 60),
(episode.total_time / 60) % 60,
episode.total_time % 60)
def show_podcast(self, podcast_id):
podcast = self._get_podcast_by_id(podcast_id)
@ -404,8 +423,7 @@ class gPotherSide:
return [{
'label': provider.name,
'can_search': provider.kind == provider.PROVIDER_SEARCH
} for provider in sorted(registry.directory.select(select_provider),
key=provider_sort_key, reverse=True)]
} for provider in sorted(registry.directory.select(select_provider), key=provider_sort_key, reverse=True)]
def get_directory_entries(self, provider, query):
def match_provider(p):
@ -422,6 +440,116 @@ class gPotherSide:
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()
pyotherside.atexit(gpotherside.atexit)
@ -433,6 +561,7 @@ load_podcasts = gpotherside.load_podcasts
load_episodes = gpotherside.load_episodes
show_episode = gpotherside.show_episode
play_episode = gpotherside.play_episode
import_opml = gpotherside.import_opml
subscribe = gpotherside.subscribe
unsubscribe = gpotherside.unsubscribe
check_for_episodes = gpotherside.check_for_episodes

View file

@ -1,5 +1,5 @@
PROJECT := gpodder-ui-qml
VERSION := 4.4.0
VERSION := 4.6.0
all:
@echo ""

2
setup.cfg Normal file
View file

@ -0,0 +1,2 @@
[pep8]
max-line-length = 120

View file

@ -73,7 +73,7 @@ SlidePage {
anchors.horizontalCenter: parent.horizontalCenter
wrapMode: Text.WordWrap
text: [
'© 2005-2014 Thomas Perl and the gPodder Team',
'© 2005-2015 Thomas Perl and the gPodder Team',
'License: ISC / GPLv3 or later',
'Website: http://gpodder.org/',
'',

View file

@ -135,22 +135,22 @@ Item {
Rectangle {
anchors {
top: parent.top
left: downloadIndicator.right
left: parent.left
}
height: Constants.layout.padding * pgst.scalef
width: (parent.width - downloadIndicator.width) * progress
width: parent.width * progress
color: Constants.colors.download
}
Rectangle {
anchors {
bottom: parent.bottom
left: downloadIndicator.right
left: parent.left
}
height: Constants.layout.padding * pgst.scalef
width: (parent.width - downloadIndicator.width) * playbackProgress
width: parent.width * playbackProgress
color: titleLabel.color
opacity: episodeItem.isPlaying ? 1 : .2
}
@ -163,27 +163,17 @@ Item {
right: parent.right
}
Rectangle {
RectangleIndicator {
id: downloadIndicator
width: Constants.layout.padding * pgst.scalef * (downloadState == Constants.state.downloaded)
Behavior on width { PropertyAnimation { } }
anchors {
top: parent.top
bottom: parent.bottom
left: parent.left
}
enabled: downloadState == Constants.state.downloaded
color: titleLabel.color
}
Column {
anchors {
left: parent.left
leftMargin: 2 * Constants.layout.padding * pgst.scalef
right: parent.right
leftMargin: Constants.layout.padding * pgst.scalef
right: downloadIndicator.left
rightMargin: Constants.layout.padding * pgst.scalef
verticalCenter: parent.verticalCenter
}

View file

@ -44,6 +44,10 @@ SlidePage {
title: 'Episodes'
section.property: 'section'
section.delegate: SectionHeader { text: section }
section.delegate: SectionHeader {
text: section
color: episodeList.selectedIndex === -1 ? Constants.colors.secondaryHighlight : Constants.colors.text
opacity: episodeList.selectedIndex === -1 ? 1 : 0.2
}
}
}

View file

@ -20,6 +20,8 @@
import QtQuick 2.0
import 'common/constants.js' as Constants
Item {
id: contextMenu
default property alias children: contextMenuRow.children
@ -27,6 +29,10 @@ Item {
Row {
id: contextMenuRow
anchors.centerIn: parent
anchors {
verticalCenter: parent.verticalCenter
right: parent.right
margins: Constants.layout.padding * pgst.scalef
}
}
}

View file

@ -125,7 +125,7 @@ Item {
pgst.hasBackButton = Qt.binding(function () { return page.isDialog || page.canClose; });
pgst.hasMenuButton = Qt.binding(function () { return !page.isDialog && page.hasMenuButton; });
pgst.menuButtonLabel = Qt.binding(function () { return (!page.isDialog && pgst.hasMenuButton) ? page.menuButtonLabel : 'Menu'; });
pgst.menuButtonIcon = Qt.binding(function () { return (!page.isDialog && pgst.hasMenuButton) ? page.menuButtonIcon : Icons.vellipsis; });
pgst.menuButtonIcon = Qt.binding(function () { return (!page.isDialog && pgst.hasMenuButton) ? page.menuButtonIcon : Icons.stack; });
if (!page.isDialog) {
pgst.windowTitle = page.title || 'gPodder';

View file

@ -30,6 +30,19 @@ ListView {
boundsBehavior: Flickable.StopAtBounds
function relayout() {
var _contentY = contentY;
var _model = model;
model = null;
model = _model;
contentY = _contentY;
}
Connections {
target: pgst
onScalefChanged: relayout();
}
header: SlidePageHeader {
id: header
title: pListView.title

View file

@ -28,6 +28,7 @@ Item {
property real value
property real min: 0.0
property real max: 1.0
property real step: 0.0
property color color: Constants.colors.highlight
property real displayedValue: mouseArea.pressed ? temporaryValue : value
@ -42,13 +43,22 @@ Item {
MouseArea {
id: mouseArea
anchors.fill: parent
onClicked: slider.valueChangeRequested(min + (max - min) * (mouse.x / width))
onPressed: {
slider.temporaryValue = (min + (max - min) * (mouse.x / width));
}
onPositionChanged: {
slider.temporaryValue = (min + (max - min) * (mouse.x / width));
function updateValue(x) {
if (x > width) {
x = width;
} else if (x < 0) {
x = 0;
}
var v = (max - min) * (x / width);
if (slider.step > 0.0) {
v = slider.step * parseInt(0.5 + v / slider.step);
}
slider.temporaryValue = min + v;
return slider.temporaryValue;
}
onClicked: slider.valueChangeRequested(updateValue(mouse.x));
onPressed: updateValue(mouse.x);
onPositionChanged: updateValue(mouse.x);
preventStealing: true
}
@ -68,4 +78,14 @@ Item {
verticalCenter: parent.verticalCenter
}
}
Repeater {
model: slider.step ? ((slider.max - slider.min) / slider.step - 1) : 0
delegate: Rectangle {
width: Math.max(1, 1 * pgst.scalef)
height: parent.height
color: Constants.colors.area
x: parent.width * ((slider.step * (1 + index)) / (slider.max - slider.min));
}
}
}

View file

@ -50,6 +50,7 @@ Item {
right: clipboardIcon.left
margins: 5 * pgst.scalef
}
clip: true
inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText

View file

@ -33,19 +33,6 @@ ButtonArea {
right: parent.right
}
Rectangle {
width: Constants.layout.padding * pgst.scalef * (newEpisodes > 0)
Behavior on width { PropertyAnimation { } }
anchors {
top: cover.top
bottom: cover.bottom
left: parent.left
}
color: Constants.colors.fresh
}
CoverArt {
id: cover
visible: !updating
@ -85,7 +72,7 @@ ButtonArea {
PLabel {
id: downloadsLabel
anchors {
right: parent.right
right: newEpisodesIndicator.enabled ? newEpisodesIndicator.left : parent.right
rightMargin: Constants.layout.padding * pgst.scalef
verticalCenter: parent.verticalCenter
}
@ -93,4 +80,10 @@ ButtonArea {
text: downloaded ? downloaded : ''
color: Constants.colors.text
}
RectangleIndicator {
id: newEpisodesIndicator
enabled: newEpisodes > 0
color: Constants.colors.fresh
}
}

View file

@ -47,9 +47,9 @@ SlidePage {
}
},
{
label: 'About',
label: 'Settings',
callback: function () {
pgst.loadPage('AboutPage.qml');
pgst.loadPage('SettingsPage.qml');
},
},
{
@ -66,6 +66,20 @@ SlidePage {
});
},
},
{
label: 'Add from OPML',
callback: function () {
var ctx = { py: py };
pgst.loadPage('TextInputDialog.qml', {
buttonText: 'Subscribe',
placeholderText: 'OPML URL',
pasteOnLoad: true,
callback: function (url) {
ctx.py.call('main.import_opml', [url]);
}
});
},
},
{
label: 'Discover new podcasts',
callback: function () {

View file

@ -0,0 +1,36 @@
/**
*
* gPodder QML UI Reference Implementation
* Copyright (c) 2015, Thomas Perl <m@thp.io>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*
*/
import QtQuick 2.0
import 'common/constants.js' as Constants
Rectangle {
width: Constants.layout.padding * 2 * pgst.scalef * enabled
height: width
Behavior on width { PropertyAnimation { } }
anchors {
verticalCenter: parent.verticalCenter
right: parent.right
margins: Constants.layout.padding * 2 * pgst.scalef - width / 2
}
}

View file

@ -24,15 +24,29 @@ import 'common/constants.js' as Constants
Item {
property alias text: pLabel.text
property alias color: pLabel.color
height: 70 * pgst.scalef
width: parent.width
Rectangle {
anchors {
verticalCenter: parent.verticalCenter
left: parent.left
right: pLabel.left
margins: Constants.layout.padding * pgst.scalef
}
color: pLabel.color
height: 1 * pgst.scalef
}
PLabel {
id: pLabel
anchors {
left: parent.left
bottom: parent.bottom
margins: 10 * pgst.scalef
right: parent.right
verticalCenter: parent.verticalCenter
margins: Constants.layout.padding * pgst.scalef
}
color: Constants.colors.secondaryHighlight

32
touch/SettingsLabel.qml Normal file
View file

@ -0,0 +1,32 @@
/**
*
* gPodder QML UI Reference Implementation
* Copyright (c) 2015, Thomas Perl <m@thp.io>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*
*/
import QtQuick 2.0
import 'common/constants.js' as Constants
PLabel {
anchors {
left: parent.left
right: parent.right
margins: Constants.layout.padding * pgst.scalef
}
color: Constants.colors.secondaryHighlight
}

113
touch/SettingsPage.qml Normal file
View file

@ -0,0 +1,113 @@
/**
*
* gPodder QML UI Reference Implementation
* Copyright (c) 2015, Thomas Perl <m@thp.io>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*
*/
import QtQuick 2.0
import 'common/constants.js' as Constants
SlidePage {
id: page
Component.onCompleted: {
py.getConfig('plugins.youtube.api_key_v3', function (value) {
youtube_api_key_v3.text = value;
});
py.getConfig('limit.episodes', function (value) {
limit_episodes.value = value;
});
}
Component.onDestruction: {
py.setConfig('plugins.youtube.api_key_v3', youtube_api_key_v3.text);
py.setConfig('limit.episodes', parseInt(limit_episodes.value));
}
Flickable {
id: flickable
anchors.fill: parent
boundsBehavior: Flickable.StopAtBounds
contentWidth: detailColumn.width
contentHeight: detailColumn.height + detailColumn.spacing
Column {
id: detailColumn
width: page.width
spacing: 15 * pgst.scalef
SlidePageHeader { title: 'Settings' }
SectionHeader { text: 'YouTube' }
SettingsLabel { text: 'API Key (v3)' }
PTextField {
id: youtube_api_key_v3
anchors {
left: parent.left
right: parent.right
margins: Constants.layout.padding * pgst.scalef
}
}
SectionHeader { text: 'Limits' }
SettingsLabel { text: 'Maximum episodes per feed' }
PSlider {
id: limit_episodes
min: 100
step: 100
max: 1000
anchors {
left: parent.left
right: parent.right
margins: Constants.layout.padding * pgst.scalef
}
onValueChangeRequested: { value = newValue; }
}
PLabel {
text: parseInt(limit_episodes.displayedValue)
anchors {
left: parent.left
right: parent.right
margins: Constants.layout.padding * pgst.scalef
}
}
SectionHeader { text: 'About' }
ButtonArea {
width: parent.width
height: Constants.layout.item.height * pgst.scalef
PLabel {
anchors.centerIn: parent
text: 'About gPodder ' + py.uiversion
}
onClicked: pgst.loadPage('AboutPage.qml')
}
}
}
PScrollDecorator { flickable: flickable }
}

View file

@ -36,7 +36,7 @@ Rectangle {
property string title: ''
property bool hasMenuButton: false
property string menuButtonLabel: 'Menu'
property string menuButtonIcon: Icons.vellipsis
property string menuButtonIcon: Icons.stack
signal menuButtonClicked()
function closePage() {

View file

@ -19,8 +19,8 @@ var folder = '\ue065';
var magnifying_glass = '\ue074';
var cog = '\u2699';
var link = '\ue077';
var vellipsis = '\u22ee';
var paperclip = '\ue08a';
var tag_fill = '\ue02b';
var headphones = '\ue061';
var sleep = '\u263e';
var stack = '\ue020';