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/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
#### 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.
@ -27,7 +28,7 @@ Compared to AntennaPod this project:
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
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,
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
* 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
* 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

View File

@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020244
versionName "6.5.10"
versionCode 3020245
versionName "6.6.0"
applicationId "ac.mdiq.podcini.R"
def commit = ""
@ -172,17 +172,14 @@ android {
dependencies {
/** Desugaring for using VistaGuide **/
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 'androidx.compose.material:material:1.7.0'
implementation 'androidx.compose.ui:ui-tooling-preview:1.7.0'
debugImplementation 'androidx.compose.ui:ui-tooling:1.7.0'
def composeBom = platform('androidx.compose:compose-bom:2024.09.01')
implementation composeBom
androidTestImplementation composeBom
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.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?) {
if (playable != null && playable !== curMedia) {
curMedia = playable
}
if (playable != null && playable !== curMedia) curMedia = playable
}
open fun getVideoSize(): Pair<Int, Int>? {
@ -92,7 +90,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
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
@ -165,10 +163,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
*/
fun seekDelta(delta: Int) {
val curPosition = getPosition()
if (curPosition != Playable.INVALID_TIME) {
val prevMedia = curMedia
seekTo(curPosition + delta)
}
if (curPosition != Playable.INVALID_TIME) seekTo(curPosition + delta)
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
val LONG_REWIND: Long = TimeUnit.SECONDS.toMillis(20)
val audioPlaybackSpeed: Float
val prefPlaybackSpeed: Float
get() {
try { return appPrefs.getString(Prefs.prefPlaybackSpeed.name, "1.00")!!.toFloat()
} catch (e: NumberFormatException) {
@ -374,7 +369,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
if (prefs_ != null) playbackSpeed = prefs_.playSpeed
}
}
if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = audioPlaybackSpeed
if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = prefPlaybackSpeed
return playbackSpeed
}
}

View File

@ -804,7 +804,6 @@ class PlaybackService : MediaLibraryService() {
intent?.getParcelableExtra(EXTRA_KEY_EVENT)
}
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()}")
if (keycode == -1 && playable == null && customAction == null) {
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")
return super.onStartCommand(intent, flags, startId)
}
when {
keycode != -1 -> {
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 audioStream = audioStreamsList[audioIndex]
Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate}")
val aSource = DefaultMediaSourceFactory(context).createMediaSource(MediaItem.Builder().setTag(metadata).setUri(
Uri.parse(audioStream.content)).build())
val aSource = DefaultMediaSourceFactory(context).createMediaSource(
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(audioStream.content)).build())
if (media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) {
Logd(TAG, "setDataSource1 result: $streamInfo")
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 videoStream = videoStreamsList[videoIndex]
Logd(TAG, "setDataSource1 use video quality: ${videoStream.resolution}")
val vSource = DefaultMediaSourceFactory(context).createMediaSource(MediaItem.Builder().setTag(metadata).setUri(
Uri.parse(videoStream.content)).build())
val vSource = DefaultMediaSourceFactory(context).createMediaSource(
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(videoStream.content)).build())
val mediaSources: MutableList<MediaSource> = ArrayList()
mediaSources.add(vSource)
mediaSources.add(aSource)

View File

@ -286,6 +286,7 @@ object UserPreferences {
prefDrawerFeedOrder,
prefDrawerFeedOrderDir,
prefFeedGridLayout,
prefSwipeToRefreshAll,
prefExpandNotify,
prefEpisodeCover,
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.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.vista.extractor.stream.StreamInfo
import ac.mdiq.vista.extractor.stream.StreamInfoItem
import android.app.backup.BackupManager
import android.content.Context
@ -197,39 +198,24 @@ object Episodes {
}
}
if (removedFromQueue.isNotEmpty()) removeFromAllQueuesSync(*removedFromQueue.toTypedArray())
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.
// especially important if download or refresh failed, as the user should not be able
// to retry these
EventFlow.postEvent(FlowEvent.DownloadLogEvent())
val backupManager = BackupManager(context)
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
fun persistEpisodeMedia(media: EpisodeMedia) : Job {
Logd(TAG, "persistEpisodeMedia called")
return runOnIOScope {
var episode = media.episodeOrFetch()
if (episode != null) {
episode = upsert(episode) {
it.media = media
}
episode = upsert(episode) { it.media = media }
EventFlow.postEvent(FlowEvent.EpisodeMediaEvent.updated(episode))
} else Log.e(TAG, "persistEpisodeMedia media.episode is null")
}
@ -255,9 +241,7 @@ object Episodes {
fun addToHistory(episode: Episode, date: Date? = Date()) : Job {
Logd(TAG, "addToHistory called")
return runOnIOScope {
upsert(episode) {
it.media?.playbackCompletionDate = date
}
upsert(episode) { it.media?.playbackCompletionDate = date }
EventFlow.postEvent(FlowEvent.HistoryEvent())
}
}
@ -266,9 +250,7 @@ object Episodes {
fun setFavorite(episode: Episode, stat: Boolean?) : Job {
Logd(TAG, "setFavorite called $stat")
return runOnIOScope {
val result = upsert(episode) {
it.isFavorite = stat ?: !it.isFavorite
}
val result = upsert(episode) { it.isFavorite = stat ?: !it.isFavorite }
EventFlow.postEvent(FlowEvent.FavoritesEvent(result))
}
}
@ -309,11 +291,25 @@ object Episodes {
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()
e.link = info.url
e.title = info.name
e.description = info.shortDescription
e.description = info.description?.content
e.imageUrl = info.thumbnails.first().url
e.setPubDate(info.uploadDate?.date()?.time)
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.queue.SynchronizationQueueSink
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.isAutoDeleteLocal
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.Queues.addToQueueSync
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.Companion.TAG_ROOT
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.EventFlow
import ac.mdiq.podcini.util.FlowEvent
@ -412,6 +416,43 @@ object Feeds {
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
*/

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.unmanaged
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.util.Logd
import ac.mdiq.vista.extractor.Vista
import ac.mdiq.vista.extractor.stream.StreamInfo
import android.content.Context
import android.os.Parcel
import android.os.Parcelable

View File

@ -1,20 +1,41 @@
package ac.mdiq.podcini.ui.activity
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.vista.extractor.Vista
import ac.mdiq.vista.extractor.stream.StreamInfo
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import androidx.activity.compose.setContent
import androidx.annotation.OptIn
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 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
class ShareReceiverActivity : AppCompatActivity() {
@OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) {
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_VIEW -> feedUrl = intent.dataString
}
if (!feedUrl.isNullOrBlank() && !feedUrl.startsWith("http")) {
if (feedUrl.isNullOrBlank()) {
Log.e(TAG, "feedUrl is empty or null.")
showNoPodcastFoundError()
return
}
if (!feedUrl.startsWith("http")) {
val uri = Uri.parse(feedUrl)
val urlString = uri?.getQueryParameter("url")
if (urlString != null) feedUrl = URLDecoder.decode(urlString, "UTF-8")
}
Logd(TAG, "feedUrl: $feedUrl")
when {
feedUrl.isNullOrBlank() -> {
Log.e(TAG, "feedUrl is empty or null.")
showNoPodcastFoundError()
}
// plain text
feedUrl.matches(Regex("^[^\\s<>/]+\$")) -> {
feedUrl!!.matches(Regex("^[^\\s<>/]+\$")) -> {
val intent = MainActivity.showOnlineSearch(this, feedUrl)
startActivity(intent)
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 -> {
Logd(TAG, "Activity was started with url $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() {
runOnUiThread {
MaterialAlertDialogBuilder(this@ShareReceiverActivity)
@ -71,8 +149,9 @@ class ShareReceiverActivity : AppCompatActivity() {
}
companion object {
private val TAG: String = ShareReceiverActivity::class.simpleName ?: "Anonymous"
const val ARG_FEEDURL: String = "arg.feedurl"
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) {
holder.image.visibility = View.VISIBLE
if (sc.imageUrl.isNullOrEmpty()) {
// Glide.with(context).clear(holder.image)
val imageLoader = ImageLoader.Builder(context).build()
imageLoader.enqueue(ImageRequest.Builder(context).data(null).target(holder.image).build())
} else {

View File

@ -541,7 +541,7 @@ import java.util.concurrent.Semaphore
placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher)
}
}
} else binding.header.imgvCover.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_launcher_foreground))
}
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),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// refresh
Column {
Row(Modifier.fillMaxWidth()) {
Icon(ImageVector.vectorResource(id = R.drawable.ic_refresh), "", tint = textColor)
Spacer(modifier = Modifier.width(20.dp))
if ((feed?.id ?: 0) > 10) {
// refresh
Column {
Row(Modifier.fillMaxWidth()) {
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 = stringResource(R.string.keep_updated),
style = MaterialTheme.typography.h6,
text = stringResource(R.string.keep_updated_summary),
style = MaterialTheme.typography.body2,
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) {
// prefer play audio only
if ((feed?.id?:0) > 10 && feed?.hasVideoMedia == true) {
// video mode
Column {
Row(Modifier.fillMaxWidth()) {
Icon(ImageVector.vectorResource(id = R.drawable.ic_delete), "", tint = textColor)
@ -383,7 +385,7 @@ class FeedSettingsFragment : Fragment() {
)
}
// authentication
if (feed?.isLocalFeed != true) {
if ((feed?.id?:0) > 0 && feed?.isLocalFeed != true) {
Column {
Row(Modifier.fillMaxWidth()) {
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
val eList: RealmList<Episode> = realmListOf()
for (r in channelTabInfo.relatedItems) {
// Logd(TAG, "startFeedBuilding relatedItem: $r")
Logd(TAG, "startFeedBuilding relatedItem: $r")
val e = episodeFromStreamInfoItem(r as StreamInfoItem)
e.feed = feed_
e.feedId = feed_.id

View File

@ -122,6 +122,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
private var useGrid: Boolean? = null
private val useGridLayout: Boolean
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?) {
super.onCreate(savedInstanceState)
@ -193,10 +195,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
}
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)
speedDialView = speedDialBinding.fabSD
speedDialView.overlayLayout = speedDialBinding.fabSDOverlay
@ -216,6 +215,22 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
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() {
if (useGrid != useGridLayout) {
useGrid = useGridLayout
@ -376,11 +391,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
} else binding.txtvInformation.visibility = View.GONE
emptyView.updateVisibility()
}
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
} finally {
loadItemsRunning = false
}
} catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e))
} finally { loadItemsRunning = false }
}
}
}
@ -973,12 +985,13 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
count.visibility = View.VISIBLE
val mainActRef = (activity as MainActivity)
val coverLoader = CoverLoader(mainActRef)
coverLoader.withUri(feed.imageUrl)
errorIcon.visibility = if (feed.lastUpdateFailed) View.VISIBLE else View.GONE
coverLoader.withCoverView(coverImage)
coverLoader.load()
if (feed.imageUrl != null) {
val coverLoader = CoverLoader(mainActRef)
coverLoader.withUri(feed.imageUrl)
errorIcon.visibility = if (feed.lastUpdateFailed) View.VISIBLE else View.GONE
coverLoader.withCoverView(coverImage)
coverLoader.load()
} else coverImage.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_launcher_foreground))
val density: Float = mainActRef.resources.displayMetrics.density
binding.outerContainer.setCardBackgroundColor(SurfaceColors.getColorForElevation(mainActRef, 1 * density))

View File

@ -60,9 +60,7 @@ class CoverLoader(private val activity: MainActivity) {
fun load() {
if (imgvCover == null) return
val coverTargetCoil = CoilCoverTarget(fallbackTitle, imgvCover!!, textAndImageCombined)
if (resource != 0) {
val imageLoader = ImageLoader.Builder(activity).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_small_window">Small window</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_sum">When set, Subscriptions view use a grid layout, otherwise list layout</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:key="prefDrawerFeedOrder"
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
android:defaultValue="false"
android:enabled="true"
android:key="prefFeedGridLayout"
android:summary="@string/pref_feedGridLayout_sum"
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 android:title="@string/external_elements">
<SwitchPreferenceCompat

View File

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

View File

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

View File

@ -20,8 +20,7 @@ abstract class CastEnabledActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
canCast = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
if (canCast) {
try {
CastContext.getSharedInstance(this)
try { CastContext.getSharedInstance(this)
} catch (e: Exception) {
e.printStackTrace()
canCast = false
@ -30,9 +29,7 @@ abstract class CastEnabledActivity : AppCompatActivity() {
}
fun requestCastButton(menu: Menu?) {
if (!canCast) {
return
}
if (!canCast) return
menuInflater.inflate(R.menu.cast_button, menu)
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")
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
return CastOptions.Builder()
.setReceiverApplicationId("BEBC1DB1")
.build()
return CastOptions.Builder().setReceiverApplicationId("BEBC1DB1").build()
}
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {

View File

@ -29,17 +29,14 @@ import kotlin.math.min
*/
@SuppressLint("VisibleForTests")
class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBase(context, callback) {
val TAG = this::class.simpleName ?: "Anonymous"
@Volatile
private var remoteMedia: MediaInfo? = null
@Volatile
private var remoteState: Int
private val castContext = CastContext.getSharedInstance(context)
private val remoteMediaClient = castContext.sessionManager.currentCastSession!!.remoteMediaClient
private val isBuffering: AtomicBoolean
private val remoteMediaClientCallback: RemoteMediaClient.Callback = object : RemoteMediaClient.Callback() {
@ -47,17 +44,14 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
super.onMetadataUpdated()
onRemoteMediaPlayerStatusUpdated()
}
override fun onPreloadStatusUpdated() {
super.onPreloadStatusUpdated()
onRemoteMediaPlayerStatusUpdated()
}
override fun onStatusUpdated() {
super.onStatusUpdated()
onRemoteMediaPlayerStatusUpdated()
}
override fun onMediaError(mediaError: MediaError) {
EventFlow.postEvent(FlowEvent.PlayerErrorEvent(mediaError.reason!!))
}
@ -81,7 +75,6 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
private fun localVersion(info: MediaInfo?): Playable? {
if (info == null || info.metadata == null) return null
if (CastUtils.matches(info, curMedia)) return curMedia
val streamUrl = info.metadata!!.getString(CastUtils.KEY_STREAM_URL)
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
stateChanged = oldState != MediaStatus.PLAYER_STATE_UNKNOWN
}
if (stateChanged) remoteState = state
if (mediaChanged && stateChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING && state != MediaStatus.PLAYER_STATE_IDLE) {
callback.onPlaybackPause(null, Playable.INVALID_TIME)
// We don't want setPlayerStatus to handle the onPlaybackPause callback
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia)
}
setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING)
when (state) {
MediaStatus.PLAYER_STATE_PLAYING -> {
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
* 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)
var position = curMedia!!.getPosition()
if (position > 0) position = calculatePositionWithRewind(position, curMedia!!.getLastPlayedTime())
remoteMediaClient!!.load(MediaLoadRequestData.Builder()
.setMediaInfo(remoteMedia)
.setAutoplay(startWhenPrepared.get())
@ -346,14 +330,12 @@ class CastPsmp(context: Context, callback: MediaPlayerCallback) : MediaPlayerBas
var nextMedia: Playable? = null
if (shouldContinue) {
nextMedia = callback.getNextInQueue(currentMedia)
val playNextEpisode = isPlaying && nextMedia != null
when {
playNextEpisode -> Logd(TAG, "Playback of next episode will start immediately.")
nextMedia == null -> Logd(TAG, "No more episodes available to play")
else -> Logd(TAG, "Loading next episode, but not playing automatically.")
}
if (nextMedia != null) {
callback.onPlaybackEnded(nextMedia.getMediaType(), !playNextEpisode)
// 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? {
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
}
}

View File

@ -11,9 +11,8 @@ open class CastStateListener(context: Context) : SessionManagerListener<CastSess
private var castContext: CastContext?
init {
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) {
castContext = null
} else {
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) castContext = null
else {
var castCtx: CastContext?
try {
castCtx = CastContext.getSharedInstance(context)
@ -27,40 +26,30 @@ open class CastStateListener(context: Context) : SessionManagerListener<CastSess
}
fun destroy() {
if (castContext != null) {
castContext!!.sessionManager.removeSessionManagerListener(this, CastSession::class.java)
}
if (castContext != null) castContext!!.sessionManager.removeSessionManagerListener(this, CastSession::class.java)
}
override fun onSessionStarting(castSession: CastSession) {
}
override fun onSessionStarting(castSession: CastSession) {}
override fun onSessionStarted(session: CastSession, sessionId: String) {
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) {
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
val metadata = info.metadata
return (metadata != null && metadata.getString(KEY_EPISODE_IDENTIFIER) ==
media.getEpisodeIdentifier() && metadata.getString(KEY_FEED_URL) == media.feedUrl)
return (metadata != null && metadata.getString(KEY_EPISODE_IDENTIFIER) == media.getEpisodeIdentifier()
&& 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_SUBTITLE, media.getFeedTitle())
if (!media.getImageLocation().isNullOrEmpty()) {
metadata.addImage(WebImage(Uri.parse(media.getImageLocation())))
}
if (!media.getImageLocation().isNullOrEmpty()) metadata.addImage(WebImage(Uri.parse(media.getImageLocation())))
val calendar = Calendar.getInstance()
calendar.time = media.getPubDate()
metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar)
if (media.getFeedAuthor().isNotEmpty()) {
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.getEpisodeIdentifier().isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getEpisodeIdentifier()!!)
} else {
if (media.getFeedAuthor().isNotEmpty()) 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.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.episodeLink.isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_EPISODE_LINK, media.episodeLink)
}
if (!media.episodeLink.isNullOrEmpty()) metadata.putString(CastUtils.KEY_EPISODE_LINK, media.episodeLink)
val notes: String? = media.getDescription()
if (notes != null) {
metadata.putString(CastUtils.KEY_EPISODE_NOTES, notes)
}
if (notes != null) metadata.putString(CastUtils.KEY_EPISODE_NOTES, notes)
// Default id value
metadata.putInt(CastUtils.KEY_MEDIA_ID, 0)
metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE)
@ -51,9 +39,7 @@ object MediaInfoCreator {
.setContentType(media.getMimeType())
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata)
if (media.getDuration() > 0) {
builder.setStreamDuration(media.getDuration().toLong())
}
if (media.getDuration() > 0) builder.setStreamDuration(media.getDuration().toLong())
return builder.build()
}
@ -66,9 +52,7 @@ object MediaInfoCreator {
* @return [MediaInfo] object in a format proper for casting.
*/
fun from(media: EpisodeMedia?): MediaInfo? {
if (media == null) {
return null
}
if (media == null) return null
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC)
checkNotNull(media.episode) { "item is null" }
val feedItem = media.episode
@ -77,35 +61,21 @@ object MediaInfoCreator {
val subtitle = media.getFeedTitle()
metadata.putString(MediaMetadata.KEY_SUBTITLE, subtitle)
val feed: Feed? = feedItem.feed
// Manual because cast does not support embedded images
val url: String = if (feedItem.imageUrl == null && feed != null) feed.imageUrl?:"" else feedItem.imageUrl?:""
if (url.isNotEmpty()) {
metadata.addImage(WebImage(Uri.parse(url)))
}
if (url.isNotEmpty()) metadata.addImage(WebImage(Uri.parse(url)))
val calendar = Calendar.getInstance()
if (media.episode?.getPubDate() != null) calendar.time = media.episode!!.getPubDate()!!
metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar)
if (feed != null) {
if (!feed.author.isNullOrEmpty()) {
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 (!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 (!feed.author.isNullOrEmpty()) 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 (!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.
// 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)
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata)
if (media.getDuration() > 0) {
builder.setStreamDuration(media.getDuration().toLong())
}
if (media.getDuration() > 0) builder.setStreamDuration(media.getDuration().toLong())
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
* 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.jvmargs=-Xmx2048m
kotlin.daemon.jvmargs=-Xmx2048m
#kotlin.daemon.jvmargs=-Xmx1g
org.gradle.configuration-cache=true