Compare commits
15 commits
f/experime
...
master
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8aab9adb4d | ||
![]() |
1386245b50 | ||
![]() |
f3fb2585dc | ||
![]() |
cfed87916b | ||
![]() |
5186558174 | ||
![]() |
fa280e9ef5 | ||
![]() |
4a0b3f1b12 | ||
![]() |
ab4dfc611b | ||
![]() |
ad275cf1fb | ||
![]() |
bfb22d0d3f | ||
![]() |
1fb4c0df40 | ||
![]() |
7a1c0c6173 | ||
![]() |
e576909235 | ||
![]() |
953905ba3b | ||
![]() |
d4ca82e6d3 |
31 changed files with 869 additions and 232 deletions
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
8
desktop/Pill.qml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import QtQuick 2.0
|
||||||
|
|
||||||
|
Image {
|
||||||
|
property int leftCount: 0
|
||||||
|
property int rightCount: 0
|
||||||
|
source: 'image://python/pill/' + leftCount + '/' + rightCount
|
||||||
|
cache: true
|
||||||
|
}
|
38
desktop/dialogs/AddPodcastDialog.qml
Normal file
38
desktop/dialogs/AddPodcastDialog.qml
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
26
desktop/dialogs/EpisodeDetailsDialog.qml
Normal file
26
desktop/dialogs/EpisodeDetailsDialog.qml
Normal 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 : '...'
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
151
main.py
|
@ -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
|
||||||
|
|
2
makefile
2
makefile
|
@ -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
2
setup.cfg
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[pep8]
|
||||||
|
max-line-length = 120
|
|
@ -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/',
|
||||||
'',
|
'',
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
36
touch/RectangleIndicator.qml
Normal file
36
touch/RectangleIndicator.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
32
touch/SettingsLabel.qml
Normal 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
113
touch/SettingsPage.qml
Normal 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 }
|
||||||
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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';
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue