diff --git a/.reuse/dep5 b/.reuse/dep5 index 4624818a..0137acc4 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -18,6 +18,10 @@ Files: kasts.svg kasts-android-square.svg logo.png android/ic_launcher-playstore Copyright: 2021 Mathis BrĂ¼chert License: CC-BY-SA-4.0 +Files: icons/media-playback-start-cloud.svg +Copyright: 2022 Bart De Vries +License: CC-BY-SA-4.0 + Files: android/res/mipmap-anydpi-v26/ic_launcher.xml Copyright: None License: CC0-1.0 diff --git a/icons/media-playback-start-cloud.svg b/icons/media-playback-start-cloud.svg new file mode 100644 index 00000000..43bf19d4 --- /dev/null +++ b/icons/media-playback-start-cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/audiomanager.cpp b/src/audiomanager.cpp index abc065c0..b13b645b 100644 --- a/src/audiomanager.cpp +++ b/src/audiomanager.cpp @@ -1,6 +1,6 @@ /** * SPDX-FileCopyrightText: 2017 (c) Matthieu Gallien - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2022 Bart De Vries * * SPDX-License-Identifier: LGPL-3.0-or-later */ @@ -22,11 +22,15 @@ #include "powermanagementinterface.h" #include "settingsmanager.h" +#include + 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; + } } } } diff --git a/src/audiomanager.h b/src/audiomanager.h index edbcf77b..49ebdf12 100644 --- a/src/audiomanager.h +++ b/src/audiomanager.h @@ -1,6 +1,6 @@ /** * SPDX-FileCopyrightText: 2017 (c) Matthieu Gallien - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2022 Bart De Vries * * 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: diff --git a/src/error.cpp b/src/error.cpp index 895f6d65..e36aefef 100644 --- a/src/error.cpp +++ b/src/error.cpp @@ -1,5 +1,5 @@ /** - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2022 Bart De Vries * * 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; } diff --git a/src/error.h b/src/error.h index 4364f3b8..bc3481e7 100644 --- a/src/error.h +++ b/src/error.h @@ -1,5 +1,5 @@ /** - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2022 Bart De Vries * * 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) diff --git a/src/qml/EntryPage.qml b/src/qml/EntryPage.qml index 4392e48b..4d73cf8f 100644 --- a/src/qml/EntryPage.qml +++ b/src/qml/EntryPage.qml @@ -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 { diff --git a/src/qml/GenericEntryDelegate.qml b/src/qml/GenericEntryDelegate.qml index 32da8ce5..b1862d3e 100644 --- a/src/qml/GenericEntryDelegate.qml +++ b/src/qml/GenericEntryDelegate.qml @@ -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() } ] diff --git a/src/qml/Settings/GeneralSettingsPage.qml b/src/qml/Settings/GeneralSettingsPage.qml index ddef1fc6..ed5b95e9 100644 --- a/src/qml/Settings/GeneralSettingsPage.qml +++ b/src/qml/Settings/GeneralSettingsPage.qml @@ -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:") diff --git a/src/qml/Settings/NetworkSettingsPage.qml b/src/qml/Settings/NetworkSettingsPage.qml index 6aedcf7b..490d5d0e 100644 --- a/src/qml/Settings/NetworkSettingsPage.qml +++ b/src/qml/Settings/NetworkSettingsPage.qml @@ -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 + } } } diff --git a/src/resources.qrc b/src/resources.qrc index 9eeb0444..659ec86b 100755 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -37,5 +37,6 @@ qml/SleepTimerDialog.qml qtquickcontrols2.conf ../kasts.svg + ../icons/media-playback-start-cloud.svg diff --git a/src/settingsmanager.kcfg b/src/settingsmanager.kcfg index 91548c59..7cfcb1d6 100644 --- a/src/settingsmanager.kcfg +++ b/src/settingsmanager.kcfg @@ -9,6 +9,10 @@ false + + + false + true @@ -104,6 +108,10 @@ false + + + false + diff --git a/src/updatefeedjob.cpp b/src/updatefeedjob.cpp index 2b89254c..f590134e 100644 --- a/src/updatefeedjob.cpp +++ b/src/updatefeedjob.cpp @@ -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"))) {