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

View file

@ -62,6 +62,14 @@ ListModel {
}); });
} }
onPodcast_idChanged: {
reload();
}
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--) {
@ -122,11 +130,12 @@ 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) {
Util.updateModelFrom(episodeListModel, episodes); modelWorker.updateModelFrom(episodeListModel, episodes, function () {
episodeListModel.ready = true; episodeListModel.ready = true;
if (callback !== undefined) { if (callback !== undefined) {
callback(); callback();
} }
});
}); });
} }
} }

View file

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

View file

@ -26,11 +26,9 @@ 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 floatingPlayButton: true property bool hideDisabledMenu: android
property bool hideDisabledMenu: true
} }

View file

@ -25,9 +25,13 @@ 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) {
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) { function formatDuration(duration) {
if (duration !== 0 && !duration) { if (duration !== 0 && !duration) {
return '' 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.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
@ -21,339 +15,66 @@ 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 { Menu { title: 'File'; MenuItem { text: 'Quit' } }
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
ColumnLayout { TableView {
TableView { width: 200
Layout.fillHeight: true model: GPodderPodcastListModel { id: podcastListModel }
Layout.fillWidth: true GPodderPodcastListModelConnections {}
headerVisible: false
alternatingRowColors: false
id: podcastListView rowDelegate: Rectangle {
height: 60
color: styleData.selected ? '#eee' : '#fff'
}
model: GPodderPodcastListModel { id: podcastListModel } TableViewColumn {
GPodderPodcastListModelConnections {} role: 'coverart'
headerVisible: false title: 'Image'
alternatingRowColors: false delegate: Item {
horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff height: 60
width: 60
Menu { Image {
id: podcastContextMenu source: styleData.value
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
property var row: podcastListModel.get(styleData.row) height: 50
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
}
} }
} }
onCurrentRowChanged: { width: 60
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: {
Button { var id = podcastListModel.get(currentRow).id;
Layout.fillWidth: true episodeListModel.loadEpisodes(id);
Layout.margins: 3
text: 'Check for new episodes'
onClicked: py.call('main.check_for_episodes');
enabled: !py.refreshing
} }
} }
SplitView { TableView {
orientation: Orientation.Vertical Layout.fillWidth: true
model: GPodderEpisodeListModel { id: episodeListModel }
GPodderEpisodeListModelConnections {}
TableView { TableViewColumn { role: 'title'; title: 'Title' }
id: episodeListView
Layout.fillWidth: true
model: GPodderEpisodeListModel { id: episodeListModel }
GPodderEpisodeListModelConnections {}
selectionMode: SelectionMode.MultiSelection
function forEachSelectedEpisode(callback) {
episodeListView.selection.forEach(function(rowIndex) {
var episode_id = episodeListModel.get(rowIndex).id;
callback(episode_id);
});
}
Menu {
id: episodeContextMenu
MenuItem {
text: 'Toggle new'
onTriggered: {
episodeListView.forEachSelectedEpisode(function (episode_id) {
py.call('main.toggle_new', [episode_id]);
});
}
}
MenuItem {
text: 'Download'
onTriggered: {
episodeListView.forEachSelectedEpisode(function (episode_id) {
py.call('main.download_episode', [episode_id]);
});
}
}
MenuItem {
text: 'Delete'
onTriggered: {
episodeListView.forEachSelectedEpisode(function (episode_id) {
py.call('main.delete_episode', [episode_id]);
});
}
}
}
rowDelegate: Rectangle {
height: 40
color: styleData.selected ? Constants.colors.select : 'transparent'
MouseArea {
acceptedButtons: Qt.RightButton
anchors.fill: parent
onClicked: episodeContextMenu.popup()
}
}
TableViewColumn {
role: 'title'
title: 'Episode'
delegate: Row {
property var row: episodeListModel.get(styleData.row)
height: 32
Item {
width: 32
height: 32
Rectangle {
anchors.centerIn: parent
width: 10
height: 10
color: episodeTitle.color
}
anchors.verticalCenter: parent.verticalCenter
opacity: row.downloadState === Constants.state.downloaded
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: -5
Text {
id: episodeTitle
text: styleData.value
elide: styleData.elideMode
color: styleData.selected ? 'white' : 'black'
font.bold: row.isNew
}
Text {
text: row.progress ? ('Downloading: ' + parseInt(100*row.progress) + '%') : row.subtitle
elide: styleData.elideMode
color: styleData.selected ? 'white' : 'black'
}
}
}
}
onActivated: {
var episode_id = episodeListModel.get(currentRow).id;
openDialog('dialogs/EpisodeDetailsDialog.qml', function (dialog) {
py.call('main.show_episode', [episode_id], function (episode) {
dialog.episode = episode;
});
});
}
}
Label {
}
} }
} }
} }

151
main.py
View file

@ -19,7 +19,7 @@
# of gpodder-core, but we might have a different release schedule later on. If # of gpodder-core, but we might have a different release schedule later on. If
# we decide to have parallel releases, we can at least start using this version # we decide to have parallel releases, we can at least start using this version
# to check if the core version is compatible with the QML UI version. # to check if the core version is compatible with the QML UI version.
__version__ = '4.6.0' __version__ = '4.4.0'
import sys import sys
import os import os
@ -32,17 +32,14 @@ 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
@ -115,7 +112,6 @@ 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):
@ -136,10 +132,8 @@ 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,
@ -148,7 +142,8 @@ 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(), 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): def load_podcasts(self):
podcasts = self._get_podcasts_sorted() podcasts = self._get_podcasts_sorted()
@ -222,21 +217,6 @@ 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)
@ -339,7 +319,8 @@ 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, 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('updated-podcast', self.convert_podcast(podcast))
pyotherside.send('update-stats') pyotherside.send('update-stats')
@ -355,7 +336,9 @@ 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) 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, 'position': episode.current_position,
'total': episode.total_time, 'total': episode.total_time,
'video': episode.file_type() == 'video', 'video': episode.file_type() == 'video',
@ -389,9 +372,7 @@ 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), yield '%02d:%02d:%02d' % (episode.total_time / (60 * 60), (episode.total_time / 60) % 60, episode.total_time % 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)
@ -423,7 +404,8 @@ 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), 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 get_directory_entries(self, provider, query):
def match_provider(p): def match_provider(p):
@ -440,116 +422,6 @@ 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)
@ -561,7 +433,6 @@ load_podcasts = gpotherside.load_podcasts
load_episodes = gpotherside.load_episodes load_episodes = gpotherside.load_episodes
show_episode = gpotherside.show_episode show_episode = gpotherside.show_episode
play_episode = gpotherside.play_episode play_episode = gpotherside.play_episode
import_opml = gpotherside.import_opml
subscribe = gpotherside.subscribe subscribe = gpotherside.subscribe
unsubscribe = gpotherside.unsubscribe unsubscribe = gpotherside.unsubscribe
check_for_episodes = gpotherside.check_for_episodes check_for_episodes = gpotherside.check_for_episodes

View file

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

View file

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

View file

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

View file

@ -135,22 +135,22 @@ Item {
Rectangle { Rectangle {
anchors { anchors {
top: parent.top top: parent.top
left: parent.left left: downloadIndicator.right
} }
height: Constants.layout.padding * pgst.scalef height: Constants.layout.padding * pgst.scalef
width: parent.width * progress width: (parent.width - downloadIndicator.width) * progress
color: Constants.colors.download color: Constants.colors.download
} }
Rectangle { Rectangle {
anchors { anchors {
bottom: parent.bottom bottom: parent.bottom
left: parent.left left: downloadIndicator.right
} }
height: Constants.layout.padding * pgst.scalef height: Constants.layout.padding * pgst.scalef
width: parent.width * playbackProgress width: (parent.width - downloadIndicator.width) * playbackProgress
color: titleLabel.color color: titleLabel.color
opacity: episodeItem.isPlaying ? 1 : .2 opacity: episodeItem.isPlaying ? 1 : .2
} }
@ -163,17 +163,27 @@ Item {
right: parent.right right: parent.right
} }
RectangleIndicator { Rectangle {
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: Constants.layout.padding * pgst.scalef leftMargin: 2 * Constants.layout.padding * pgst.scalef
right: downloadIndicator.left right: parent.right
rightMargin: Constants.layout.padding * pgst.scalef rightMargin: Constants.layout.padding * pgst.scalef
verticalCenter: parent.verticalCenter verticalCenter: parent.verticalCenter
} }

View file

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

View file

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

View file

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

View file

@ -20,8 +20,6 @@
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
@ -29,10 +27,6 @@ Item {
Row { Row {
id: contextMenuRow id: contextMenuRow
anchors { anchors.centerIn: parent
verticalCenter: parent.verticalCenter
right: parent.right
margins: Constants.layout.padding * pgst.scalef
}
} }
} }

View file

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

View file

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

View file

@ -28,7 +28,6 @@ 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
@ -43,22 +42,13 @@ Item {
MouseArea { MouseArea {
id: mouseArea id: mouseArea
anchors.fill: parent anchors.fill: parent
function updateValue(x) { onClicked: slider.valueChangeRequested(min + (max - min) * (mouse.x / width))
if (x > width) { onPressed: {
x = width; slider.temporaryValue = (min + (max - min) * (mouse.x / width));
} else if (x < 0) { }
x = 0; onPositionChanged: {
} 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
} }
@ -78,14 +68,4 @@ Item {
verticalCenter: parent.verticalCenter verticalCenter: parent.verticalCenter
} }
} }
Repeater {
model: slider.step ? ((slider.max - slider.min) / slider.step - 1) : 0
delegate: Rectangle {
width: Math.max(1, 1 * pgst.scalef)
height: parent.height
color: Constants.colors.area
x: parent.width * ((slider.step * (1 + index)) / (slider.max - slider.min));
}
}
} }

View file

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

View file

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

View file

@ -25,8 +25,9 @@ import 'common/util.js' as Util
import 'icons/icons.js' as Icons import 'icons/icons.js' as Icons
import 'common/constants.js' as Constants import 'common/constants.js' as Constants
SlidePage { SplitPage {
id: page id: page
position: 0.4
canClose: false canClose: false
@ -47,9 +48,9 @@ SlidePage {
} }
}, },
{ {
label: 'Settings', label: 'About',
callback: function () { 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', label: 'Discover new podcasts',
callback: function () { callback: function () {
@ -105,67 +92,80 @@ SlidePage {
], undefined, undefined, true); ], undefined, undefined, true);
} }
PListView { SplitPane {
id: podcastList PListView {
title: 'Subscriptions' id: podcastList
title: 'Subscriptions'
section.property: 'section' section.property: 'section'
section.delegate: SectionHeader { text: section } section.delegate: SectionHeader { text: section }
PPlaceholder { PPlaceholder {
text: 'No podcasts' text: 'No podcasts'
visible: podcastList.count === 0 visible: podcastList.count === 0
} }
model: podcastListModel model: podcastListModel
delegate: PodcastItem { delegate: PodcastItem {
onClicked: pgst.loadPage('EpisodesPage.qml', {'podcast_id': id, 'title': title}); onClicked: {
onPressAndHold: { episodesPage.podcast_id = id;
pgst.showSelection([ episodesPage.title = title;
{ }//pgst.loadPage('EpisodesPage.qml', {'podcast_id': id, 'title': title});
label: 'Refresh', onPressAndHold: {
callback: function () { pgst.showSelection([
py.call('main.check_for_episodes', [url]); {
label: 'Refresh',
callback: function () {
py.call('main.check_for_episodes', [url]);
},
}, },
}, {
{ label: 'Unsubscribe',
label: 'Unsubscribe', callback: function () {
callback: function () { var ctx = { py: py, id: id };
var ctx = { py: py, id: id }; pgst.showConfirmation(title, 'Unsubscribe', 'Cancel', 'Remove this podcast and all downloaded episodes?', Icons.trash, function () {
pgst.showConfirmation(title, 'Unsubscribe', 'Cancel', 'Remove this podcast and all downloaded episodes?', Icons.trash, function () { ctx.py.call('main.unsubscribe', [ctx.id]);
ctx.py.call('main.unsubscribe', [ctx.id]); });
}); },
}, },
}, {
{ label: 'Rename',
label: 'Rename', callback: function () {
callback: function () { var ctx = { py: py, id: id };
var ctx = { py: py, id: id }; pgst.loadPage('TextInputDialog.qml', {
pgst.loadPage('TextInputDialog.qml', { buttonText: 'Rename',
buttonText: 'Rename', placeholderText: 'New name',
placeholderText: 'New name', text: title,
text: title, callback: function (new_title) {
callback: function (new_title) { ctx.py.call('main.rename_podcast', [ctx.id, 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: 'Mark episodes as old',
label: 'Podcast details', callback: function () {
callback: function () { py.call('main.mark_episodes_as_old', [id]);
pgst.loadPage('PodcastDetail.qml', {podcast_id: id, title: title}); },
} },
}, {
], title); 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 { 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 {
right: parent.right left: parent.left
verticalCenter: parent.verticalCenter bottom: parent.bottom
margins: Constants.layout.padding * pgst.scalef margins: 10 * pgst.scalef
} }
color: Constants.colors.secondaryHighlight 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 string title: ''
property bool hasMenuButton: false property bool hasMenuButton: false
property string menuButtonLabel: 'Menu' property string menuButtonLabel: 'Menu'
property string menuButtonIcon: Icons.stack property string menuButtonIcon: Icons.vellipsis
signal menuButtonClicked() signal menuButtonClicked()
function closePage() { function closePage() {

View file

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

View file

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

View file

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

View file

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