Compare commits

..

1 commit

Author SHA1 Message Date
Thomas Perl
e95ec1f76a Experimental tablet UI 2015-03-07 17:38:12 +01:00
34 changed files with 322 additions and 882 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,67 @@
/**
*
* 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)();
}
}
}

42
common/modelworker.js Normal file
View file

@ -0,0 +1,42 @@
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,31 +18,6 @@
*
*/
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 ''

View file

@ -1,8 +0,0 @@
import QtQuick 2.0
Image {
property int leftCount: 0
property int rightCount: 0
source: 'image://python/pill/' + leftCount + '/' + rightCount
cache: true
}

View file

@ -1,38 +0,0 @@
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

@ -1,26 +0,0 @@
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,17 +1,11 @@
import QtQuick 2.3
import QtQuick 2.0
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
@ -21,339 +15,66 @@ 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: '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()
}
}
Menu { title: 'File'; MenuItem { text: 'Quit' } }
}
SplitView {
anchors.fill: parent
ColumnLayout {
TableView {
Layout.fillHeight: true
Layout.fillWidth: true
TableView {
width: 200
model: GPodderPodcastListModel { id: podcastListModel }
GPodderPodcastListModelConnections {}
headerVisible: false
alternatingRowColors: false
id: podcastListView
rowDelegate: Rectangle {
height: 60
color: styleData.selected ? '#eee' : '#fff'
}
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
TableViewColumn {
role: 'coverart'
title: 'Image'
delegate: Item {
height: 60
width: 60
Image {
source: styleData.value
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
}
height: 50
anchors.centerIn: parent
}
}
onCurrentRowChanged: {
var id = podcastListModel.get(currentRow).id;
episodeListModel.loadEpisodes(id);
width: 60
}
TableViewColumn {
role: 'title'
title: 'Podcast'
delegate: Item {
height: 60
Text {
text: styleData.value
anchors.verticalCenter: parent.verticalCenter
}
}
}
Button {
Layout.fillWidth: true
Layout.margins: 3
text: 'Check for new episodes'
onClicked: py.call('main.check_for_episodes');
enabled: !py.refreshing
onCurrentRowChanged: {
var id = podcastListModel.get(currentRow).id;
episodeListModel.loadEpisodes(id);
}
}
SplitView {
orientation: Orientation.Vertical
TableView {
Layout.fillWidth: true
model: GPodderEpisodeListModel { id: episodeListModel }
GPodderEpisodeListModelConnections {}
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 {
}
TableViewColumn { role: 'title'; title: 'Title' }
}
}
}

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.6.0'
__version__ = '4.4.0'
import sys
import os
@ -32,17 +32,14 @@ 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
@ -115,7 +112,6 @@ class gPotherSide:
'episodes': total,
'newEpisodes': new,
'downloaded': downloaded,
'unplayed': unplayed,
}
def _get_cover(self, podcast):
@ -136,10 +132,8 @@ 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,
@ -148,7 +142,8 @@ 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()
@ -222,21 +217,6 @@ 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)
@ -339,7 +319,8 @@ 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')
@ -355,7 +336,9 @@ 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',
@ -389,9 +372,7 @@ 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)
@ -423,7 +404,8 @@ 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):
@ -440,116 +422,6 @@ 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)
@ -561,7 +433,6 @@ 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.6.0
VERSION := 4.4.0
all:
@echo ""

View file

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

View file

@ -73,7 +73,7 @@ SlidePage {
anchors.horizontalCenter: parent.horizontalCenter
wrapMode: Text.WordWrap
text: [
'© 2005-2015 Thomas Perl and the gPodder Team',
'© 2005-2014 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: parent.left
left: downloadIndicator.right
}
height: Constants.layout.padding * pgst.scalef
width: parent.width * progress
width: (parent.width - downloadIndicator.width) * progress
color: Constants.colors.download
}
Rectangle {
anchors {
bottom: parent.bottom
left: parent.left
left: downloadIndicator.right
}
height: Constants.layout.padding * pgst.scalef
width: parent.width * playbackProgress
width: (parent.width - downloadIndicator.width) * playbackProgress
color: titleLabel.color
opacity: episodeItem.isPlaying ? 1 : .2
}
@ -163,17 +163,27 @@ Item {
right: parent.right
}
RectangleIndicator {
Rectangle {
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
}
Column {
anchors {
left: parent.left
leftMargin: Constants.layout.padding * pgst.scalef
right: downloadIndicator.left
leftMargin: 2 * Constants.layout.padding * pgst.scalef
right: parent.right
rightMargin: Constants.layout.padding * pgst.scalef
verticalCenter: parent.verticalCenter
}

View file

@ -29,6 +29,7 @@ PListView {
id: episodeList
property int selectedIndex: -1
property alias podcast_id: episodeListModel.podcast_id
PScrollIntoView { id: scrollIntoView }
@ -42,14 +43,14 @@ PListView {
GPodderEpisodeListModelConnections {}
PBusyIndicator {
visible: !episodeListModel.ready
visible: !episodeListModel.ready && episodeListModel.podcast_id
anchors.centerIn: parent
}
PPlaceholder {
// TODO: If filter is "all", say "No episodes"
text: 'No episodes found'
visible: episodeList.count === 0 && episodeListModel.ready
text: episodeListModel.podcast_id ? 'No episodes found' : 'No podcast selected'
visible: (episodeList.count === 0 && episodeListModel.ready) || !episodeListModel.podcast_id
}
delegate: EpisodeItem { }

View file

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

View file

@ -80,12 +80,6 @@ SlidePage {
], undefined, undefined, true);
}
Component.onCompleted: {
episodeList.model.podcast_id = podcast_id;
// List model will be loaded automatically on load
}
EpisodeQueryControl {
id: queryControl
model: episodeList.model
@ -95,5 +89,6 @@ SlidePage {
EpisodeListView {
id: episodeList
title: page.title
podcast_id: page.podcast_id
}
}

View file

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

View file

@ -68,7 +68,7 @@ Item {
// Initial focus
focus: true
property real scalef: (width < height) ? (width / 480) : (height / 480)
property real scalef: 1.0 //(width < height) ? (width / 480) : (height / 480)
property int shorterSide: (width < height) ? width : height
property int dialogsVisible: 0
@ -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.stack; });
pgst.menuButtonIcon = Qt.binding(function () { return (!page.isDialog && pgst.hasMenuButton) ? page.menuButtonIcon : Icons.vellipsis; });
if (!page.isDialog) {
pgst.windowTitle = page.title || 'gPodder';

View file

@ -30,19 +30,6 @@ 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,7 +28,6 @@ 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
@ -43,22 +42,13 @@ Item {
MouseArea {
id: mouseArea
anchors.fill: parent
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(min + (max - min) * (mouse.x / width))
onPressed: {
slider.temporaryValue = (min + (max - min) * (mouse.x / width));
}
onPositionChanged: {
slider.temporaryValue = (min + (max - min) * (mouse.x / width));
}
onClicked: slider.valueChangeRequested(updateValue(mouse.x));
onPressed: updateValue(mouse.x);
onPositionChanged: updateValue(mouse.x);
preventStealing: true
}
@ -78,14 +68,4 @@ 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,7 +50,6 @@ Item {
right: clipboardIcon.left
margins: 5 * pgst.scalef
}
clip: true
inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText

View file

@ -33,6 +33,19 @@ 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
@ -72,7 +85,7 @@ ButtonArea {
PLabel {
id: downloadsLabel
anchors {
right: newEpisodesIndicator.enabled ? newEpisodesIndicator.left : parent.right
right: parent.right
rightMargin: Constants.layout.padding * pgst.scalef
verticalCenter: parent.verticalCenter
}
@ -80,10 +93,4 @@ ButtonArea {
text: downloaded ? downloaded : ''
color: Constants.colors.text
}
RectangleIndicator {
id: newEpisodesIndicator
enabled: newEpisodes > 0
color: Constants.colors.fresh
}
}

View file

@ -25,8 +25,9 @@ import 'common/util.js' as Util
import 'icons/icons.js' as Icons
import 'common/constants.js' as Constants
SlidePage {
SplitPage {
id: page
position: 0.4
canClose: false
@ -47,9 +48,9 @@ SlidePage {
}
},
{
label: 'Settings',
label: 'About',
callback: function () {
pgst.loadPage('SettingsPage.qml');
pgst.loadPage('AboutPage.qml');
},
},
{
@ -66,20 +67,6 @@ 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 () {
@ -105,67 +92,80 @@ SlidePage {
], undefined, undefined, true);
}
PListView {
id: podcastList
title: 'Subscriptions'
SplitPane {
PListView {
id: podcastList
title: 'Subscriptions'
section.property: 'section'
section.delegate: SectionHeader { text: section }
section.property: 'section'
section.delegate: SectionHeader { text: section }
PPlaceholder {
text: 'No podcasts'
visible: podcastList.count === 0
}
PPlaceholder {
text: 'No podcasts'
visible: podcastList.count === 0
}
model: podcastListModel
model: podcastListModel
delegate: PodcastItem {
onClicked: pgst.loadPage('EpisodesPage.qml', {'podcast_id': id, 'title': title});
onPressAndHold: {
pgst.showSelection([
{
label: 'Refresh',
callback: function () {
py.call('main.check_for_episodes', [url]);
delegate: PodcastItem {
onClicked: {
episodesPage.podcast_id = id;
episodesPage.title = title;
}//pgst.loadPage('EpisodesPage.qml', {'podcast_id': id, 'title': title});
onPressAndHold: {
pgst.showSelection([
{
label: 'Refresh',
callback: function () {
py.call('main.check_for_episodes', [url]);
},
},
},
{
label: 'Unsubscribe',
callback: function () {
var ctx = { py: py, id: id };
pgst.showConfirmation(title, 'Unsubscribe', 'Cancel', 'Remove this podcast and all downloaded episodes?', Icons.trash, function () {
ctx.py.call('main.unsubscribe', [ctx.id]);
});
{
label: 'Unsubscribe',
callback: function () {
var ctx = { py: py, id: id };
pgst.showConfirmation(title, 'Unsubscribe', 'Cancel', 'Remove this podcast and all downloaded episodes?', Icons.trash, function () {
ctx.py.call('main.unsubscribe', [ctx.id]);
});
},
},
},
{
label: 'Rename',
callback: function () {
var ctx = { py: py, id: id };
pgst.loadPage('TextInputDialog.qml', {
buttonText: 'Rename',
placeholderText: 'New name',
text: title,
callback: function (new_title) {
ctx.py.call('main.rename_podcast', [ctx.id, new_title]);
}
});
}
},
{
label: 'Mark episodes as old',
callback: function () {
py.call('main.mark_episodes_as_old', [id]);
{
label: 'Rename',
callback: function () {
var ctx = { py: py, id: id };
pgst.loadPage('TextInputDialog.qml', {
buttonText: 'Rename',
placeholderText: 'New name',
text: title,
callback: function (new_title) {
ctx.py.call('main.rename_podcast', [ctx.id, new_title]);
}
});
}
},
},
{
label: 'Podcast details',
callback: function () {
pgst.loadPage('PodcastDetail.qml', {podcast_id: id, title: title});
}
},
], title);
{
label: 'Mark episodes as old',
callback: function () {
py.call('main.mark_episodes_as_old', [id]);
},
},
{
label: 'Podcast details',
callback: function () {
pgst.loadPage('PodcastDetail.qml', {podcast_id: id, title: title});
}
},
], title);
}
}
}
}
SplitPane {
EpisodesPage {
id: episodesPage
anchors.fill: parent
}
}
}

View file

@ -24,29 +24,15 @@ 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 {
right: parent.right
verticalCenter: parent.verticalCenter
margins: Constants.layout.padding * pgst.scalef
left: parent.left
bottom: parent.bottom
margins: 10 * pgst.scalef
}
color: Constants.colors.secondaryHighlight

View file

@ -1,113 +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
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.stack
property string menuButtonIcon: Icons.vellipsis
signal menuButtonClicked()
function closePage() {

View file

@ -22,15 +22,13 @@ import QtQuick 2.0
import 'common/constants.js' as Constants
Rectangle {
width: Constants.layout.padding * 2 * pgst.scalef * enabled
height: width
SlidePage {
id: splitPage
property real position: 0.5
Behavior on width { PropertyAnimation { } }
anchors {
verticalCenter: parent.verticalCenter
right: parent.right
margins: Constants.layout.padding * 2 * pgst.scalef - width / 2
Component.onCompleted: {
children[0].width = Qt.binding(function () { return splitPage.width * splitPage.position; });
children[1].width = Qt.binding(function () { return splitPage.width * (1.0 - splitPage.position); });
children[1].x = Qt.binding(function () { return children[0].width; });
}
}

View file

@ -1,4 +1,5 @@
/**
*
* gPodder QML UI Reference Implementation
@ -20,13 +21,9 @@
import QtQuick 2.0
import 'common/constants.js' as Constants
PLabel {
Item {
anchors {
left: parent.left
right: parent.right
margins: Constants.layout.padding * pgst.scalef
top: parent.top
bottom: parent.bottom
}
color: Constants.colors.secondaryHighlight
}

View file

@ -24,7 +24,7 @@ import 'common/constants.js' as Constants
Rectangle {
color: Constants.colors.page
width: 480
width: 1280
height: 800
Main {}

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';