Implement streaming support

This implements support for streaming episodes rather than downloading them
first.
This introduces a new setting: prioritizeStreaming. If it's set to false
(default) then a streaming play button is only added to the EntryPage.  If
it is set to true, then the streaming play button will also appear on the
Entry delegates instead of the download button.
There is a separate setting to decide if streaming is also allowed on
metered connections.

FEATURE: 438864
This commit is contained in:
Bart De Vries 2022-09-22 16:59:30 +02:00
parent 5965dc53cd
commit 562c76c799
13 changed files with 207 additions and 51 deletions

View File

@ -18,6 +18,10 @@ Files: kasts.svg kasts-android-square.svg logo.png android/ic_launcher-playstore
Copyright: 2021 Mathis Brüchert <mbblp@protonmail.ch>
License: CC-BY-SA-4.0
Files: icons/media-playback-start-cloud.svg
Copyright: 2022 Bart De Vries <bart@mogwai.be>
License: CC-BY-SA-4.0
Files: android/res/mipmap-anydpi-v26/ic_launcher.xml
Copyright: None
License: CC0-1.0

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<defs>
<style id="current-color-scheme" type="text/css">.ColorScheme-Text {
color:#232629;
}</style>
</defs>
<path class="ColorScheme-Text" d="m7.5 2c-2.4853 0-4.5 2.0147-4.5 4.5-0.013331 0.19965-0.013331 0.39996 0 0.59961-1.2239 0.43231-2.031 1.6027-2 2.9004 0 1.6569 1.3432 3.0142 3 3 0 0 0.191 0.01093 0.33961-0.18698 0.11373-0.20472 0.096778-0.38083-0.018045-0.59167-0.16956-0.21372-0.32156-0.22136-0.32156-0.22136-1.1045-0.00919-2-0.89543-2-2 0-1.1046 0.89543-2 2-2 0.13313-0.013393 0.26726-0.013393 0.40039 0-0.24407-0.46372-0.3809-0.97633-0.40039-1.5 0-1.933 1.567-3.5 3.5-3.5s3.5 1.567 3.5 3.5c0.01192 0.16645 0.01192 0.33355 0 0.5 0.16625-0.016709 0.33375-0.016709 0.5 0 1.3807 0 2.5 1.1193 2.5 2.5 0 1.3807-1.1195 2.4777-2.5 2.5 0 0-0.20893 0.0092-0.36684 0.20938-0.14441 0.21588-0.1159 0.50231 0.01983 0.65185 0.14494 0.13233 0.34701 0.13881 0.34701 0.13881 1.933 0.005098 3.5-1.567 3.5-3.5 0.017972-1.7535-1.2644-3.2496-3-3.5-0.25579-2.2882-2.1976-4.0143-4.5-4z" fill="currentColor"/>
<path class="ColorScheme-Text" d="m5.1552 6.208v7.7927l7.7927-3.8963z" color="#232629" fill="currentColor" stroke-width="1.2191"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,6 +1,6 @@
/**
* SPDX-FileCopyrightText: 2017 (c) Matthieu Gallien <matthieu_gallien@yahoo.fr>
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2022 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: LGPL-3.0-or-later
*/
@ -22,11 +22,15 @@
#include "powermanagementinterface.h"
#include "settingsmanager.h"
#include <solidextras/networkstatus.h>
class AudioManagerPrivate
{
private:
PowerManagementInterface mPowerInterface;
SolidExtras::NetworkStatus m_networkStatus;
QMediaPlayer m_player;
Entry *m_entry = nullptr;
@ -48,6 +52,8 @@ private:
qint64 m_sleepTime = -1;
qint64 m_remainingSleepTime = -1;
bool m_isStreaming = false;
friend class AudioManager;
};
@ -189,6 +195,11 @@ qreal AudioManager::maximumPlaybackRate() const
return MAX_RATE;
}
bool AudioManager::isStreaming() const
{
return d->m_isStreaming;
}
QMediaPlayer::MediaStatus AudioManager::status() const
{
return d->m_player.mediaStatus();
@ -221,20 +232,37 @@ void AudioManager::setEntry(Entry *entry)
}
// do some checks on the new entry to see whether it's valid and not corrupted
if (entry != nullptr && entry->hasEnclosure() && entry->enclosure() && entry->enclosure()->status() == Enclosure::Downloaded) {
if (entry != nullptr && entry->hasEnclosure() && entry->enclosure()
&& (entry->enclosure()->status() == Enclosure::Downloaded
|| (d->m_networkStatus.connectivity() != SolidExtras::NetworkStatus::No
&& (d->m_networkStatus.metered() != SolidExtras::NetworkStatus::Yes || SettingsManager::self()->allowMeteredStreaming())))) {
qCDebug(kastsAudio) << "Going to change source";
d->m_entry = entry;
Q_EMIT entryChanged(entry);
QUrl loadUrl;
if (entry->enclosure()->status() == Enclosure::Downloaded) {
loadUrl = QUrl::fromLocalFile(d->m_entry->enclosure()->path());
if (d->m_isStreaming) {
d->m_isStreaming = false;
Q_EMIT isStreamingChanged();
}
} else {
loadUrl = QUrl(d->m_entry->enclosure()->url());
if (!d->m_isStreaming) {
d->m_isStreaming = true;
Q_EMIT isStreamingChanged();
}
}
// the gst-pipeline is required to make sure that the pitch is not
// changed when speeding up the audio stream
// TODO: find a solution for Android (GStreamer not available on android by default)
#if !defined Q_OS_ANDROID && !defined Q_OS_WIN
qCDebug(kastsAudio) << "use custom pipeline";
d->m_player.setMedia(QUrl(QStringLiteral("gst-pipeline: playbin uri=file://") + d->m_entry->enclosure()->path()
d->m_player.setMedia(QUrl(QStringLiteral("gst-pipeline: playbin uri=") + loadUrl.toString()
+ QStringLiteral(" audio_sink=\"scaletempo ! audioconvert ! audioresample ! autoaudiosink\" video_sink=\"fakevideosink\"")));
#else
qCDebug(kastsAudio) << "regular audio backend";
d->m_player.setMedia(QUrl::fromLocalFile(d->m_entry->enclosure()->path()));
d->m_player.setMedia(loadUrl);
#endif
// save the current playing track in the settingsfile for restoring on startup
DataManager::instance().setLastPlayingEntry(d->m_entry->id());
@ -303,6 +331,22 @@ void AudioManager::play()
{
qCDebug(kastsAudio) << "AudioManager::play";
// if we're streaming, check that we're still connected and check for metered
// connection
if (isStreaming()) {
if (d->m_networkStatus.connectivity() != SolidExtras::NetworkStatus::Yes
|| (d->m_networkStatus.metered() != SolidExtras::NetworkStatus::No && !SettingsManager::self()->allowMeteredStreaming())) {
qCDebug(kastsAudio) << "Refusing to play: no Connection or streaming on metered connection not allowed";
Q_EMIT logError(Error::Type::MeteredStreamingNotAllowed,
d->m_entry->feed()->url(),
d->m_entry->id(),
0,
i18n("No connection or streaming on metered connection not allowed"),
QString());
return;
}
}
// setting m_continuePlayback will make sure that, if the audio stream is
// still being prepared, that the playback will start once it's ready
d->m_continuePlayback = true;
@ -374,7 +418,6 @@ void AudioManager::skipBackward()
bool AudioManager::canGoNext() const
{
// TODO: extend with streaming capability
if (d->m_entry) {
int index = DataManager::instance().queue().indexOf(d->m_entry->id());
if (index >= 0) {
@ -385,6 +428,12 @@ bool AudioManager::canGoNext() const
qCDebug(kastsAudio) << "Enclosure status" << next_entry->enclosure()->path() << next_entry->enclosure()->status();
if (next_entry->enclosure()->status() == Enclosure::Downloaded) {
return true;
} else {
SolidExtras::NetworkStatus networkStatus;
if (networkStatus.connectivity() == SolidExtras::NetworkStatus::Yes
&& (networkStatus.metered() == SolidExtras::NetworkStatus::No || SettingsManager::self()->allowMeteredStreaming())) {
return true;
}
}
}
}

View File

@ -1,6 +1,6 @@
/**
* SPDX-FileCopyrightText: 2017 (c) Matthieu Gallien <matthieu_gallien@yahoo.fr>
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2022 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: LGPL-3.0-or-later
*/
@ -45,6 +45,7 @@ class AudioManager : public QObject
Q_PROPERTY(qint64 sleepTime READ sleepTime WRITE setSleepTimer RESET stopSleepTimer NOTIFY sleepTimerChanged)
Q_PROPERTY(qint64 remainingSleepTime READ remainingSleepTime NOTIFY remainingSleepTimeChanged)
Q_PROPERTY(QString formattedRemainingSleepTime READ formattedRemainingSleepTime NOTIFY remainingSleepTimeChanged)
Q_PROPERTY(bool isStreaming READ isStreaming NOTIFY isStreamingChanged)
public:
const double MAX_RATE = 1.0;
@ -87,6 +88,8 @@ public:
qint64 remainingSleepTime() const; // returns remaining sleep time
QString formattedRemainingSleepTime() const;
bool isStreaming() const;
Q_SIGNALS:
void entryChanged(Entry *entry);
@ -112,6 +115,8 @@ Q_SIGNALS:
void sleepTimerChanged(qint64 duration);
void remainingSleepTimeChanged(qint64 duration);
void isStreamingChanged();
void logError(Error::Type type, const QString &url, const QString &id, const int errorId, const QString &errorString, const QString &title);
public Q_SLOTS:

View File

@ -1,5 +1,5 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2022 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
@ -58,6 +58,8 @@ QString Error::description() const
return i18n("Error moving storage path");
case Error::Type::SyncError:
return i18n("Error Syncing Feed and/or Episode Status");
case Error::Type::MeteredStreamingNotAllowed:
return i18n("No Connection or Streaming Not Allowed on Metered Connection");
default:
return QString();
}
@ -80,6 +82,8 @@ int Error::typeToDb(Error::Type type)
return 5;
case Error::Type::SyncError:
return 6;
case Error::Type::MeteredStreamingNotAllowed:
return 7;
default:
return -1;
}
@ -102,6 +106,8 @@ Error::Type Error::dbToType(int value)
return Error::Type::StorageMoveError;
case 6:
return Error::Type::SyncError;
case 7:
return Error::Type::MeteredStreamingNotAllowed;
default:
return Error::Type::Unknown;
}

View File

@ -1,5 +1,5 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2022 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
@ -24,6 +24,7 @@ public:
DiscoverError,
StorageMoveError,
SyncError,
MeteredStreamingNotAllowed,
};
Q_ENUM(Type)

View File

@ -110,36 +110,62 @@ Kirigami.ScrollablePage {
actions: [
Kirigami.Action {
text: !entry.enclosure ? i18n("Open in Browser") :
(entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) ? i18n("Download") :
entry.enclosure.status === Enclosure.Downloading ? i18n("Cancel Download") :
!entry.queueStatus ? i18n("Delete Download") :
(AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState) ? i18n("Pause") :
i18n("Play")
icon.name: !entry.enclosure ? "globe" :
(entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) ? "download" :
entry.enclosure.status === Enclosure.Downloading ? "edit-delete-remove" :
!entry.queueStatus ? "delete" :
(AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState) ? "media-playback-pause" :
"media-playback-start"
text: i18n("Open in Browser")
visible: !entry.enclosure
icon.name: "globe"
onTriggered: {
if (!entry.enclosure) {
Qt.openUrlExternally(entry.link)
} else if (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) {
downloadOverlay.entry = entry;
downloadOverlay.run();
} else if (entry.enclosure.status === Enclosure.Downloading) {
entry.enclosure.cancelDownload()
} else if (!entry.queueStatus) {
entry.enclosure.deleteFile()
} else {
if(AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState) {
AudioManager.pause()
} else {
AudioManager.entry = entry
AudioManager.play()
}
}
Qt.openUrlExternally(entry.link);
}
},
Kirigami.Action {
text: i18n("Download")
visible: entry.enclosure && (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded)
icon.name: "download"
onTriggered: {
downloadOverlay.entry = entry;
downloadOverlay.run();
}
},
Kirigami.Action {
text: i18n("Cancel Download")
visible: entry.enclosure && entry.enclosure.status === Enclosure.Downloading
icon.name: "edit-delete-remove"
onTriggered: {
entry.enclosure.cancelDownload();
}
},
Kirigami.Action {
text: i18n("Delete Download")
visible: entry.enclosure && entry.enclosure.status === Enclosure.Downloaded && !entry.queueStatus
icon.name: "delete"
onTriggered: {
entry.enclosure.deleteFile();
}
},
Kirigami.Action {
text: i18n("Pause")
visible: entry.enclosure && entry.queueStatus && (AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState)
icon.name: "media-playback-pause"
onTriggered: {
AudioManager.pause();
}
},
Kirigami.Action {
text: i18n("Play")
visible: entry.enclosure && entry.enclosure.status === Enclosure.Downloaded && entry.queueStatus && (AudioManager.entry !== entry || AudioManager.playbackState !== Audio.PlayingState)
icon.name: "media-playback-start"
onTriggered: {
AudioManager.entry = entry;
AudioManager.play();
}
},
Kirigami.Action {
text: i18nc("Action to start playback by streaming the episode rather than downloading it first", "Stream")
visible: entry.enclosure && entry.queueStatus && entry.enclosure.status !== Enclosure.Downloaded && (AudioManager.entry !== entry || AudioManager.playbackState !== Audio.PlayingState)
icon.name: ":/media-playback-start-cloud"
onTriggered: {
AudioManager.entry = entry;
AudioManager.play()
}
},
Kirigami.Action {

View File

@ -12,6 +12,7 @@ import QtGraphicalEffects 1.15
import QtQml.Models 2.15
import org.kde.kirigami 2.14 as Kirigami
import org.kde.kasts.solidextras 1.0
import org.kde.kasts 1.0
@ -25,6 +26,18 @@ Kirigami.SwipeListItem {
property bool selected: false
property int row: model ? model.row : -1
property bool streamingAllowed: (NetworkStatus.connectivity !== NetworkStatus.No && SettingsManager.prioritizeStreaming && (SettingsManager.allowMeteredStreaming || NetworkStatus.metered !== NetworkStatus.Yes))
property bool showRemoveFromQueueButton: !entry.enclosure && entry.queueStatus
property bool showDownloadButton: (!isDownloads || entry.enclosure.status === Enclosure.PartiallyDownloaded) && entry.enclosure && (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) && !streamingAllowed && !(AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState)
property bool showCancelDownloadButton: entry.enclosure && entry.enclosure.status === Enclosure.Downloading
property bool showDeleteDownloadButton: isDownloads && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded
property bool showAddToQueueButton: !isDownloads && !entry.queueStatus && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded
property bool showPlayButton: !isDownloads && entry.queueStatus && entry.enclosure && (entry.enclosure.status === Enclosure.Downloaded) && (AudioManager.entry !== entry || AudioManager.playbackState !== Audio.PlayingState)
property bool showStreamingPlayButton: !isDownloads && entry.queueStatus && entry.enclosure && (entry.enclosure.status !== Enclosure.Downloaded && streamingAllowed) && (AudioManager.entry !== entry || AudioManager.playbackState !== Audio.PlayingState)
property bool showPauseButton: !isDownloads && entry.queueStatus && entry.enclosure && (AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState)
highlighted: selected
activeBackgroundColor: Qt.lighter(Kirigami.Theme.highlightColor, 1.3)
@ -273,7 +286,7 @@ Kirigami.SwipeListItem {
onTriggered: {
entry.queueStatus = false;
}
visible: !entry.enclosure && entry.queueStatus
visible: showRemoveFromQueueButton
},
Kirigami.Action {
text: i18n("Download")
@ -282,30 +295,39 @@ Kirigami.SwipeListItem {
downloadOverlay.entry = entry;
downloadOverlay.run();
}
visible: (!isDownloads || entry.enclosure.status === Enclosure.PartiallyDownloaded) && entry.enclosure && (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded)
visible: showDownloadButton
},
Kirigami.Action {
text: i18n("Cancel Download")
icon.name: "edit-delete-remove"
onTriggered: entry.enclosure.cancelDownload()
visible: entry.enclosure && entry.enclosure.status === Enclosure.Downloading
visible: showCancelDownloadButton
},
Kirigami.Action {
text: i18n("Delete Download")
icon.name: "delete"
onTriggered: entry.enclosure.deleteFile()
visible: isDownloads && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded
visible: showDeleteDownloadButton
},
Kirigami.Action {
text: i18n("Add to Queue")
icon.name: "media-playlist-append"
visible: !isDownloads && !entry.queueStatus && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded
visible: showAddToQueueButton
onTriggered: entry.queueStatus = true
},
Kirigami.Action {
text: i18n("Play")
icon.name: "media-playback-start"
visible: !isDownloads && entry.queueStatus && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded && (AudioManager.entry !== entry || AudioManager.playbackState !== Audio.PlayingState)
visible: showPlayButton
onTriggered: {
AudioManager.entry = entry
AudioManager.play()
}
},
Kirigami.Action {
text: i18nc("Action to start playback by streaming the episode rather than downloading it first", "Stream")
icon.name: ":/media-playback-start-cloud"
visible: showStreamingPlayButton
onTriggered: {
AudioManager.entry = entry
AudioManager.play()
@ -314,7 +336,7 @@ Kirigami.SwipeListItem {
Kirigami.Action {
text: i18n("Pause")
icon.name: "media-playback-pause"
visible: !isDownloads && entry.queueStatus && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded && AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState
visible: showPauseButton
onTriggered: AudioManager.pause()
}
]

View File

@ -36,25 +36,41 @@ Kirigami.ScrollablePage {
}
Controls.CheckBox {
id: continuePlayingNextEntry
checked: SettingsManager.continuePlayingNextEntry
text: i18n("Continue playing next episode after current one finishes")
onToggled: SettingsManager.continuePlayingNextEntry = checked
id: showTimeLeft
Kirigami.FormData.label: i18nc("Label for settings related to the play time, e.g. whether the total track time is shown or a countdown of the remaining play time", "Play Time:")
checked: SettingsManager.toggleRemainingTime
text: i18n("Show time left instead of total track time")
onToggled: SettingsManager.toggleRemainingTime = checked
}
Controls.CheckBox {
id: adjustTimeLeft
checked: SettingsManager.adjustTimeLeft
enabled: SettingsManager.toggleRemainingTime
text: i18n("Adjust time left based on current playback speed")
onToggled: SettingsManager.adjustTimeLeft = checked
}
Controls.CheckBox {
id: prioritizeStreaming
Kirigami.FormData.label: i18nc("Label for settings related to streaming of episodes (as opposed to playing back locally downloaded files)", "Streaming:")
checked: SettingsManager.prioritizeStreaming
text: i18n("Prioritize streaming over downloading")
onToggled: SettingsManager.prioritizeStreaming = checked
}
Kirigami.Heading {
Kirigami.FormData.isSection: true
text: i18n("Queue Settings")
}
Controls.CheckBox {
id: continuePlayingNextEntry
checked: SettingsManager.continuePlayingNextEntry
text: i18n("Continue playing next episode after current one finishes")
onToggled: SettingsManager.continuePlayingNextEntry = checked
}
Controls.CheckBox {
id: refreshOnStartup
Kirigami.FormData.label: i18nc("Label for settings related to podcast updates", "Update Settings:")
checked: SettingsManager.refreshOnStartup
text: i18n("Automatically fetch podcast updates on startup")
onToggled: SettingsManager.refreshOnStartup = checked
@ -65,6 +81,7 @@ Kirigami.ScrollablePage {
text: i18n("Update existing episode data on refresh (slower)")
onToggled: SettingsManager.doFullUpdate = checked
}
Controls.CheckBox {
id: autoQueue
Kirigami.FormData.label: i18n("New Episodes:")

View File

@ -38,5 +38,12 @@ Kirigami.ScrollablePage {
text: i18n("Allow image downloads")
onToggled: SettingsManager.allowMeteredImageDownloads = checked
}
Controls.CheckBox {
id: allowMeteredStreaming
checked: SettingsManager.allowMeteredStreaming
text: i18n("Allow streaming")
onToggled: SettingsManager.allowMeteredStreaming = checked
}
}
}

View File

@ -37,5 +37,6 @@
<file alias="SleepTimerDialog.qml">qml/SleepTimerDialog.qml</file>
<file>qtquickcontrols2.conf</file>
<file alias="logo.svg">../kasts.svg</file>
<file alias="media-playback-start-cloud">../icons/media-playback-start-cloud.svg</file>
</qresource>
</RCC>

View File

@ -9,6 +9,10 @@
<label>Always show the title of podcast feeds in subscription view</label>
<default>false</default>
</entry>
<entry name="prioritizeStreaming" type="Bool">
<label>Show streaming button instead of download button</label>
<default>false</default>
</entry>
<entry name="continuePlayingNextEntry" type="Bool">
<label>Continue playing next episode after current one finishes</label>
<default>true</default>
@ -104,6 +108,10 @@
<label>Allow image downloads on metered connections</label>
<default>false</default>
</entry>
<entry name="allowMeteredStreaming" type="Bool">
<label>Allow streaming on metered connections</label>
<default>false</default>
</entry>
</group>
<group name="Persistency">
<entry name="lastOpenedPage" type="String">

View File

@ -282,10 +282,10 @@ bool UpdateFeedJob::processEntry(Syndication::ItemPtr entry)
entryDetails.read = m_isNewFeed ? m_markUnreadOnNewFeed : false; // if new feed, then check settings
entryDetails.isNew = !m_isNewFeed; // if new feed, then mark none as new
if (!entry->content().isEmpty())
entryDetails.content = entry->content();
else
if (!entry->description().isEmpty())
entryDetails.content = entry->description();
else
entryDetails.content = entry->content();
// Look for image in itunes tags
if (otherItems.value(QStringLiteral("http://www.itunes.com/dtds/podcast-1.0.dtdimage")).hasAttribute(QStringLiteral("href"))) {