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: { onReceived: {
console.log('unhandled message: ' + data); console.log('unhandled message: ' + data);
} }

View file

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

View file

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

View file

@ -26,9 +26,11 @@ Item {
property bool android: (typeof(gpodderAndroid) !== 'undefined') || emulatingAndroid property bool android: (typeof(gpodderAndroid) !== 'undefined') || emulatingAndroid
property bool needsBackButton: !android property bool needsBackButton: !android
property bool toolbarOnTop: android
property bool toolbarOnTop: true
property bool invertedToolbar: toolbarOnTop property bool invertedToolbar: toolbarOnTop
property bool titleInToolbar: 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 { ListModel {
id: podcastListModel id: podcastListModel
property var worker: ModelWorkerScript {
id: modelWorker
}
function reload() { function reload() {
py.call('main.load_podcasts', [], function (podcasts) { 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) { function formatDuration(duration) {
if (duration !== 0 && !duration) { if (duration !== 0 && !duration) {
return '' 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.Controls 1.0
import QtQuick.Layouts 1.0 import QtQuick.Layouts 1.0
import QtQuick.Dialogs 1.2
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
width: 500 width: 500
height: 400 height: 400
@ -15,66 +21,339 @@ ApplicationWindow {
id: py 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 { 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 { SplitView {
anchors.fill: parent anchors.fill: parent
TableView { ColumnLayout {
width: 200 TableView {
model: GPodderPodcastListModel { id: podcastListModel } Layout.fillHeight: true
GPodderPodcastListModelConnections {} Layout.fillWidth: true
headerVisible: false
alternatingRowColors: false
rowDelegate: Rectangle { id: podcastListView
height: 60
color: styleData.selected ? '#eee' : '#fff'
}
TableViewColumn { model: GPodderPodcastListModel { id: podcastListModel }
role: 'coverart' GPodderPodcastListModelConnections {}
title: 'Image' headerVisible: false
delegate: Item { alternatingRowColors: false
height: 60 horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff
width: 60
Image { Menu {
source: styleData.value 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 width: 50
height: 50 property var row: podcastListModel.get(styleData.row)
anchors.centerIn: parent
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 onCurrentRowChanged: {
} var id = podcastListModel.get(currentRow).id;
episodeListModel.loadEpisodes(id);
TableViewColumn {
role: 'title'
title: 'Podcast'
delegate: Item {
height: 60
Text {
text: styleData.value
anchors.verticalCenter: parent.verticalCenter
}
} }
} }
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
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 # 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 # 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. # to check if the core version is compatible with the QML UI version.
__version__ = '4.4.0' __version__ = '4.6.0'
import sys import sys
import os import os
@ -32,14 +32,17 @@ from gpodder.api import core
from gpodder.api import util from gpodder.api import util
from gpodder.api import query from gpodder.api import query
from gpodder.api import registry from gpodder.api import registry
from gpodder import opml
import logging import logging
import functools import functools
import time import time
import datetime import datetime
import re
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def run_in_background_thread(f): def run_in_background_thread(f):
"""Decorator for functions that take longer to finish """Decorator for functions that take longer to finish
@ -112,6 +115,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):
@ -132,8 +136,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,
@ -142,8 +148,7 @@ class gPotherSide:
def _get_podcasts_sorted(self): def _get_podcasts_sorted(self):
sort_key = self.core.model.podcast_sort_key sort_key = self.core.model.podcast_sort_key
return sorted(self.core.model.get_podcasts(), return sorted(self.core.model.get_podcasts(), key=lambda podcast: (podcast.section, sort_key(podcast)))
key=lambda podcast: (podcast.section, sort_key(podcast)))
def load_podcasts(self): def load_podcasts(self):
podcasts = self._get_podcasts_sorted() podcasts = self._get_podcasts_sorted()
@ -217,6 +222,21 @@ class gPotherSide:
summary.sort(key=lambda e: e['newEpisodes'], reverse=True) summary.sort(key=lambda e: e['newEpisodes'], reverse=True)
return summary[:int(count)] 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 @run_in_background_thread
def subscribe(self, url): def subscribe(self, url):
url = self.core.model.normalize_feed_url(url) url = self.core.model.normalize_feed_url(url)
@ -319,8 +339,7 @@ class gPotherSide:
try: try:
podcast.update() podcast.update()
except Exception as e: except Exception as e:
logger.warn('Could not update %s: %s', podcast.url, logger.warn('Could not update %s: %s', podcast.url, e, exc_info=True)
e, exc_info=True)
pyotherside.send('updated-podcast', self.convert_podcast(podcast)) pyotherside.send('updated-podcast', self.convert_podcast(podcast))
pyotherside.send('update-stats') pyotherside.send('update-stats')
@ -336,9 +355,7 @@ class gPotherSide:
return { return {
'title': episode.title, 'title': episode.title,
'podcast_title': episode.podcast.title, 'podcast_title': episode.podcast.title,
'source': episode.local_filename(False) 'source': episode.local_filename(False) if episode.state == gpodder.STATE_DOWNLOADED else episode.url,
if episode.state == gpodder.STATE_DOWNLOADED
else episode.url,
'position': episode.current_position, 'position': episode.current_position,
'total': episode.total_time, 'total': episode.total_time,
'video': episode.file_type() == 'video', 'video': episode.file_type() == 'video',
@ -372,7 +389,9 @@ class gPotherSide:
yield '%.2f MiB' % (episode.file_size / (1024 * 1024)) yield '%.2f MiB' % (episode.file_size / (1024 * 1024))
if episode.total_time > 0: 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): def show_podcast(self, podcast_id):
podcast = self._get_podcast_by_id(podcast_id) podcast = self._get_podcast_by_id(podcast_id)
@ -404,8 +423,7 @@ class gPotherSide:
return [{ return [{
'label': provider.name, 'label': provider.name,
'can_search': provider.kind == provider.PROVIDER_SEARCH 'can_search': provider.kind == provider.PROVIDER_SEARCH
} for provider in sorted(registry.directory.select(select_provider), } for provider in sorted(registry.directory.select(select_provider), key=provider_sort_key, reverse=True)]
key=provider_sort_key, reverse=True)]
def get_directory_entries(self, provider, query): def get_directory_entries(self, provider, query):
def match_provider(p): def match_provider(p):
@ -422,6 +440,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)
@ -433,6 +561,7 @@ load_podcasts = gpotherside.load_podcasts
load_episodes = gpotherside.load_episodes load_episodes = gpotherside.load_episodes
show_episode = gpotherside.show_episode show_episode = gpotherside.show_episode
play_episode = gpotherside.play_episode play_episode = gpotherside.play_episode
import_opml = gpotherside.import_opml
subscribe = gpotherside.subscribe subscribe = gpotherside.subscribe
unsubscribe = gpotherside.unsubscribe unsubscribe = gpotherside.unsubscribe
check_for_episodes = gpotherside.check_for_episodes check_for_episodes = gpotherside.check_for_episodes

View file

@ -1,5 +1,5 @@
PROJECT := gpodder-ui-qml PROJECT := gpodder-ui-qml
VERSION := 4.4.0 VERSION := 4.6.0
all: all:
@echo "" @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 anchors.horizontalCenter: parent.horizontalCenter
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
text: [ text: [
'© 2005-2014 Thomas Perl and the gPodder Team', '© 2005-2015 Thomas Perl and the gPodder Team',
'License: ISC / GPLv3 or later', 'License: ISC / GPLv3 or later',
'Website: http://gpodder.org/', 'Website: http://gpodder.org/',
'', '',

View file

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

View file

@ -44,6 +44,10 @@ SlidePage {
title: 'Episodes' title: 'Episodes'
section.property: 'section' 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 QtQuick 2.0
import 'common/constants.js' as Constants
Item { Item {
id: contextMenu id: contextMenu
default property alias children: contextMenuRow.children default property alias children: contextMenuRow.children
@ -27,6 +29,10 @@ Item {
Row { Row {
id: contextMenuRow 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.hasBackButton = Qt.binding(function () { return page.isDialog || page.canClose; });
pgst.hasMenuButton = Qt.binding(function () { return !page.isDialog && page.hasMenuButton; }); pgst.hasMenuButton = Qt.binding(function () { return !page.isDialog && page.hasMenuButton; });
pgst.menuButtonLabel = Qt.binding(function () { return (!page.isDialog && pgst.hasMenuButton) ? page.menuButtonLabel : 'Menu'; }); 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) { if (!page.isDialog) {
pgst.windowTitle = page.title || 'gPodder'; pgst.windowTitle = page.title || 'gPodder';

View file

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

View file

@ -28,6 +28,7 @@ Item {
property real value property real value
property real min: 0.0 property real min: 0.0
property real max: 1.0 property real max: 1.0
property real step: 0.0
property color color: Constants.colors.highlight property color color: Constants.colors.highlight
property real displayedValue: mouseArea.pressed ? temporaryValue : value property real displayedValue: mouseArea.pressed ? temporaryValue : value
@ -42,13 +43,22 @@ Item {
MouseArea { MouseArea {
id: mouseArea id: mouseArea
anchors.fill: parent anchors.fill: parent
onClicked: slider.valueChangeRequested(min + (max - min) * (mouse.x / width)) function updateValue(x) {
onPressed: { if (x > width) {
slider.temporaryValue = (min + (max - min) * (mouse.x / width)); x = width;
} } else if (x < 0) {
onPositionChanged: { x = 0;
slider.temporaryValue = (min + (max - min) * (mouse.x / width)); }
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 preventStealing: true
} }
@ -68,4 +78,14 @@ Item {
verticalCenter: parent.verticalCenter 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 right: clipboardIcon.left
margins: 5 * pgst.scalef margins: 5 * pgst.scalef
} }
clip: true
inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText

View file

@ -33,19 +33,6 @@ ButtonArea {
right: parent.right 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 { CoverArt {
id: cover id: cover
visible: !updating visible: !updating
@ -85,7 +72,7 @@ ButtonArea {
PLabel { PLabel {
id: downloadsLabel id: downloadsLabel
anchors { anchors {
right: parent.right right: newEpisodesIndicator.enabled ? newEpisodesIndicator.left : parent.right
rightMargin: Constants.layout.padding * pgst.scalef rightMargin: Constants.layout.padding * pgst.scalef
verticalCenter: parent.verticalCenter verticalCenter: parent.verticalCenter
} }
@ -93,4 +80,10 @@ ButtonArea {
text: downloaded ? downloaded : '' text: downloaded ? downloaded : ''
color: Constants.colors.text 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 () { 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', label: 'Discover new podcasts',
callback: function () { 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 { Item {
property alias text: pLabel.text property alias text: pLabel.text
property alias color: pLabel.color
height: 70 * pgst.scalef 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 { PLabel {
id: pLabel id: pLabel
anchors { anchors {
left: parent.left right: parent.right
bottom: parent.bottom verticalCenter: parent.verticalCenter
margins: 10 * pgst.scalef margins: Constants.layout.padding * pgst.scalef
} }
color: Constants.colors.secondaryHighlight 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 string title: ''
property bool hasMenuButton: false property bool hasMenuButton: false
property string menuButtonLabel: 'Menu' property string menuButtonLabel: 'Menu'
property string menuButtonIcon: Icons.vellipsis property string menuButtonIcon: Icons.stack
signal menuButtonClicked() signal menuButtonClicked()
function closePage() { function closePage() {

View file

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