6.6.0 commit

This commit is contained in:
Xilin Jia 2024-09-12 11:54:00 +01:00
parent ddc0f94d89
commit 1141938b73
28 changed files with 314 additions and 247 deletions

View File

@ -12,6 +12,7 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin
[<img src="./images/external/getItf-droid.png" alt="F-Droid" height="50">](https://f-droid.org/packages/ac.mdiq.podcini.R/) [<img src="./images/external/getItf-droid.png" alt="F-Droid" height="50">](https://f-droid.org/packages/ac.mdiq.podcini.R/)
[<img src="./images/external/amazon.png" alt="Amazon" height="40">](https://www.amazon.com/%E8%B4%BE%E8%A5%BF%E6%9E%97-Podcini-R/dp/B0D9WR8P13) [<img src="./images/external/amazon.png" alt="Amazon" height="40">](https://www.amazon.com/%E8%B4%BE%E8%A5%BF%E6%9E%97-Podcini-R/dp/B0D9WR8P13)
#### Podcini.R 6.6 is capable of receiving/handling shared single media from Youtube, for more see the changelogs.
#### Podcini.R version 6.5 as a major step forward brings YouTube channels in the app. They can be searched, subscribed and played from within Podcini. For more see the changelogs #### Podcini.R version 6.5 as a major step forward brings YouTube channels in the app. They can be searched, subscribed and played from within Podcini. For more see the changelogs
#### If you are migrating from Podcini version 5, please read the migrationTo5.md file for migration instructions. #### If you are migrating from Podcini version 5, please read the migrationTo5.md file for migration instructions.
#### For Podcini to show up on car's HUD with Android Auto, please read AnroidAuto.md for instructions. #### For Podcini to show up on car's HUD with Android Auto, please read AnroidAuto.md for instructions.
@ -27,7 +28,7 @@ Compared to AntennaPod this project:
5. Boasts new UI's including streamlined drawer, subscriptions view and player controller, 5. Boasts new UI's including streamlined drawer, subscriptions view and player controller,
6. Supports multiple, virtual and circular play queues associable to any podcast 6. Supports multiple, virtual and circular play queues associable to any podcast
7. Auto-download is governed by policy and limit settings of individual feed 7. Auto-download is governed by policy and limit settings of individual feed
8. Accepts podcast as well as Youtube channels and plain RSS, 8. Accepts podcast, Youtube channels, Youtube media and plain RSS,
9. Offers Readability and Text-to-Speech for RSS contents, 9. Offers Readability and Text-to-Speech for RSS contents,
10. Features `instant sync` across devices without a server. 10. Features `instant sync` across devices without a server.
@ -124,7 +125,15 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
* Ability to open podcast from webpage address * Ability to open podcast from webpage address
* Online feed info display is handled in similar ways as any local feed, and offers options to subscribe or view episodes * Online feed info display is handled in similar ways as any local feed, and offers options to subscribe or view episodes
* Online feed episodes can be freely played (streamed) without a subscription * Online feed episodes can be freely played (streamed) without a subscription
* Youtube channels can be searched in podcast search view, and can be subscribed as a normal podcast.
### Youtube channels and media
* Youtube channels can be searched in podcast search view, can also be shared from other apps (such as Youtube) to Podcini
* Youtube channels can be subscribed as normal podcasts
* Single Youtube media can also be shared from other apps, once received, are added to artificial podcast "Youtube Syndicate"
* All Youtube media can be played (only streamed) with video in fullscreen and in window as well as in audio only mode
* Every Youtube media comes with the lowest video quality and highest audio quality
* If a Youtube channel podcast is set for "audio only", then only audio stream is fetched at play time for every media in the podcast
### Instant (or Wifi) sync ### Instant (or Wifi) sync

View File

@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests" testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020244 versionCode 3020245
versionName "6.5.10" versionName "6.6.0"
applicationId "ac.mdiq.podcini.R" applicationId "ac.mdiq.podcini.R"
def commit = "" def commit = ""
@ -172,17 +172,14 @@ android {
dependencies { dependencies {
/** Desugaring for using VistaGuide **/ /** Desugaring for using VistaGuide **/
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.2' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.2'
def composeBom = platform('androidx.compose:compose-bom:2024.09.00')
implementation composeBom
androidTestImplementation composeBom
implementation 'com.github.XilinJia.vistaguide:VistaGuide:lv0.24.2.6' implementation 'com.github.XilinJia.vistaguide:VistaGuide:lv0.24.2.6'
implementation 'androidx.compose.material:material:1.7.0' def composeBom = platform('androidx.compose:compose-bom:2024.09.01')
implementation composeBom
implementation 'androidx.compose.ui:ui-tooling-preview:1.7.0' androidTestImplementation composeBom
debugImplementation 'androidx.compose.ui:ui-tooling:1.7.0' implementation 'androidx.compose.material:material:1.7.1'
implementation 'androidx.compose.ui:ui-tooling-preview:1.7.1'
debugImplementation 'androidx.compose.ui:ui-tooling:1.7.1'
implementation 'androidx.activity:activity-compose:1.9.2' implementation 'androidx.activity:activity-compose:1.9.2'
implementation 'androidx.window:window:1.3.0' implementation 'androidx.window:window:1.3.0'

View File

@ -69,9 +69,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
} }
protected open fun setPlayable(playable: Playable?) { protected open fun setPlayable(playable: Playable?) {
if (playable != null && playable !== curMedia) { if (playable != null && playable !== curMedia) curMedia = playable
curMedia = playable
}
} }
open fun getVideoSize(): Pair<Int, Int>? { open fun getVideoSize(): Pair<Int, Int>? {
@ -92,7 +90,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
return -1 return -1
} }
abstract fun createMediaPlayer() open fun createMediaPlayer() {}
/** /**
* Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing
@ -165,10 +163,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
*/ */
fun seekDelta(delta: Int) { fun seekDelta(delta: Int) {
val curPosition = getPosition() val curPosition = getPosition()
if (curPosition != Playable.INVALID_TIME) { if (curPosition != Playable.INVALID_TIME) seekTo(curPosition + delta)
val prevMedia = curMedia
seekTo(curPosition + delta)
}
else Log.e(TAG, "seekDelta getPosition() returned INVALID_TIME in seekDelta") else Log.e(TAG, "seekDelta getPosition() returned INVALID_TIME in seekDelta")
} }
@ -309,7 +304,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
@JvmField @JvmField
val LONG_REWIND: Long = TimeUnit.SECONDS.toMillis(20) val LONG_REWIND: Long = TimeUnit.SECONDS.toMillis(20)
val audioPlaybackSpeed: Float val prefPlaybackSpeed: Float
get() { get() {
try { return appPrefs.getString(Prefs.prefPlaybackSpeed.name, "1.00")!!.toFloat() try { return appPrefs.getString(Prefs.prefPlaybackSpeed.name, "1.00")!!.toFloat()
} catch (e: NumberFormatException) { } catch (e: NumberFormatException) {
@ -374,7 +369,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
if (prefs_ != null) playbackSpeed = prefs_.playSpeed if (prefs_ != null) playbackSpeed = prefs_.playSpeed
} }
} }
if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = audioPlaybackSpeed if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = prefPlaybackSpeed
return playbackSpeed return playbackSpeed
} }
} }

View File

@ -804,7 +804,6 @@ class PlaybackService : MediaLibraryService() {
intent?.getParcelableExtra(EXTRA_KEY_EVENT) intent?.getParcelableExtra(EXTRA_KEY_EVENT)
} }
val playable = curMedia val playable = curMedia
Log.d(TAG, "onStartCommand flags=$flags startId=$startId keycode=$keycode keyEvent=$keyEvent customAction=$customAction hardwareButton=$hardwareButton action=${intent?.action.toString()} ${playable?.getEpisodeTitle()}") Log.d(TAG, "onStartCommand flags=$flags startId=$startId keycode=$keycode keyEvent=$keyEvent customAction=$customAction hardwareButton=$hardwareButton action=${intent?.action.toString()} ${playable?.getEpisodeTitle()}")
if (keycode == -1 && playable == null && customAction == null) { if (keycode == -1 && playable == null && customAction == null) {
Log.e(TAG, "onStartCommand PlaybackService was started with no arguments, return") Log.e(TAG, "onStartCommand PlaybackService was started with no arguments, return")
@ -819,7 +818,6 @@ class PlaybackService : MediaLibraryService() {
Logd(TAG, "onStartCommand playing same media: $status, return") Logd(TAG, "onStartCommand playing same media: $status, return")
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
when { when {
keycode != -1 -> { keycode != -1 -> {
Logd(TAG, "onStartCommand Received hardware button event: $hardwareButton") Logd(TAG, "onStartCommand Received hardware button event: $hardwareButton")
@ -1437,8 +1435,8 @@ class PlaybackService : MediaLibraryService() {
val audioIndex = if (isNetworkRestricted) 0 else audioStreamsList.size - 1 val audioIndex = if (isNetworkRestricted) 0 else audioStreamsList.size - 1
val audioStream = audioStreamsList[audioIndex] val audioStream = audioStreamsList[audioIndex]
Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate}") Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate}")
val aSource = DefaultMediaSourceFactory(context).createMediaSource(MediaItem.Builder().setTag(metadata).setUri( val aSource = DefaultMediaSourceFactory(context).createMediaSource(
Uri.parse(audioStream.content)).build()) MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(audioStream.content)).build())
if (media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) { if (media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) {
Logd(TAG, "setDataSource1 result: $streamInfo") Logd(TAG, "setDataSource1 result: $streamInfo")
Logd(TAG, "setDataSource1 videoStreams: ${streamInfo.videoStreams.size} videoOnlyStreams: ${streamInfo.videoOnlyStreams.size} audioStreams: ${streamInfo.audioStreams.size}") Logd(TAG, "setDataSource1 videoStreams: ${streamInfo.videoStreams.size} videoOnlyStreams: ${streamInfo.videoOnlyStreams.size} audioStreams: ${streamInfo.audioStreams.size}")
@ -1446,8 +1444,8 @@ class PlaybackService : MediaLibraryService() {
val videoIndex = 0 val videoIndex = 0
val videoStream = videoStreamsList[videoIndex] val videoStream = videoStreamsList[videoIndex]
Logd(TAG, "setDataSource1 use video quality: ${videoStream.resolution}") Logd(TAG, "setDataSource1 use video quality: ${videoStream.resolution}")
val vSource = DefaultMediaSourceFactory(context).createMediaSource(MediaItem.Builder().setTag(metadata).setUri( val vSource = DefaultMediaSourceFactory(context).createMediaSource(
Uri.parse(videoStream.content)).build()) MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(videoStream.content)).build())
val mediaSources: MutableList<MediaSource> = ArrayList() val mediaSources: MutableList<MediaSource> = ArrayList()
mediaSources.add(vSource) mediaSources.add(vSource)
mediaSources.add(aSource) mediaSources.add(aSource)

View File

@ -286,6 +286,7 @@ object UserPreferences {
prefDrawerFeedOrder, prefDrawerFeedOrder,
prefDrawerFeedOrderDir, prefDrawerFeedOrderDir,
prefFeedGridLayout, prefFeedGridLayout,
prefSwipeToRefreshAll,
prefExpandNotify, prefExpandNotify,
prefEpisodeCover, prefEpisodeCover,
showTimeLeft, showTimeLeft,

View File

@ -29,6 +29,7 @@ import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.vista.extractor.stream.StreamInfo
import ac.mdiq.vista.extractor.stream.StreamInfoItem import ac.mdiq.vista.extractor.stream.StreamInfoItem
import android.app.backup.BackupManager import android.app.backup.BackupManager
import android.content.Context import android.content.Context
@ -197,39 +198,24 @@ object Episodes {
} }
} }
if (removedFromQueue.isNotEmpty()) removeFromAllQueuesSync(*removedFromQueue.toTypedArray()) if (removedFromQueue.isNotEmpty()) removeFromAllQueuesSync(*removedFromQueue.toTypedArray())
for (episode in removedFromQueue) EventFlow.postEvent(FlowEvent.QueueEvent.irreversibleRemoved(episode)) for (episode in removedFromQueue) EventFlow.postEvent(FlowEvent.QueueEvent.irreversibleRemoved(episode))
// we assume we also removed download log entries for the feed or its media files. // we assume we also removed download log entries for the feed or its media files.
// especially important if download or refresh failed, as the user should not be able // especially important if download or refresh failed, as the user should not be able
// to retry these // to retry these
EventFlow.postEvent(FlowEvent.DownloadLogEvent()) EventFlow.postEvent(FlowEvent.DownloadLogEvent())
val backupManager = BackupManager(context) val backupManager = BackupManager(context)
backupManager.dataChanged() backupManager.dataChanged()
} }
} }
// fun persistEpisodes(episodes: List<Episode>) : Job {
// Logd(TAG, "persistEpisodes called")
// return runOnIOScope {
// for (episode in episodes) {
// Logd(TAG, "persistEpisodes: ${episode.playState} ${episode.title}")
// upsert(episode) {}
// }
// EventFlow.postEvent(FlowEvent.EpisodeEvent(episodes))
// }
// }
// only used in tests // only used in tests
fun persistEpisodeMedia(media: EpisodeMedia) : Job { fun persistEpisodeMedia(media: EpisodeMedia) : Job {
Logd(TAG, "persistEpisodeMedia called") Logd(TAG, "persistEpisodeMedia called")
return runOnIOScope { return runOnIOScope {
var episode = media.episodeOrFetch() var episode = media.episodeOrFetch()
if (episode != null) { if (episode != null) {
episode = upsert(episode) { episode = upsert(episode) { it.media = media }
it.media = media
}
EventFlow.postEvent(FlowEvent.EpisodeMediaEvent.updated(episode)) EventFlow.postEvent(FlowEvent.EpisodeMediaEvent.updated(episode))
} else Log.e(TAG, "persistEpisodeMedia media.episode is null") } else Log.e(TAG, "persistEpisodeMedia media.episode is null")
} }
@ -255,9 +241,7 @@ object Episodes {
fun addToHistory(episode: Episode, date: Date? = Date()) : Job { fun addToHistory(episode: Episode, date: Date? = Date()) : Job {
Logd(TAG, "addToHistory called") Logd(TAG, "addToHistory called")
return runOnIOScope { return runOnIOScope {
upsert(episode) { upsert(episode) { it.media?.playbackCompletionDate = date }
it.media?.playbackCompletionDate = date
}
EventFlow.postEvent(FlowEvent.HistoryEvent()) EventFlow.postEvent(FlowEvent.HistoryEvent())
} }
} }
@ -266,9 +250,7 @@ object Episodes {
fun setFavorite(episode: Episode, stat: Boolean?) : Job { fun setFavorite(episode: Episode, stat: Boolean?) : Job {
Logd(TAG, "setFavorite called $stat") Logd(TAG, "setFavorite called $stat")
return runOnIOScope { return runOnIOScope {
val result = upsert(episode) { val result = upsert(episode) { it.isFavorite = stat ?: !it.isFavorite }
it.isFavorite = stat ?: !it.isFavorite
}
EventFlow.postEvent(FlowEvent.FavoritesEvent(result)) EventFlow.postEvent(FlowEvent.FavoritesEvent(result))
} }
} }
@ -309,11 +291,25 @@ object Episodes {
return appPrefs.getBoolean(Prefs.prefRemoveFromQueueMarkedPlayed.name, true) return appPrefs.getBoolean(Prefs.prefRemoveFromQueueMarkedPlayed.name, true)
} }
fun episodeFromStreamInfoItem(info: StreamInfoItem): Episode { fun episodeFromStreamInfoItem(item: StreamInfoItem): Episode {
val e = Episode()
e.link = item.url
e.title = item.name
e.description = item.shortDescription
e.imageUrl = item.thumbnails.first().url
e.setPubDate(item.uploadDate?.date()?.time)
val m = EpisodeMedia(e, item.url, 0, "video/*")
if (item.duration > 0) m.duration = item.duration.toInt() * 1000
m.fileUrl = getMediafilename(m)
e.media = m
return e
}
fun episodeFromStreamInfo(info: StreamInfo): Episode {
val e = Episode() val e = Episode()
e.link = info.url e.link = info.url
e.title = info.name e.title = info.name
e.description = info.shortDescription e.description = info.description?.content
e.imageUrl = info.thumbnails.first().url e.imageUrl = info.thumbnails.first().url
e.setPubDate(info.uploadDate?.date()?.time) e.setPubDate(info.uploadDate?.date()?.time)
val m = EpisodeMedia(e, info.url, 0, "video/*") val m = EpisodeMedia(e, info.url, 0, "video/*")

View File

@ -5,9 +5,11 @@ import ac.mdiq.podcini.net.download.DownloadError
import ac.mdiq.podcini.net.sync.model.EpisodeAction import ac.mdiq.podcini.net.sync.model.EpisodeAction
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink.needSynch import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink.needSynch
import ac.mdiq.podcini.playback.base.VideoMode
import ac.mdiq.podcini.preferences.UserPreferences.isAutoDelete import ac.mdiq.podcini.preferences.UserPreferences.isAutoDelete
import ac.mdiq.podcini.preferences.UserPreferences.isAutoDeleteLocal import ac.mdiq.podcini.preferences.UserPreferences.isAutoDeleteLocal
import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodes import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodes
import ac.mdiq.podcini.storage.database.Feeds.EpisodeAssistant.searchEpisodeByIdentifyingValue
import ac.mdiq.podcini.storage.database.LogsAndStats.addDownloadStatus import ac.mdiq.podcini.storage.database.LogsAndStats.addDownloadStatus
import ac.mdiq.podcini.storage.database.Queues.addToQueueSync import ac.mdiq.podcini.storage.database.Queues.addToQueueSync
import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesQuiet import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesQuiet
@ -19,6 +21,8 @@ import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction
import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.TAG_ROOT import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.TAG_ROOT
import ac.mdiq.podcini.storage.model.VolumeAdaptionSetting import ac.mdiq.podcini.storage.model.VolumeAdaptionSetting
import ac.mdiq.podcini.storage.utils.FilesUtils.feedfilePath
import ac.mdiq.podcini.storage.utils.FilesUtils.getFeedfileName
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.FlowEvent
@ -412,6 +416,43 @@ object Feeds {
return !feed.isLocalFeed || isAutoDeleteLocal return !feed.isLocalFeed || isAutoDeleteLocal
} }
fun getYoutubeSyndicate(video: Boolean): Feed {
val feedId: Long = if (video) 1 else 2
var feed = getFeed(feedId, true)
if (feed != null) return feed
feed = Feed()
feed.id = feedId
feed.title = "Youtube Syndicate" + if (video) "" else " Audio"
feed.type = Feed.FeedType.YOUTUBE.name
feed.hasVideoMedia = video
feed.downloadUrl = null
feed.fileUrl = File(feedfilePath, getFeedfileName(feed)).toString()
feed.preferences = FeedPreferences(feed.id, false, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, "", "")
feed.preferences!!.keepUpdated = false
feed.preferences!!.queue = null
feed.preferences!!.videoModePolicy = if (video) VideoMode.WINDOW_VIEW else VideoMode.AUDIO_ONLY
upsertBlk(feed) {}
return feed
}
fun addToYoutubeSyndicate(episode: Episode, video: Boolean) {
val feed = getYoutubeSyndicate(video)
Logd(TAG, "addToYoutubeSyndicate: feed: ${feed.title}")
if (searchEpisodeByIdentifyingValue(feed.episodes, episode) != null) return
Logd(TAG, "addToYoutubeSyndicate adding new episode: ${episode.title}")
runOnIOScope {
episode.feed = feed
episode.id = Feed.newId()
episode.feedId = feed.id
episode.media?.id = episode.id
upsert(episode) {}
feed.episodes.add(episode)
upsert(feed) {}
}
}
/** /**
* Compares the pubDate of two FeedItems for sorting in reverse order * Compares the pubDate of two FeedItems for sorting in reverse order
*/ */

View File

@ -3,11 +3,8 @@ package ac.mdiq.podcini.storage.model
import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.RemoteMedia.Companion.PLAYABLE_TYPE_REMOTE_MEDIA
import ac.mdiq.podcini.storage.utils.MediaMetadataRetrieverCompat import ac.mdiq.podcini.storage.utils.MediaMetadataRetrieverCompat
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.vista.extractor.Vista
import ac.mdiq.vista.extractor.stream.StreamInfo
import android.content.Context import android.content.Context
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable

View File

@ -1,20 +1,41 @@
package ac.mdiq.podcini.ui.activity package ac.mdiq.podcini.ui.activity
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.Episodes.episodeFromStreamInfo
import ac.mdiq.podcini.storage.database.Feeds.addToYoutubeSyndicate
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.vista.extractor.Vista
import ac.mdiq.vista.extractor.stream.StreamInfo
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.ViewGroup
import androidx.activity.compose.setContent
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLDecoder import java.net.URLDecoder
class ShareReceiverActivity : AppCompatActivity() { class ShareReceiverActivity : AppCompatActivity() {
@OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -24,24 +45,43 @@ class ShareReceiverActivity : AppCompatActivity() {
intent.action == Intent.ACTION_SEND -> feedUrl = intent.getStringExtra(Intent.EXTRA_TEXT) intent.action == Intent.ACTION_SEND -> feedUrl = intent.getStringExtra(Intent.EXTRA_TEXT)
intent.action == Intent.ACTION_VIEW -> feedUrl = intent.dataString intent.action == Intent.ACTION_VIEW -> feedUrl = intent.dataString
} }
if (feedUrl.isNullOrBlank()) {
if (!feedUrl.isNullOrBlank() && !feedUrl.startsWith("http")) { Log.e(TAG, "feedUrl is empty or null.")
showNoPodcastFoundError()
return
}
if (!feedUrl.startsWith("http")) {
val uri = Uri.parse(feedUrl) val uri = Uri.parse(feedUrl)
val urlString = uri?.getQueryParameter("url") val urlString = uri?.getQueryParameter("url")
if (urlString != null) feedUrl = URLDecoder.decode(urlString, "UTF-8") if (urlString != null) feedUrl = URLDecoder.decode(urlString, "UTF-8")
} }
Logd(TAG, "feedUrl: $feedUrl")
when { when {
feedUrl.isNullOrBlank() -> {
Log.e(TAG, "feedUrl is empty or null.")
showNoPodcastFoundError()
}
// plain text // plain text
feedUrl.matches(Regex("^[^\\s<>/]+\$")) -> { feedUrl!!.matches(Regex("^[^\\s<>/]+\$")) -> {
val intent = MainActivity.showOnlineSearch(this, feedUrl) val intent = MainActivity.showOnlineSearch(this, feedUrl)
startActivity(intent) startActivity(intent)
finish() finish()
} }
// Youtube media
feedUrl.startsWith("https://youtube.com/watch?") -> {
Logd(TAG, "got youtube media")
CoroutineScope(Dispatchers.IO).launch {
val info = StreamInfo.getInfo(Vista.getService(0), feedUrl)
Logd(TAG, "info: $info")
val episode = episodeFromStreamInfo(info)
Logd(TAG, "episode: $episode")
withContext(Dispatchers.Main) {
setContent {
val showDialog = remember { mutableStateOf(true) }
CustomTheme(this@ShareReceiverActivity) {
confirmAddEpisode(showDialog.value, episode, onDismissRequest = { showDialog.value = false })
}
}
}
}
}
else -> { else -> {
Logd(TAG, "Activity was started with url $feedUrl") Logd(TAG, "Activity was started with url $feedUrl")
val intent = MainActivity.showOnlineFeed(this, feedUrl) val intent = MainActivity.showOnlineFeed(this, feedUrl)
@ -52,6 +92,44 @@ class ShareReceiverActivity : AppCompatActivity() {
} }
} }
@Composable
fun confirmAddEpisode(showDialog: Boolean, episode: Episode, onDismissRequest: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(
modifier = Modifier
.wrapContentSize(align = Alignment.Center)
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
var checked by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth()) {
Checkbox(checked = checked,
onCheckedChange = {
checked = it
}
)
Text(
text = stringResource(R.string.pref_video_mode_audio_only),
style = MaterialTheme.typography.body1.merge(),
)
}
Button(onClick = {
addToYoutubeSyndicate(episode, !checked)
finish()
}) {
Text("Confirm")
}
}
}
}
}
}
private fun showNoPodcastFoundError() { private fun showNoPodcastFoundError() {
runOnUiThread { runOnUiThread {
MaterialAlertDialogBuilder(this@ShareReceiverActivity) MaterialAlertDialogBuilder(this@ShareReceiverActivity)
@ -71,8 +149,9 @@ class ShareReceiverActivity : AppCompatActivity() {
} }
companion object { companion object {
private val TAG: String = ShareReceiverActivity::class.simpleName ?: "Anonymous"
const val ARG_FEEDURL: String = "arg.feedurl" const val ARG_FEEDURL: String = "arg.feedurl"
private const val RESULT_ERROR = 2 private const val RESULT_ERROR = 2
private val TAG: String = ShareReceiverActivity::class.simpleName ?: "Anonymous"
} }
} }

View File

@ -270,7 +270,6 @@ class ChaptersFragment : AppCompatDialogFragment() {
if (hasImages) { if (hasImages) {
holder.image.visibility = View.VISIBLE holder.image.visibility = View.VISIBLE
if (sc.imageUrl.isNullOrEmpty()) { if (sc.imageUrl.isNullOrEmpty()) {
// Glide.with(context).clear(holder.image)
val imageLoader = ImageLoader.Builder(context).build() val imageLoader = ImageLoader.Builder(context).build()
imageLoader.enqueue(ImageRequest.Builder(context).data(null).target(holder.image).build()) imageLoader.enqueue(ImageRequest.Builder(context).data(null).target(holder.image).build())
} else { } else {

View File

@ -541,7 +541,7 @@ import java.util.concurrent.Semaphore
placeholder(R.color.light_gray) placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher) error(R.mipmap.ic_launcher)
} }
} } else binding.header.imgvCover.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_launcher_foreground))
} }
private var loadItemsRunning = false private var loadItemsRunning = false

View File

@ -100,37 +100,39 @@ class FeedSettingsFragment : Fragment() {
modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 10.dp, bottom = 10.dp), modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 10.dp, bottom = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// refresh if ((feed?.id ?: 0) > 10) {
Column { // refresh
Row(Modifier.fillMaxWidth()) { Column {
Icon(ImageVector.vectorResource(id = R.drawable.ic_refresh), "", tint = textColor) Row(Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.width(20.dp)) Icon(ImageVector.vectorResource(id = R.drawable.ic_refresh), "", tint = textColor)
Spacer(modifier = Modifier.width(20.dp))
Text(
text = stringResource(R.string.keep_updated),
style = MaterialTheme.typography.h6,
color = textColor
)
Spacer(modifier = Modifier.weight(1f))
var checked by remember { mutableStateOf(feed?.preferences?.keepUpdated ?: true) }
Switch(
checked = checked,
modifier = Modifier.height(24.dp),
onCheckedChange = {
checked = it
feed = upsertBlk(feed!!) { f ->
f.preferences?.keepUpdated = checked
}
}
)
}
Text( Text(
text = stringResource(R.string.keep_updated), text = stringResource(R.string.keep_updated_summary),
style = MaterialTheme.typography.h6, style = MaterialTheme.typography.body2,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.weight(1f))
var checked by remember { mutableStateOf(feed?.preferences?.keepUpdated ?: true) }
Switch(
checked = checked,
modifier = Modifier.height(24.dp),
onCheckedChange = {
checked = it
feed = upsertBlk(feed!!) { f ->
f.preferences?.keepUpdated = checked
}
}
)
} }
Text(
text = stringResource(R.string.keep_updated_summary),
style = MaterialTheme.typography.body2,
color = textColor
)
} }
if (feed?.hasVideoMedia == true) { if ((feed?.id?:0) > 10 && feed?.hasVideoMedia == true) {
// prefer play audio only // video mode
Column { Column {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
Icon(ImageVector.vectorResource(id = R.drawable.ic_delete), "", tint = textColor) Icon(ImageVector.vectorResource(id = R.drawable.ic_delete), "", tint = textColor)
@ -383,7 +385,7 @@ class FeedSettingsFragment : Fragment() {
) )
} }
// authentication // authentication
if (feed?.isLocalFeed != true) { if ((feed?.id?:0) > 0 && feed?.isLocalFeed != true) {
Column { Column {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
Icon(ImageVector.vectorResource(id = R.drawable.ic_key), "", tint = textColor) Icon(ImageVector.vectorResource(id = R.drawable.ic_key), "", tint = textColor)

View File

@ -270,7 +270,7 @@ class OnlineFeedFragment : Fragment() {
feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null
val eList: RealmList<Episode> = realmListOf() val eList: RealmList<Episode> = realmListOf()
for (r in channelTabInfo.relatedItems) { for (r in channelTabInfo.relatedItems) {
// Logd(TAG, "startFeedBuilding relatedItem: $r") Logd(TAG, "startFeedBuilding relatedItem: $r")
val e = episodeFromStreamInfoItem(r as StreamInfoItem) val e = episodeFromStreamInfoItem(r as StreamInfoItem)
e.feed = feed_ e.feed = feed_
e.feedId = feed_.id e.feedId = feed_.id

View File

@ -122,6 +122,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
private var useGrid: Boolean? = null private var useGrid: Boolean? = null
private val useGridLayout: Boolean private val useGridLayout: Boolean
get() = appPrefs.getBoolean(UserPreferences.Prefs.prefFeedGridLayout.name, false) get() = appPrefs.getBoolean(UserPreferences.Prefs.prefFeedGridLayout.name, false)
private val swipeToRefresh: Boolean
get() = appPrefs.getBoolean(UserPreferences.Prefs.prefSwipeToRefreshAll.name, true)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -193,10 +195,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
} }
binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
binding.swipeRefresh.setDistanceToTriggerSync(resources.getInteger(R.integer.swipe_refresh_distance))
binding.swipeRefresh.setOnRefreshListener {
FeedUpdateManager.runOnceOrAsk(requireContext())
}
val speedDialBinding = MultiSelectSpeedDialBinding.bind(binding.root) val speedDialBinding = MultiSelectSpeedDialBinding.bind(binding.root)
speedDialView = speedDialBinding.fabSD speedDialView = speedDialBinding.fabSD
speedDialView.overlayLayout = speedDialBinding.fabSDOverlay speedDialView.overlayLayout = speedDialBinding.fabSDOverlay
@ -216,6 +215,22 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
return binding.root return binding.root
} }
private fun setSwipeRefresh() {
if (swipeToRefresh) {
binding.swipeRefresh.isEnabled = true
binding.swipeRefresh.setDistanceToTriggerSync(resources.getInteger(R.integer.swipe_refresh_distance))
binding.swipeRefresh.setOnRefreshListener {
FeedUpdateManager.runOnceOrAsk(requireContext())
}
} else binding.swipeRefresh.isEnabled = false
}
override fun onResume() {
Logd(TAG, "onResume() called")
super.onResume()
setSwipeRefresh()
}
private fun initAdapter() { private fun initAdapter() {
if (useGrid != useGridLayout) { if (useGrid != useGridLayout) {
useGrid = useGridLayout useGrid = useGridLayout
@ -376,11 +391,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
} else binding.txtvInformation.visibility = View.GONE } else binding.txtvInformation.visibility = View.GONE
emptyView.updateVisibility() emptyView.updateVisibility()
} }
} catch (e: Throwable) { } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e))
Log.e(TAG, Log.getStackTraceString(e)) } finally { loadItemsRunning = false }
} finally {
loadItemsRunning = false
}
} }
} }
} }
@ -973,12 +985,13 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
count.visibility = View.VISIBLE count.visibility = View.VISIBLE
val mainActRef = (activity as MainActivity) val mainActRef = (activity as MainActivity)
val coverLoader = CoverLoader(mainActRef) if (feed.imageUrl != null) {
coverLoader.withUri(feed.imageUrl) val coverLoader = CoverLoader(mainActRef)
errorIcon.visibility = if (feed.lastUpdateFailed) View.VISIBLE else View.GONE coverLoader.withUri(feed.imageUrl)
errorIcon.visibility = if (feed.lastUpdateFailed) View.VISIBLE else View.GONE
coverLoader.withCoverView(coverImage) coverLoader.withCoverView(coverImage)
coverLoader.load() coverLoader.load()
} else coverImage.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_launcher_foreground))
val density: Float = mainActRef.resources.displayMetrics.density val density: Float = mainActRef.resources.displayMetrics.density
binding.outerContainer.setCardBackgroundColor(SurfaceColors.getColorForElevation(mainActRef, 1 * density)) binding.outerContainer.setCardBackgroundColor(SurfaceColors.getColorForElevation(mainActRef, 1 * density))

View File

@ -60,9 +60,7 @@ class CoverLoader(private val activity: MainActivity) {
fun load() { fun load() {
if (imgvCover == null) return if (imgvCover == null) return
val coverTargetCoil = CoilCoverTarget(fallbackTitle, imgvCover!!, textAndImageCombined) val coverTargetCoil = CoilCoverTarget(fallbackTitle, imgvCover!!, textAndImageCombined)
if (resource != 0) { if (resource != 0) {
val imageLoader = ImageLoader.Builder(activity).build() val imageLoader = ImageLoader.Builder(activity).build()
imageLoader.enqueue(ImageRequest.Builder(activity).data(null).target(coverTargetCoil).build()) imageLoader.enqueue(ImageRequest.Builder(activity).data(null).target(coverTargetCoil).build())

View File

@ -540,6 +540,8 @@
<string name="pref_video_mode_full_screen">Full screen</string> <string name="pref_video_mode_full_screen">Full screen</string>
<string name="pref_video_mode_small_window">Small window</string> <string name="pref_video_mode_small_window">Small window</string>
<string name="pref_video_mode_audio_only">Audio only</string> <string name="pref_video_mode_audio_only">Audio only</string>
<string name="pref_swipe_refresh_title">Swipe to refresh</string>
<string name="pref_swipe_refresh_sum">Swipe down to refresh all subscriptions</string>
<string name="pref_feedGridLayout_title">Subscriptions use grid layout </string> <string name="pref_feedGridLayout_title">Subscriptions use grid layout </string>
<string name="pref_feedGridLayout_sum">When set, Subscriptions view use a grid layout, otherwise list layout</string> <string name="pref_feedGridLayout_sum">When set, Subscriptions view use a grid layout, otherwise list layout</string>
<string name="pref_expandNotify_title">High notification priority</string> <string name="pref_expandNotify_title">High notification priority</string>

View File

@ -38,18 +38,18 @@
android:title="@string/pref_nav_drawer_feed_order_title" android:title="@string/pref_nav_drawer_feed_order_title"
android:key="prefDrawerFeedOrder" android:key="prefDrawerFeedOrder"
android:summary="@string/pref_nav_drawer_feed_order_sum"/> android:summary="@string/pref_nav_drawer_feed_order_sum"/>
<SwitchPreferenceCompat
android:defaultValue="true"
android:enabled="true"
android:key="prefSwipeToRefreshAll"
android:summary="@string/pref_swipe_refresh_sum"
android:title="@string/pref_swipe_refresh_title"/>
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="false" android:defaultValue="false"
android:enabled="true" android:enabled="true"
android:key="prefFeedGridLayout" android:key="prefFeedGridLayout"
android:summary="@string/pref_feedGridLayout_sum" android:summary="@string/pref_feedGridLayout_sum"
android:title="@string/pref_feedGridLayout_title"/> android:title="@string/pref_feedGridLayout_title"/>
<!-- <SwitchPreferenceCompat-->
<!-- android:title="@string/pref_show_subscription_title"-->
<!-- android:key="prefSubscriptionTitle"-->
<!-- android:summary="@string/pref_show_subscription_title_summary"-->
<!-- android:defaultValue="true" />-->
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="@string/external_elements"> <PreferenceCategory android:title="@string/external_elements">
<SwitchPreferenceCompat <SwitchPreferenceCompat

View File

@ -35,9 +35,7 @@ object RatingDialog {
} }
fun check() { fun check() {
if (shouldShow()) { if (shouldShow()) try { showInAppReview() } catch (e: Exception) { Log.e(TAG, Log.getStackTraceString(e)) }
try { showInAppReview() } catch (e: Exception) { Log.e(TAG, Log.getStackTraceString(e)) }
}
} }
private fun showInAppReview() { private fun showInAppReview() {
@ -55,10 +53,7 @@ object RatingDialog {
if (previousAttempts >= 3) saveRated() if (previousAttempts >= 3) saveRated()
else { else {
resetStartDate() resetStartDate()
mPreferences mPreferences.edit().putInt(KEY_NUMBER_OF_REVIEWS, previousAttempts + 1).apply()
.edit()
.putInt(KEY_NUMBER_OF_REVIEWS, previousAttempts + 1)
.apply()
} }
Logd("ReviewDialog", "Successfully finished in-app review") Logd("ReviewDialog", "Successfully finished in-app review")
} }
@ -74,17 +69,11 @@ object RatingDialog {
@VisibleForTesting @VisibleForTesting
fun saveRated() { fun saveRated() {
mPreferences mPreferences.edit().putBoolean(KEY_RATED, true).apply()
.edit()
.putBoolean(KEY_RATED, true)
.apply()
} }
private fun resetStartDate() { private fun resetStartDate() {
mPreferences mPreferences.edit().putLong(KEY_FIRST_START_DATE, System.currentTimeMillis()).apply()
.edit()
.putLong(KEY_FIRST_START_DATE, System.currentTimeMillis())
.apply()
} }
private fun shouldShow(): Boolean { private fun shouldShow(): Boolean {

View File

@ -8,13 +8,10 @@ import com.google.android.gms.security.ProviderInstaller
object SslProviderInstaller { object SslProviderInstaller {
fun install(context: Context) { fun install(context: Context) {
try { try { ProviderInstaller.installIfNeeded(context)
ProviderInstaller.installIfNeeded(context)
} catch (e: GooglePlayServicesRepairableException) { } catch (e: GooglePlayServicesRepairableException) {
e.printStackTrace() e.printStackTrace()
GoogleApiAvailability.getInstance().showErrorNotification(context, e.connectionStatusCode) GoogleApiAvailability.getInstance().showErrorNotification(context, e.connectionStatusCode)
} catch (e: GooglePlayServicesNotAvailableException) { } catch (e: GooglePlayServicesNotAvailableException) { e.printStackTrace() }
e.printStackTrace()
}
} }
} }

View File

@ -20,8 +20,7 @@ abstract class CastEnabledActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
canCast = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS canCast = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
if (canCast) { if (canCast) {
try { try { CastContext.getSharedInstance(this)
CastContext.getSharedInstance(this)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
canCast = false canCast = false
@ -30,9 +29,7 @@ abstract class CastEnabledActivity : AppCompatActivity() {
} }
fun requestCastButton(menu: Menu?) { fun requestCastButton(menu: Menu?) {
if (!canCast) { if (!canCast) return
return
}
menuInflater.inflate(R.menu.cast_button, menu) menuInflater.inflate(R.menu.cast_button, menu)
CastButtonFactory.setUpMediaRouteButton(applicationContext, menu!!, R.id.media_route_menu_item) CastButtonFactory.setUpMediaRouteButton(applicationContext, menu!!, R.id.media_route_menu_item)
} }

View File

@ -10,9 +10,7 @@ import com.google.android.gms.cast.framework.SessionProvider
@SuppressLint("VisibleForTests") @SuppressLint("VisibleForTests")
class CastOptionsProvider : OptionsProvider { class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions { override fun getCastOptions(context: Context): CastOptions {
return CastOptions.Builder() return CastOptions.Builder().setReceiverApplicationId("BEBC1DB1").build()
.setReceiverApplicationId("BEBC1DB1")
.build()
} }
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? { override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {

View File

@ -29,17 +29,14 @@ import kotlin.math.min
*/ */
@SuppressLint("VisibleForTests") @SuppressLint("VisibleForTests")
class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBase(context, callback) { class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBase(context, callback) {
val TAG = this::class.simpleName ?: "Anonymous" val TAG = this::class.simpleName ?: "Anonymous"
@Volatile @Volatile
private var remoteMedia: MediaInfo? = null private var remoteMedia: MediaInfo? = null
@Volatile @Volatile
private var remoteState: Int private var remoteState: Int
private val castContext = CastContext.getSharedInstance(context) private val castContext = CastContext.getSharedInstance(context)
private val remoteMediaClient = castContext.sessionManager.currentCastSession!!.remoteMediaClient private val remoteMediaClient = castContext.sessionManager.currentCastSession!!.remoteMediaClient
private val isBuffering: AtomicBoolean private val isBuffering: AtomicBoolean
private val remoteMediaClientCallback: RemoteMediaClient.Callback = object : RemoteMediaClient.Callback() { private val remoteMediaClientCallback: RemoteMediaClient.Callback = object : RemoteMediaClient.Callback() {
@ -47,17 +44,14 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
super.onMetadataUpdated() super.onMetadataUpdated()
onRemoteMediaPlayerStatusUpdated() onRemoteMediaPlayerStatusUpdated()
} }
override fun onPreloadStatusUpdated() { override fun onPreloadStatusUpdated() {
super.onPreloadStatusUpdated() super.onPreloadStatusUpdated()
onRemoteMediaPlayerStatusUpdated() onRemoteMediaPlayerStatusUpdated()
} }
override fun onStatusUpdated() { override fun onStatusUpdated() {
super.onStatusUpdated() super.onStatusUpdated()
onRemoteMediaPlayerStatusUpdated() onRemoteMediaPlayerStatusUpdated()
} }
override fun onMediaError(mediaError: MediaError) { override fun onMediaError(mediaError: MediaError) {
EventFlow.postEvent(FlowEvent.PlayerErrorEvent(mediaError.reason!!)) EventFlow.postEvent(FlowEvent.PlayerErrorEvent(mediaError.reason!!))
} }
@ -81,7 +75,6 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
private fun localVersion(info: MediaInfo?): Playable? { private fun localVersion(info: MediaInfo?): Playable? {
if (info == null || info.metadata == null) return null if (info == null || info.metadata == null) return null
if (CastUtils.matches(info, curMedia)) return curMedia if (CastUtils.matches(info, curMedia)) return curMedia
val streamUrl = info.metadata!!.getString(CastUtils.KEY_STREAM_URL) val streamUrl = info.metadata!!.getString(CastUtils.KEY_STREAM_URL)
return if (streamUrl == null) CastUtils.makeRemoteMedia(info) else callback.findMedia(streamUrl) return if (streamUrl == null) CastUtils.makeRemoteMedia(info) else callback.findMedia(streamUrl)
} }
@ -122,17 +115,13 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
state = MediaStatus.PLAYER_STATE_UNKNOWN state = MediaStatus.PLAYER_STATE_UNKNOWN
stateChanged = oldState != MediaStatus.PLAYER_STATE_UNKNOWN stateChanged = oldState != MediaStatus.PLAYER_STATE_UNKNOWN
} }
if (stateChanged) remoteState = state if (stateChanged) remoteState = state
if (mediaChanged && stateChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING && state != MediaStatus.PLAYER_STATE_IDLE) { if (mediaChanged && stateChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING && state != MediaStatus.PLAYER_STATE_IDLE) {
callback.onPlaybackPause(null, Playable.INVALID_TIME) callback.onPlaybackPause(null, Playable.INVALID_TIME)
// We don't want setPlayerStatus to handle the onPlaybackPause callback // We don't want setPlayerStatus to handle the onPlaybackPause callback
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia) setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia)
} }
setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING) setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING)
when (state) { when (state) {
MediaStatus.PLAYER_STATE_PLAYING -> { MediaStatus.PLAYER_STATE_PLAYING -> {
if (!stateChanged) { if (!stateChanged) {
@ -198,10 +187,6 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
} }
} }
override fun createMediaPlayer() {}
// private var prevMedia: Playable? = null
/** /**
* Internal implementation of playMediaObject. This method has an additional parameter that * Internal implementation of playMediaObject. This method has an additional parameter that
* allows the caller to force a media player reset even if * allows the caller to force a media player reset even if
@ -275,7 +260,6 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
setPlayerStatus(PlayerStatus.PREPARING, curMedia) setPlayerStatus(PlayerStatus.PREPARING, curMedia)
var position = curMedia!!.getPosition() var position = curMedia!!.getPosition()
if (position > 0) position = calculatePositionWithRewind(position, curMedia!!.getLastPlayedTime()) if (position > 0) position = calculatePositionWithRewind(position, curMedia!!.getLastPlayedTime())
remoteMediaClient!!.load(MediaLoadRequestData.Builder() remoteMediaClient!!.load(MediaLoadRequestData.Builder()
.setMediaInfo(remoteMedia) .setMediaInfo(remoteMedia)
.setAutoplay(startWhenPrepared.get()) .setAutoplay(startWhenPrepared.get())
@ -346,14 +330,12 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
var nextMedia: Playable? = null var nextMedia: Playable? = null
if (shouldContinue) { if (shouldContinue) {
nextMedia = callback.getNextInQueue(currentMedia) nextMedia = callback.getNextInQueue(currentMedia)
val playNextEpisode = isPlaying && nextMedia != null val playNextEpisode = isPlaying && nextMedia != null
when { when {
playNextEpisode -> Logd(TAG, "Playback of next episode will start immediately.") playNextEpisode -> Logd(TAG, "Playback of next episode will start immediately.")
nextMedia == null -> Logd(TAG, "No more episodes available to play") nextMedia == null -> Logd(TAG, "No more episodes available to play")
else -> Logd(TAG, "Loading next episode, but not playing automatically.") else -> Logd(TAG, "Loading next episode, but not playing automatically.")
} }
if (nextMedia != null) { if (nextMedia != null) {
callback.onPlaybackEnded(nextMedia.getMediaType(), !playNextEpisode) callback.onPlaybackEnded(nextMedia.getMediaType(), !playNextEpisode)
// setting media to null signals to playMediaObject() that we're taking care of post-playback processing // setting media to null signals to playMediaObject() that we're taking care of post-playback processing
@ -381,12 +363,7 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
fun getInstanceIfConnected(context: Context, callback: MediaPlayerCallback): MediaPlayerBase? { fun getInstanceIfConnected(context: Context, callback: MediaPlayerCallback): MediaPlayerBase? {
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) return null if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) return null
try { if (CastContext.getSharedInstance(context).castState == CastState.CONNECTED) return CastPsmp(context, callback) } catch (e: Exception) { e.printStackTrace() }
try {
if (CastContext.getSharedInstance(context).castState == CastState.CONNECTED) return CastPsmp(context, callback)
} catch (e: Exception) {
e.printStackTrace()
}
return null return null
} }
} }

View File

@ -11,9 +11,8 @@ open class CastStateListener(context: Context) : SessionManagerListener<CastSess
private var castContext: CastContext? private var castContext: CastContext?
init { init {
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) { if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) castContext = null
castContext = null else {
} else {
var castCtx: CastContext? var castCtx: CastContext?
try { try {
castCtx = CastContext.getSharedInstance(context) castCtx = CastContext.getSharedInstance(context)
@ -27,40 +26,30 @@ open class CastStateListener(context: Context) : SessionManagerListener<CastSess
} }
fun destroy() { fun destroy() {
if (castContext != null) { if (castContext != null) castContext!!.sessionManager.removeSessionManagerListener(this, CastSession::class.java)
castContext!!.sessionManager.removeSessionManagerListener(this, CastSession::class.java)
}
} }
override fun onSessionStarting(castSession: CastSession) { override fun onSessionStarting(castSession: CastSession) {}
}
override fun onSessionStarted(session: CastSession, sessionId: String) { override fun onSessionStarted(session: CastSession, sessionId: String) {
onSessionStartedOrEnded() onSessionStartedOrEnded()
} }
override fun onSessionStartFailed(castSession: CastSession, i: Int) { override fun onSessionStartFailed(castSession: CastSession, i: Int) {}
}
override fun onSessionEnding(castSession: CastSession) { override fun onSessionEnding(castSession: CastSession) {}
}
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) { override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {}
}
override fun onSessionResumeFailed(castSession: CastSession, i: Int) { override fun onSessionResumeFailed(castSession: CastSession, i: Int) {}
}
override fun onSessionSuspended(castSession: CastSession, i: Int) { override fun onSessionSuspended(castSession: CastSession, i: Int) {}
}
override fun onSessionEnded(session: CastSession, error: Int) { override fun onSessionEnded(session: CastSession, error: Int) {
onSessionStartedOrEnded() onSessionStartedOrEnded()
} }
override fun onSessionResuming(castSession: CastSession, s: String) { override fun onSessionResuming(castSession: CastSession, s: String) {}
}
open fun onSessionStartedOrEnded() { open fun onSessionStartedOrEnded() {}
}
} }

View File

@ -121,8 +121,8 @@ object CastUtils {
if (info.contentId != media.getStreamUrl()) return false if (info.contentId != media.getStreamUrl()) return false
val metadata = info.metadata val metadata = info.metadata
return (metadata != null && metadata.getString(KEY_EPISODE_IDENTIFIER) == return (metadata != null && metadata.getString(KEY_EPISODE_IDENTIFIER) == media.getEpisodeIdentifier()
media.getEpisodeIdentifier() && metadata.getString(KEY_FEED_URL) == media.feedUrl) && metadata.getString(KEY_FEED_URL) == media.feedUrl)
} }
/** /**

View File

@ -15,33 +15,21 @@ object MediaInfoCreator {
metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle()) metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle())
metadata.putString(MediaMetadata.KEY_SUBTITLE, media.getFeedTitle()) metadata.putString(MediaMetadata.KEY_SUBTITLE, media.getFeedTitle())
if (!media.getImageLocation().isNullOrEmpty()) { if (!media.getImageLocation().isNullOrEmpty()) metadata.addImage(WebImage(Uri.parse(media.getImageLocation())))
metadata.addImage(WebImage(Uri.parse(media.getImageLocation())))
}
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
calendar.time = media.getPubDate() calendar.time = media.getPubDate()
metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar) metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar)
if (media.getFeedAuthor().isNotEmpty()) { if (media.getFeedAuthor().isNotEmpty()) metadata.putString(MediaMetadata.KEY_ARTIST, media.getFeedAuthor())
metadata.putString(MediaMetadata.KEY_ARTIST, media.getFeedAuthor()) if (!media.feedUrl.isNullOrEmpty()) metadata.putString(CastUtils.KEY_FEED_URL, media.feedUrl)
} if (!media.feedLink.isNullOrEmpty()) metadata.putString(CastUtils.KEY_FEED_WEBSITE, media.feedLink)
if (!media.feedUrl.isNullOrEmpty()) { if (!media.getEpisodeIdentifier().isNullOrEmpty()) metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getEpisodeIdentifier()!!)
metadata.putString(CastUtils.KEY_FEED_URL, media.feedUrl) else {
}
if (!media.feedLink.isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_FEED_WEBSITE, media.feedLink)
}
if (!media.getEpisodeIdentifier().isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getEpisodeIdentifier()!!)
} else {
if (media.getStreamUrl() != null) metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getStreamUrl()!!) if (media.getStreamUrl() != null) metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getStreamUrl()!!)
} }
if (!media.episodeLink.isNullOrEmpty()) { if (!media.episodeLink.isNullOrEmpty()) metadata.putString(CastUtils.KEY_EPISODE_LINK, media.episodeLink)
metadata.putString(CastUtils.KEY_EPISODE_LINK, media.episodeLink)
}
val notes: String? = media.getDescription() val notes: String? = media.getDescription()
if (notes != null) { if (notes != null) metadata.putString(CastUtils.KEY_EPISODE_NOTES, notes)
metadata.putString(CastUtils.KEY_EPISODE_NOTES, notes)
}
// Default id value // Default id value
metadata.putInt(CastUtils.KEY_MEDIA_ID, 0) metadata.putInt(CastUtils.KEY_MEDIA_ID, 0)
metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE) metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE)
@ -51,9 +39,7 @@ object MediaInfoCreator {
.setContentType(media.getMimeType()) .setContentType(media.getMimeType())
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata) .setMetadata(metadata)
if (media.getDuration() > 0) { if (media.getDuration() > 0) builder.setStreamDuration(media.getDuration().toLong())
builder.setStreamDuration(media.getDuration().toLong())
}
return builder.build() return builder.build()
} }
@ -66,9 +52,7 @@ object MediaInfoCreator {
* @return [MediaInfo] object in a format proper for casting. * @return [MediaInfo] object in a format proper for casting.
*/ */
fun from(media: EpisodeMedia?): MediaInfo? { fun from(media: EpisodeMedia?): MediaInfo? {
if (media == null) { if (media == null) return null
return null
}
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC) val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC)
checkNotNull(media.episode) { "item is null" } checkNotNull(media.episode) { "item is null" }
val feedItem = media.episode val feedItem = media.episode
@ -77,35 +61,21 @@ object MediaInfoCreator {
val subtitle = media.getFeedTitle() val subtitle = media.getFeedTitle()
metadata.putString(MediaMetadata.KEY_SUBTITLE, subtitle) metadata.putString(MediaMetadata.KEY_SUBTITLE, subtitle)
val feed: Feed? = feedItem.feed val feed: Feed? = feedItem.feed
// Manual because cast does not support embedded images // Manual because cast does not support embedded images
val url: String = if (feedItem.imageUrl == null && feed != null) feed.imageUrl?:"" else feedItem.imageUrl?:"" val url: String = if (feedItem.imageUrl == null && feed != null) feed.imageUrl?:"" else feedItem.imageUrl?:""
if (url.isNotEmpty()) { if (url.isNotEmpty()) metadata.addImage(WebImage(Uri.parse(url)))
metadata.addImage(WebImage(Uri.parse(url)))
}
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
if (media.episode?.getPubDate() != null) calendar.time = media.episode!!.getPubDate()!! if (media.episode?.getPubDate() != null) calendar.time = media.episode!!.getPubDate()!!
metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar) metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar)
if (feed != null) { if (feed != null) {
if (!feed.author.isNullOrEmpty()) { if (!feed.author.isNullOrEmpty()) metadata.putString(MediaMetadata.KEY_ARTIST, feed.author!!)
metadata.putString(MediaMetadata.KEY_ARTIST, feed.author!!) if (!feed.downloadUrl.isNullOrEmpty()) metadata.putString(CastUtils.KEY_FEED_URL, feed.downloadUrl!!)
} if (!feed.link.isNullOrEmpty()) metadata.putString(CastUtils.KEY_FEED_WEBSITE, feed.link!!)
if (!feed.downloadUrl.isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_FEED_URL, feed.downloadUrl!!)
}
if (!feed.link.isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_FEED_WEBSITE, feed.link!!)
}
}
if (!feedItem.identifier.isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, feedItem.identifier!!)
} else {
metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getStreamUrl()?:"")
}
if (!feedItem.link.isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_EPISODE_LINK, feedItem.link!!)
} }
if (!feedItem.identifier.isNullOrEmpty()) metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, feedItem.identifier!!)
else metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getStreamUrl() ?: "")
if (!feedItem.link.isNullOrEmpty()) metadata.putString(CastUtils.KEY_EPISODE_LINK, feedItem.link!!)
} }
// This field only identifies the id on the device that has the original version. // This field only identifies the id on the device that has the original version.
// Idea is to perhaps, on a first approach, check if the version on the local DB with the // Idea is to perhaps, on a first approach, check if the version on the local DB with the
@ -121,9 +91,7 @@ object MediaInfoCreator {
.setContentType(media.mimeType) .setContentType(media.mimeType)
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata) .setMetadata(metadata)
if (media.getDuration() > 0) { if (media.getDuration() > 0) builder.setStreamDuration(media.getDuration().toLong())
builder.setStreamDuration(media.getDuration().toLong())
}
return builder.build() return builder.build()
} }
} }

View File

@ -1,3 +1,16 @@
# 6.6.0
* added ability to receive shared Youtube media,
* once received, the user can choose to set it as "audio only" before confirm
* the media is then added as an episode to one of the two artificial podcasts: "Youtube Syndicate" or "Youtube Syndicate Audio" (for audio-only media)
* the two artificial podcasts behave as normal Youtube-channel podcasts except that they can not be updated, and video mode and authentication can not be changed,
* the episodes can be handled in the same fashion as normal podcast episodes, except that those in "Youtube Syndicate Audio" can not be played with video
* fixed info display on notification panel for Youtube episodes
* added a setting to disable "swipe to refresh all subscriptions" under Settings -> Interface -> Subscriptions
* even when disabled, subscriptions can be refreshed from the menu in Subscriptions view
* this doesn't affect "swipe to refresh" in FeedEpisodes view for single podcast
* updated various compose dependencies
# 6.5.10 # 6.5.10
* fixed crash when switching to a newly created queue in Queues view * fixed crash when switching to a newly created queue in Queues view

View File

@ -0,0 +1,12 @@
Version 6.5.10:
* added ability to receive shared Youtube media,
* once received, the user can choose to set it as "audio only" before confirm
* the media is then added as an episode to one of the two artificial podcasts: "Youtube Syndicate" or "Youtube Syndicate Audio" (for audio-only media)
* the two artificial podcasts behave as normal Youtube-channel podcasts except that they can not be updated, and video mode and authentication can not be changed,
* the episodes can be handled in the same fashion as normal podcast episodes, except that those in "Youtube Syndicate Audio" can not be played with video
* fixed info display on notification panel for Youtube episodes
* added a setting to disable "swipe to refresh all subscriptions" under Settings -> Interface -> Subscriptions
* even when disabled, subscriptions can be refreshed from the menu in Subscriptions view
* this doesn't affect "swipe to refresh" in FeedEpisodes view for single podcast
* updated various compose dependencies

View File

@ -7,5 +7,5 @@ android.nonFinalResIds=true
org.gradle.caching=true org.gradle.caching=true
org.gradle.jvmargs=-Xmx2048m org.gradle.jvmargs=-Xmx2048m
kotlin.daemon.jvmargs=-Xmx2048m #kotlin.daemon.jvmargs=-Xmx1g
org.gradle.configuration-cache=true org.gradle.configuration-cache=true