diff --git a/README.md b/README.md index 542ccbe4..605abaa2 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,12 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin [Amazon](https://www.amazon.com/%E8%B4%BE%E8%A5%BF%E6%9E%97-Podcini-R/dp/B0D9WR8P13) #### Podcini.R 6.10 allows creating synthetic podcast and shelving any episdes to any synthetic podcasts -#### Podcini.R version 6.5 as a major step forward brings YouTube contents in the app. Channels can be searched, received from share, subscribed. Podcasts, playlists as well as single media from Youtube and YT Music can be shared to Podcini. For more see the Youtube section below or the changelogs +#### Podcini.R 6.5 as a major step forward brings YouTube contents in the app. Channels can be searched, received from share, subscribed. Podcasts, playlists as well as single media from Youtube and YT Music can be shared to Podcini. For more see the Youtube section below or the changelogs That means finally: [Nessun dorma](https://www.youtube.com/watch?v=cWc7vYjgnTs) #### For Podcini to show up on car's HUD with Android Auto, please read AnroidAuto.md for instructions. #### If you need to cast to an external speaker, you should install the "play" apk, not the "free" apk, that's about the difference between the two. #### Since version 6.11.5, Podcini.R is back to be built to target SDK 35 (Android 15), but requests for permission for unrestricted background activities for uninterrupted background play of a playlist. For more see [this issue](https://github.com/XilinJia/Podcini/issues/88) +#### If you intend to sync with through a server, be cautious as it's not well tested with Podcini. Welcome any ideas and contribution on this. #### If you are migrating from Podcini version 5, please read the migrationTo5.md file for migration instructions. This project was developed from a fork of [AntennaPod]() as of Feb 5 2024. @@ -141,6 +142,7 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c * 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 +* When subscribing to a Youtube channel, tabs can be chosen to form separate podcasts * Playlists and podcasts on Youtube or Youtube Music can be shared to Podcini, and then can be subscribed in similar fashion as the channels * Single media from Youtube or Youtube Music can also be shared from other apps, can be accepted as including video or audio only, are added to synthetic podcasts such as "Youtube Syndicate" * All the media from Youtube or Youtube Music can be played (only streamed) with video in fullscreen and in window modes or in audio only mode in the background diff --git a/app/build.gradle b/app/build.gradle index 9e050aba..5df002c6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,8 +26,8 @@ android { vectorDrawables.useSupportLibrary false vectorDrawables.generatedDensities = [] - versionCode 3020291 - versionName "6.13.4" + versionCode 3020292 + versionName "6.13.5" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt index e2a7cf2b..f43e23ad 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt @@ -18,11 +18,22 @@ import ac.mdiq.vista.extractor.InfoItem import ac.mdiq.vista.extractor.Vista import ac.mdiq.vista.extractor.channel.ChannelInfo import ac.mdiq.vista.extractor.channel.tabs.ChannelTabInfo -import ac.mdiq.vista.extractor.exceptions.ExtractionException import ac.mdiq.vista.extractor.playlist.PlaylistInfo import ac.mdiq.vista.extractor.stream.StreamInfoItem import android.content.Context import android.util.Log +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import io.realm.kotlin.ext.realmListOf import kotlinx.coroutines.* import org.jsoup.Jsoup @@ -34,123 +45,195 @@ import java.net.URL class FeedBuilder(val context: Context, val showError: (String?, String)->Unit) { private val TAG = "DirectSubscribe" + private val service by lazy { Vista.getService("YouTube") } + private val ytTabsMap: MutableMap = mutableMapOf() + private lateinit var channelInfo: ChannelInfo + var feedSource: String = "" var selectedDownloadUrl: String? = null private var downloader: Downloader? = null - fun startFeedBuilding(url: String, username: String?, password: String?, handleFeed: (Feed, Map)->Unit) { - if (feedSource == "VistaGuide" || url.contains("youtube.com")) { - feedSource = "VistaGuide" - CoroutineScope(Dispatchers.IO).launch { - try { - val service = try { Vista.getService("YouTube") } catch (e: ExtractionException) { throw ExtractionException("YouTube service not found") } - selectedDownloadUrl = prepareUrl(url) - val feed_ = Feed(selectedDownloadUrl, null) - feed_.isBuilding = true - feed_.id = Feed.newId() - feed_.type = Feed.FeedType.YOUTUBE.name - feed_.hasVideoMedia = true - feed_.fileUrl = File(feedfilePath, getFeedfileName(feed_)).toString() - val eList: MutableList = mutableListOf() + private var urlInit: String = "" - val uURL = URL(url) - if (uURL.path.startsWith("/playlist") || uURL.path.startsWith("/playlist")) { - val playlistInfo = PlaylistInfo.getInfo(Vista.getService(0), url) ?: return@launch - feed_.title = playlistInfo.name - feed_.description = playlistInfo.description?.content ?: "" - feed_.author = playlistInfo.uploaderName - feed_.imageUrl = if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null - feed_.episodes = realmListOf() - var infoItems = playlistInfo.relatedItems - var nextPage = playlistInfo.nextPage - Logd(TAG, "infoItems: ${infoItems.size}") - CoroutineScope(Dispatchers.IO).launch { - while (infoItems.isNotEmpty()) { - eList.clear() - for (r in infoItems) { - Logd(TAG, "startFeedBuilding relatedItem: $r") - if (r.infoType != InfoItem.InfoType.STREAM) continue - val e = episodeFromStreamInfoItem(r) - e.feed = feed_ - e.feedId = feed_.id - eList.add(e) - } - feed_.episodes.addAll(eList) - if (nextPage == null || feed_.episodes.size > 1000) break - try { - val page = PlaylistInfo.getMoreItems(service, url, nextPage) ?: break - nextPage = page.nextPage - infoItems = page.items - Logd(TAG, "more infoItems: ${infoItems.size}") - } catch (e: Throwable) { - Logd(TAG, "PlaylistInfo.getMoreItems error: ${e.message}") - withContext(Dispatchers.Main) { showError(e.message, "") } - break - } - } - feed_.isBuilding = false - } - withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) } - } else { - val channelInfo = ChannelInfo.getInfo(service, url) - Logd(TAG, "startFeedBuilding result: $channelInfo ${channelInfo.tabs.size}") - if (channelInfo.tabs.isEmpty()) { - withContext(Dispatchers.Main) { showError("Channel is empty", "") } - return@launch - } - try { - val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs.first()) - Logd(TAG, "startFeedBuilding result1: $channelTabInfo ${channelTabInfo.relatedItems.size}") - feed_.title = channelInfo.name - feed_.description = channelInfo.description - feed_.author = channelInfo.parentChannelName - feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null - feed_.episodes = realmListOf() + fun isYoutube(url: String): Boolean { + urlInit = url + val isit = (feedSource == "VistaGuide" || url.contains("youtube.com")) + if (isit) feedSource = "VistaGuide" + return isit + } - var infoItems = channelTabInfo.relatedItems - var nextPage = channelTabInfo.nextPage - Logd(TAG, "infoItems: ${infoItems.size}") - CoroutineScope(Dispatchers.IO).launch { - while (infoItems.isNotEmpty()) { - eList.clear() - for (r in infoItems) { - Logd(TAG, "startFeedBuilding relatedItem: $r") - if (r.infoType != InfoItem.InfoType.STREAM) continue - val e = episodeFromStreamInfoItem(r as StreamInfoItem) - e.feed = feed_ - e.feedId = feed_.id - eList.add(e) - } - feed_.episodes.addAll(eList) - if (nextPage == null || feed_.episodes.size > 1000) break - try { - val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs.first(), nextPage) - nextPage = page.nextPage - infoItems = page.items - Logd(TAG, "more infoItems: ${infoItems.size}") - } catch (e: Throwable) { - Logd(TAG, "ChannelTabInfo.getMoreItems error: ${e.message}") - withContext(Dispatchers.Main) { showError(e.message, "") } - break - } - } - feed_.isBuilding = false - } - withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) } - } catch (e: Throwable) { - Logd(TAG, "startFeedBuilding error1 ${e.message}") - withContext(Dispatchers.Main) { showError(e.message, "") } - } + fun isYoutubeChannel(): Boolean { + val uURL = URL(urlInit) + return !uURL.path.startsWith("/playlist") + } + + fun youtubeChannelValidTabs(): Int { + channelInfo = ChannelInfo.getInfo(service, urlInit) + var count = 0 + for (i in channelInfo.tabs.indices) { + val t = channelInfo.tabs[i] + var url = t.url + Logd(TAG, "url: $url ${t.originalUrl} ${t.baseUrl}") + if (!url.startsWith("http")) url = urlInit + url + val uURL = URL(url) + val urlEnd = uURL.path.split("/").last() + if (urlEnd != "playlists" && urlEnd != "shorts") count++ + } + return count + } + + @Composable + fun ConfirmYTChannelTabsDialog(onDismissRequest: () -> Unit, handleFeed: (Feed, Map)->Unit) { + val textColor = MaterialTheme.colorScheme.onSurface + Column { + Text(text = stringResource(R.string.choose_tab), color = textColor, style = MaterialTheme.typography.bodyLarge.merge(), modifier = Modifier.padding(start = 10.dp, bottom = 10.dp)) + val selectedId = remember { mutableStateOf(null) } + for (i in channelInfo.tabs.indices) { + val t = channelInfo.tabs[i] + var url = t.url + Logd(TAG, "url: $url ${t.originalUrl} ${t.baseUrl}") + if (!url.startsWith("http")) url = urlInit + url + val uURL = URL(url) + val urlEnd = uURL.path.split("/").last() + if (urlEnd != "playlists" && urlEnd != "shorts") Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 30.dp)) { + var checked by remember { mutableStateOf(false) } + Checkbox(checked = selectedId.value == i, onCheckedChange = { + selectedId.value = if (selectedId.value == i) null else i + checked = it + if (checked) ytTabsMap[i] = urlEnd else ytTabsMap.remove(i) + }) + Text(text = urlEnd, color = textColor, style = MaterialTheme.typography.bodyLarge.merge(), modifier = Modifier.padding(start = 10.dp)) } - } catch (e: Throwable) { - Logd(TAG, "startFeedBuilding error ${e.message}") - withContext(Dispatchers.Main) { showError(e.message, "") } } } - return + Button(modifier = Modifier.padding(start = 10.dp, top = 10.dp), onClick = { + CoroutineScope(Dispatchers.IO).launch { + for (i in ytTabsMap.keys) { + Logd(TAG, "Subscribing $i ${channelInfo.tabs[i].url}") + buildYTChannel(i, ytTabsMap[i]!!) { feed_, map -> handleFeed(feed_, map) } + } + } + onDismissRequest() + }) { + Text("Confirm") + } } + } -// handle normal podcast source + suspend fun buildYTPlaylist(handleFeed: (Feed, Map)->Unit) { + try { + val url = urlInit + val playlistInfo = PlaylistInfo.getInfo(service, url) ?: return + selectedDownloadUrl = prepareUrl(url) + Logd(TAG, "selectedDownloadUrl: $selectedDownloadUrl url: $url") + val feed_ = Feed(selectedDownloadUrl, null) + feed_.isBuilding = true + feed_.id = Feed.newId() + feed_.type = Feed.FeedType.YOUTUBE.name + feed_.hasVideoMedia = true + feed_.fileUrl = File(feedfilePath, getFeedfileName(feed_)).toString() + val eList: MutableList = mutableListOf() + feed_.title = playlistInfo.name + feed_.description = playlistInfo.description?.content ?: "" + feed_.author = playlistInfo.uploaderName + feed_.imageUrl = if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null + feed_.episodes = realmListOf() + var infoItems = playlistInfo.relatedItems + var nextPage = playlistInfo.nextPage + Logd(TAG, "infoItems: ${infoItems.size}") + CoroutineScope(Dispatchers.IO).launch { + while (infoItems.isNotEmpty()) { + eList.clear() + for (r in infoItems) { + Logd(TAG, "startFeedBuilding relatedItem: $r") + if (r.infoType != InfoItem.InfoType.STREAM) continue + val e = episodeFromStreamInfoItem(r) + e.feed = feed_ + e.feedId = feed_.id + eList.add(e) + } + feed_.episodes.addAll(eList) + if (nextPage == null || feed_.episodes.size > 2000) break + try { + val page = PlaylistInfo.getMoreItems(service, url, nextPage) ?: break + nextPage = page.nextPage + infoItems = page.items + Logd(TAG, "more infoItems: ${infoItems.size}") + } catch (e: Throwable) { + Logd(TAG, "PlaylistInfo.getMoreItems error: ${e.message}") + withContext(Dispatchers.Main) { showError(e.message, "") } + break + } + } + feed_.isBuilding = false + } + withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) } + } catch (e: Throwable) { + Logd(TAG, "startFeedBuilding error ${e.message}") + withContext(Dispatchers.Main) { showError(e.message, "") } + } + } + + suspend fun buildYTChannel(index: Int, title: String, handleFeed: (Feed, Map)->Unit) { + Logd(TAG, "startFeedBuilding result: $channelInfo ${channelInfo.tabs.size}") + var url = channelInfo.tabs[index].url + if (!url.startsWith("http")) url = urlInit + try { + selectedDownloadUrl = prepareUrl(url) + Logd(TAG, "selectedDownloadUrl: $selectedDownloadUrl url: $url") + val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs[index]) + Logd(TAG, "startFeedBuilding result1: $channelTabInfo ${channelTabInfo.relatedItems.size}") + val feed_ = Feed(selectedDownloadUrl, null) + feed_.isBuilding = true + feed_.id = Feed.newId() + feed_.type = Feed.FeedType.YOUTUBE.name + feed_.hasVideoMedia = true + feed_.fileUrl = File(feedfilePath, getFeedfileName(feed_)).toString() + val eList: MutableList = mutableListOf() + feed_.title = channelInfo.name + " " + title + feed_.description = channelInfo.description + feed_.author = channelInfo.parentChannelName + feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null + feed_.episodes = realmListOf() + + var infoItems = channelTabInfo.relatedItems + var nextPage = channelTabInfo.nextPage + Logd(TAG, "infoItems: ${infoItems.size}") + CoroutineScope(Dispatchers.IO).launch { + while (infoItems.isNotEmpty()) { + eList.clear() + for (r in infoItems) { + Logd(TAG, "startFeedBuilding relatedItem: $r") + if (r.infoType != InfoItem.InfoType.STREAM) continue + val e = episodeFromStreamInfoItem(r as StreamInfoItem) + e.feed = feed_ + e.feedId = feed_.id + eList.add(e) + } + feed_.episodes.addAll(eList) + if (nextPage == null || feed_.episodes.size > 2000) break + try { + val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs.first(), nextPage) + nextPage = page.nextPage + infoItems = page.items + Logd(TAG, "more infoItems: ${infoItems.size}") + } catch (e: Throwable) { + Logd(TAG, "ChannelTabInfo.getMoreItems error: ${e.message}") + withContext(Dispatchers.Main) { showError(e.message, "") } + break + } + } + feed_.isBuilding = false + } + withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) } + } catch (e: Throwable) { + Logd(TAG, "startFeedBuilding error1 ${e.message}") + withContext(Dispatchers.Main) { showError(e.message, "") } + } + } + + fun buildPodcast(url: String, username: String?, password: String?, handleFeed: (Feed, Map)->Unit) { when (val urlType = htmlOrXml(url)) { "HTML" -> { val doc = Jsoup.connect(url).get() @@ -159,7 +242,7 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit) for (element in linkElements) { val rssUrl = element.attr("href") Logd(TAG, "RSS URL: $rssUrl") - startFeedBuilding(rssUrl, username, password) {feed, map -> handleFeed(feed, map) } + buildPodcast(rssUrl, username, password) {feed, map -> handleFeed(feed, map) } } } "XML" -> {} @@ -280,5 +363,4 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit) // } Logd(TAG, "fo.id: ${fo?.id} feed.id: ${feed.id}") } - } \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt index 9ff2c81e..7e90e0fd 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedUpdateManager.kt @@ -31,6 +31,7 @@ import ac.mdiq.vista.extractor.Vista import ac.mdiq.vista.extractor.channel.ChannelInfo import ac.mdiq.vista.extractor.channel.tabs.ChannelTabInfo import ac.mdiq.vista.extractor.exceptions.ExtractionException +import ac.mdiq.vista.extractor.playlist.PlaylistInfo import ac.mdiq.vista.extractor.stream.StreamInfoItem import android.Manifest import android.app.Notification @@ -52,6 +53,7 @@ import io.realm.kotlin.types.RealmList import org.xml.sax.SAXException import java.io.File import java.io.IOException +import java.net.URL import java.util.* import java.util.concurrent.Callable import java.util.concurrent.TimeUnit @@ -233,29 +235,50 @@ object FeedUpdateManager { } } private fun refreshYoutubeFeed(feed: Feed) { + if (feed.downloadUrl.isNullOrEmpty()) return + val url = feed.downloadUrl try { - val service = try { Vista.getService("YouTube") } catch (e: ExtractionException) { throw ExtractionException("YouTube service not found") } - val channelInfo = ChannelInfo.getInfo(service, feed.downloadUrl!!) - Logd(TAG, "refreshYoutubeFeed channelInfo: $channelInfo ${channelInfo.tabs.size}") - if (channelInfo.tabs.isEmpty()) return - try { - val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs.first()) - Logd(TAG, "refreshYoutubeFeed result1: $channelTabInfo ${channelTabInfo.relatedItems.size}") + val service = try { Vista.getService("YouTube") + } catch (e: ExtractionException) { throw ExtractionException("YouTube service not found") } + + val uURL = URL(url) + if (uURL.path.startsWith("/channel")) { + val channelInfo = ChannelInfo.getInfo(service, url!!) + Logd(TAG, "refreshYoutubeFeed channelInfo: $channelInfo ${channelInfo.tabs.size}") + if (channelInfo.tabs.isEmpty()) return + try { + val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs.first()) + Logd(TAG, "refreshYoutubeFeed result1: $channelTabInfo ${channelTabInfo.relatedItems.size}") + val eList: RealmList = realmListOf() + for (r in channelTabInfo.relatedItems) eList.add(episodeFromStreamInfoItem(r as StreamInfoItem)) + val feed_ = Feed(url, null) + feed_.type = Feed.FeedType.YOUTUBE.name + feed_.hasVideoMedia = true + feed_.title = channelInfo.name + feed_.fileUrl = File(feedfilePath, getFeedfileName(feed_)).toString() + feed_.description = channelInfo.description + feed_.author = channelInfo.parentChannelName + feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null + feed_.episodes = eList + Feeds.updateFeed(applicationContext, feed_, false) + } catch (e: Throwable) { Logd(TAG, "refreshYoutubeFeed channel error1 ${e.message}") } + } else if (uURL.path.startsWith("/playlist")) { + val playlistInfo = PlaylistInfo.getInfo(Vista.getService(0), url) ?: return val eList: RealmList = realmListOf() - for (r in channelTabInfo.relatedItems) { - eList.add(episodeFromStreamInfoItem(r as StreamInfoItem)) - } - val feed_ = Feed(feed.downloadUrl, null) - feed_.type = Feed.FeedType.YOUTUBE.name - feed_.hasVideoMedia = true - feed_.title = channelInfo.name - feed_.fileUrl = File(feedfilePath, getFeedfileName(feed_)).toString() - feed_.description = channelInfo.description - feed_.author = channelInfo.parentChannelName - feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null - feed_.episodes = eList - Feeds.updateFeed(applicationContext, feed_, false) - } catch (e: Throwable) { Logd(TAG, "refreshYoutubeFeed error1 ${e.message}") } + try { + for (r in playlistInfo.relatedItems) eList.add(episodeFromStreamInfoItem(r)) + val feed_ = Feed(url, null) + feed_.type = Feed.FeedType.YOUTUBE.name + feed_.hasVideoMedia = true + feed_.title = playlistInfo.name + feed_.fileUrl = File(feedfilePath, getFeedfileName(feed_)).toString() + feed_.description = playlistInfo.description?.content ?: "" + feed_.author = playlistInfo.uploaderName + feed_.imageUrl = if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null + feed_.episodes = eList + Feeds.updateFeed(applicationContext, feed_, false) + } catch (e: Throwable) { Logd(TAG, "refreshYoutubeFeed playlist error1 ${e.message}") } + } } catch (e: Throwable) { Logd(TAG, "refreshYoutubeFeed error ${e.message}") } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index d6559dd6..cc821842 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -36,7 +36,6 @@ import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.Logd import android.Manifest import android.annotation.SuppressLint -import android.app.AppOpsManager import android.content.ComponentName import android.content.Context import android.content.DialogInterface @@ -60,8 +59,6 @@ import android.widget.EditText import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.ActionBarDrawerToggle -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat @@ -69,7 +66,6 @@ import androidx.core.view.WindowInsetsCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope - import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import androidx.recyclerview.widget.RecyclerView diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt index bdf7f648..ad3456fb 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt @@ -986,9 +986,7 @@ fun ConfirmAddYoutubeEpisode(sharedUrls: List, showDialog: Boolean, onDi } withContext(Dispatchers.Main) { onDismissRequest() } } - }) { - Text("Confirm") - } + }) { Text("Confirm") } } else CircularProgressIndicator(progress = { 0.6f }, strokeWidth = 4.dp, modifier = Modifier.padding(start = 20.dp, end = 20.dp).width(30.dp).height(30.dp)) } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt index 43a3d2f3..978733b2 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt @@ -3,7 +3,6 @@ package ac.mdiq.podcini.ui.compose import ac.mdiq.podcini.R import ac.mdiq.podcini.net.feed.FeedBuilder import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult -import ac.mdiq.podcini.storage.database.Feeds import ac.mdiq.podcini.storage.database.Feeds.deleteFeedSync import ac.mdiq.podcini.storage.database.RealmDB.upsert import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk @@ -55,10 +54,7 @@ fun ChooseRatingDialog(selected: List, onDismissRequest: () -> Unit) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { for (rating in Rating.entries.reversed()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable { - for (item in selected) { -// Feeds.setRating(item, rating.code) - upsertBlk(item) { it.rating = rating.code } - } + for (item in selected) upsertBlk(item) { it.rating = rating.code } onDismissRequest() }) { Icon(imageVector = ImageVector.vectorResource(id = rating.res), "") @@ -85,8 +81,7 @@ fun RemoveFeedDialog(feeds: List, onDismissRequest: () -> Unit, callback: Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Text(message) Text(stringResource(R.string.feed_delete_reason_msg)) - BasicTextField(value = textState, onValueChange = { textState = it }, - textStyle = TextStyle(fontSize = 16.sp, color = textColor), + BasicTextField(value = textState, onValueChange = { textState = it }, textStyle = TextStyle(fontSize = 16.sp, color = textColor), modifier = Modifier.fillMaxWidth().height(100.dp).padding(start = 10.dp, end = 10.dp, bottom = 10.dp).border(1.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small) ) Button(onClick = { @@ -119,9 +114,7 @@ fun RemoveFeedDialog(feeds: List, onDismissRequest: () -> Unit, callback: } catch (e: Throwable) { Log.e("RemoveFeedDialog", Log.getStackTraceString(e)) } } onDismissRequest() - }) { - Text("Confirm") - } + }) { Text("Confirm") } } } } @@ -130,41 +123,43 @@ fun RemoveFeedDialog(feeds: List, onDismissRequest: () -> Unit, callback: @OptIn(ExperimentalFoundationApi::class) @Composable fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult, log: SubscriptionLog? = null) { +// var showYTChannelDialog by remember { mutableStateOf(false) } +// if (showYTChannelDialog) feedBuilder.ConfirmYTChannelTabsDialog(onDismissRequest = {showYTChannelDialog = false}) {feed, map -> handleFeed(feed, map)} + val showSubscribeDialog = remember { mutableStateOf(false) } @Composable fun confirmSubscribe(feed: PodcastSearchResult, showDialog: Boolean, onDismissRequest: () -> Unit) { if (showDialog) { Dialog(onDismissRequest = { onDismissRequest() }) { - Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), - shape = RoundedCornerShape(16.dp)) { + Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) { + val textColor = MaterialTheme.colorScheme.onSurface Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) { - Text("Subscribe: \"${feed.title}\" ?") + Text("Subscribe: \"${feed.title}\" ?", color = textColor, modifier = Modifier.padding(bottom = 10.dp)) Button(onClick = { CoroutineScope(Dispatchers.IO).launch { if (feed.feedUrl != null) { - val feedBuilder = FeedBuilder(activity) { message, details -> - Logd("OnineFeedItem", "Subscribe error: $message \n $details") - } + val feedBuilder = FeedBuilder(activity) { message, details -> Logd("OnineFeedItem", "Subscribe error: $message \n $details") } feedBuilder.feedSource = feed.source - feedBuilder.startFeedBuilding(feed.feedUrl, - "", - "") { feed, _ -> feedBuilder.subscribe(feed) } + val url = feed.feedUrl + if (feedBuilder.isYoutube(url)) { + if (feedBuilder.isYoutubeChannel()) { + val nTabs = feedBuilder.youtubeChannelValidTabs() + feedBuilder.buildYTChannel(0, "") { feed, _ -> feedBuilder.subscribe(feed) } +// if (nTabs > 1) showYTChannelDialog = true +// else feedBuilder.buildYTChannel(0, "") { feed, map -> feedBuilder.subscribe(feed) } + } else feedBuilder.buildYTPlaylist { feed, _ -> feedBuilder.subscribe(feed) } + } else feedBuilder.buildPodcast(url, "", "") { feed, _ -> feedBuilder.subscribe(feed) } } } onDismissRequest() - }) { - Text("Confirm") - } + }) { Text("Confirm") } } } } } } - if (showSubscribeDialog.value) { - confirmSubscribe(feed, showSubscribeDialog.value, onDismissRequest = { - showSubscribeDialog.value = false - }) - } + if (showSubscribeDialog.value) confirmSubscribe(feed, showSubscribeDialog.value, onDismissRequest = { showSubscribeDialog.value = false }) + val context = LocalContext.current Column(Modifier.padding(start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp).combinedClickable( onClick = { @@ -180,11 +175,7 @@ fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult, log: Subsc } }, onLongClick = { showSubscribeDialog.value = true })) { val textColor = MaterialTheme.colorScheme.onSurface - Text(feed.title, - color = textColor, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(bottom = 4.dp)) + Text(feed.title, color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = 4.dp)) Row { ConstraintLayout(modifier = Modifier.width(56.dp).height(56.dp)) { val (imgvCover, checkMark) = createRefs() @@ -196,7 +187,6 @@ fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult, log: Subsc bottom.linkTo(parent.bottom) start.linkTo(parent.start) }) - if (feed.feedId > 0 || log != null) { Logd("OnlineFeedItem", "${feed.feedId} $log") val alpha = 1.0f @@ -215,26 +205,14 @@ fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult, log: Subsc feed.feedUrl != null && !feed.feedUrl.contains("itunes.apple.com") -> feed.feedUrl else -> "" } - if (authorText.isNotEmpty()) Text(authorText, - color = textColor, - style = MaterialTheme.typography.bodyMedium) - if (feed.subscriberCount > 0) Text(MiscFormatter.formatNumber(feed.subscriberCount) + " subscribers", - color = textColor, - style = MaterialTheme.typography.bodyMedium) + if (authorText.isNotEmpty()) Text(authorText, color = textColor, style = MaterialTheme.typography.bodyMedium) + if (feed.subscriberCount > 0) Text(MiscFormatter.formatNumber(feed.subscriberCount) + " subscribers", color = textColor, style = MaterialTheme.typography.bodyMedium) Row { - if (feed.count != null && feed.count > 0) Text(feed.count.toString() + " episodes", - color = textColor, - style = MaterialTheme.typography.bodyMedium) + if (feed.count != null && feed.count > 0) Text(feed.count.toString() + " episodes", color = textColor, style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.weight(1f)) - if (feed.update != null) Text(feed.update, - color = textColor, - style = MaterialTheme.typography.bodyMedium) + if (feed.update != null) Text(feed.update, color = textColor, style = MaterialTheme.typography.bodyMedium) } - Text(feed.source + ": " + feed.feedUrl, - color = textColor, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.labelSmall) + Text(feed.source + ": " + feed.feedUrl, color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt index 234417a5..5b1f27a9 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt @@ -77,7 +77,6 @@ import kotlin.concurrent.Volatile * If the feed cannot be downloaded or parsed, an error dialog will be displayed * and the activity will finish as soon as the error dialog is closed. */ - class OnlineFeedFragment : Fragment() { private var _binding: ComposeFragmentBinding? = null private val binding get() = _binding!! @@ -86,7 +85,9 @@ class OnlineFeedFragment : Fragment() { var feedSource: String = "" private var feedUrl: String = "" + private var urlToLog: String = "" private lateinit var feedBuilder: FeedBuilder + private var showYTChannelDialog by mutableStateOf(false) private var isShared: Boolean = false @@ -134,6 +135,7 @@ class OnlineFeedFragment : Fragment() { binding.mainView.setContent { CustomTheme(requireContext()) { + if (showYTChannelDialog) feedBuilder.ConfirmYTChannelTabsDialog(onDismissRequest = {showYTChannelDialog = false}) {feed, map -> handleFeed(feed, map)} MainView() } } @@ -181,23 +183,32 @@ class OnlineFeedFragment : Fragment() { outState.putString("password", password) } + private fun handleFeed(feed_: Feed, map: Map) { + selectedDownloadUrl = feedBuilder.selectedDownloadUrl + feed = feed_ + if (isShared) { + val log = realm.query(ShareLog::class).query("url == $0", urlToLog).first().find() + if (log != null) upsertBlk(log) { + it.title = feed_.title + it.author = feed_.author + } + } + showFeedInformation(feed_, map) + } + private fun lookupUrlAndBuild(url: String) { lifecycleScope.launch(Dispatchers.IO) { + urlToLog = url val urlString = PodcastSearcherRegistry.lookupUrl1(url) try { feeds = getFeedList() - feedBuilder.startFeedBuilding(urlString, username, password) { feed_, map -> - selectedDownloadUrl = feedBuilder.selectedDownloadUrl - feed = feed_ - if (isShared) { - val log = realm.query(ShareLog::class).query("url == $0", url).first().find() - if (log != null) upsertBlk(log) { - it.title = feed_.title - it.author = feed_.author - } - } - showFeedInformation(feed_, map) - } + if (feedBuilder.isYoutube(urlString)) { + if (feedBuilder.isYoutubeChannel()) { + val nTabs = feedBuilder.youtubeChannelValidTabs() + if (nTabs > 1) showYTChannelDialog = true + else feedBuilder.buildYTChannel(0, "") { feed_, map -> handleFeed(feed_, map) } + } else feedBuilder.buildYTPlaylist { feed_, map -> handleFeed(feed_, map) } + } else feedBuilder.buildPodcast(urlString, username, password) { feed_, map -> handleFeed(feed_, map) } } catch (e: FeedUrlNotFoundException) { tryToRetrieveFeedUrlBySearch(e) } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) @@ -223,21 +234,17 @@ class OnlineFeedFragment : Fragment() { } } if (url != null) { + urlToLog = url Logd(TAG, "Successfully retrieve feed url") isFeedFoundBySearch = true feeds = getFeedList() - feedBuilder.startFeedBuilding(url, username, password) { feed_, map -> - selectedDownloadUrl = feedBuilder.selectedDownloadUrl - feed = feed_ - if (isShared) { - val log = realm.query(ShareLog::class).query("url == $0", url).first().find() - if (log != null) upsertBlk(log) { - it.title = feed_.title - it.author = feed_.author - } - } - showFeedInformation(feed_, map) - } + if (feedBuilder.isYoutube(url)) { + if (feedBuilder.isYoutubeChannel()) { + val nTabs = feedBuilder.youtubeChannelValidTabs() + if (nTabs > 1) showYTChannelDialog = true + else feedBuilder.buildYTChannel(0, "") { feed_, map -> handleFeed(feed_, map) } + } else feedBuilder.buildYTPlaylist { feed_, map -> handleFeed(feed_, map) } + } else feedBuilder.buildPodcast(url, username, password) { feed_, map -> handleFeed(feed_, map) } } else { showNoPodcastFoundError() Logd(TAG, "Failed to retrieve feed url") @@ -352,20 +359,19 @@ class OnlineFeedFragment : Fragment() { val (progressBar, main) = createRefs() if (showProgress) CircularProgressIndicator(progress = { 0.6f }, strokeWidth = 10.dp, modifier = Modifier.size(50.dp).constrainAs(progressBar) { centerTo(parent) }) - else Column(modifier = Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp) - .constrainAs(main) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - }) { + else Column(modifier = Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp).constrainAs(main) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + }) { if (showFeedDisplay) ConstraintLayout(modifier = Modifier.fillMaxWidth().height(120.dp).background(MaterialTheme.colorScheme.surface)) { val (backgroundImage, coverImage, taColumn, buttons, closeButton) = createRefs() - if (false) Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_settings_white), contentDescription = "background", - Modifier.fillMaxWidth().height(120.dp).constrainAs(backgroundImage) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - }) +// if (false) Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_settings_white), contentDescription = "background", +// Modifier.fillMaxWidth().height(120.dp).constrainAs(backgroundImage) { +// top.linkTo(parent.top) +// bottom.linkTo(parent.bottom) +// start.linkTo(parent.start) +// }) AsyncImage(model = feed?.imageUrl?:"", contentDescription = "coverImage", error = painterResource(R.mipmap.ic_launcher), modifier = Modifier.width(100.dp).height(100.dp).padding(start = 10.dp, end = 16.dp, bottom = 10.dp).constrainAs(coverImage) { bottom.linkTo(parent.bottom) @@ -387,9 +393,7 @@ class OnlineFeedFragment : Fragment() { if (feedInFeedlist() || isSubscribed(feed!!)) { if (isShared) { val log = realm.query(ShareLog::class).query("url == $0", feedUrl).first().find() - if (log != null) upsertBlk(log) { - it.status = ShareLog.Status.EXISTING.ordinal - } + if (log != null) upsertBlk(log) { it.status = ShareLog.Status.EXISTING.ordinal } } val feed = getFeedByTitleAndAuthor(feed?.eigenTitle?:"", feed?.author?:"") if (feed != null ) (activity as MainActivity).loadChildFragment(FeedInfoFragment.newInstance(feed)) @@ -402,9 +406,7 @@ class OnlineFeedFragment : Fragment() { feedBuilder.subscribe(feed!!) if (isShared) { val log = realm.query(ShareLog::class).query("url == $0", feedUrl).first().find() - if (log != null) upsertBlk(log) { - it.status = ShareLog.Status.SUCCESS.ordinal - } + if (log != null) upsertBlk(log) { it.status = ShareLog.Status.SUCCESS.ordinal } } withContext(Dispatchers.Main) { enableSubscribe = true @@ -413,20 +415,16 @@ class OnlineFeedFragment : Fragment() { } } } - }) { - Text(stringResource(subButTextRes)) - } + }) { Text(stringResource(subButTextRes)) } Spacer(modifier = Modifier.weight(0.1f)) - if (enableEpisodes && feed != null) Button(onClick = { showEpisodes(feed!!.episodes) }) { - Text(stringResource(R.string.episodes_label)) - } + if (enableEpisodes && feed != null) Button(onClick = { showEpisodes(feed!!.episodes) }) { Text(stringResource(R.string.episodes_label)) } Spacer(modifier = Modifier.weight(0.2f)) } - if (false) Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_close_white), contentDescription = null, modifier = Modifier - .constrainAs(closeButton) { - top.linkTo(parent.top) - end.linkTo(parent.end) - }) +// if (false) Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_close_white), contentDescription = null, modifier = Modifier +// .constrainAs(closeButton) { +// top.linkTo(parent.top) +// end.linkTo(parent.end) +// }) } Column { // alternate_urls_spinner @@ -706,7 +704,6 @@ class OnlineFeedFragment : Fragment() { } } - class RemoteEpisodesFragment : BaseEpisodesFragment() { private val episodeList: MutableList = mutableListOf() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt index 84720929..84c68128 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt @@ -148,13 +148,15 @@ class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener { spinnerView = ComposeView(requireContext()).apply { setContent { - Spinner(items = spinnerTexts, selectedItem = spinnerTexts[0]) { index:Int -> - Logd(TAG, "Queue selected: $queues[index].name") - val prevQueueSize = curQueue.size() - curQueue = upsertBlk(queues[index]) { it.update() } - toolbar.menu?.findItem(R.id.rename_queue)?.setVisible(curQueue.name != "Default") - loadCurQueue(true) - playbackService?.notifyCurQueueItemsChanged(max(prevQueueSize, curQueue.size())) + CustomTheme(requireContext()) { + Spinner(items = spinnerTexts, selectedItem = spinnerTexts[0]) { index: Int -> + Logd(TAG, "Queue selected: $queues[index].name") + val prevQueueSize = curQueue.size() + curQueue = upsertBlk(queues[index]) { it.update() } + toolbar.menu?.findItem(R.id.rename_queue)?.setVisible(curQueue.name != "Default") + loadCurQueue(true) + playbackService?.notifyCurQueueItemsChanged(max(prevQueueSize, curQueue.size())) + } } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt index b55f2ffe..e3b18f87 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt @@ -65,7 +65,6 @@ import java.text.NumberFormat /** * Performs a search operation on all feeds or one specific feed and displays the search result. */ - class SearchFragment : Fragment() { private var _binding: SearchFragmentBinding? = null private val binding get() = _binding!! @@ -120,7 +119,6 @@ class SearchFragment : Fragment() { } refreshSwipeTelltale() - chip = binding.feedTitleChip chip.setOnCloseIconClickListener { requireArguments().putLong(ARG_FEED, 0) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsFragment.kt index 4066a389..36539918 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsFragment.kt @@ -54,7 +54,8 @@ class FeedStatisticsFragment : Fragment() { } private fun showStats(s: StatisticsItem?) { - binding.startedTotalLabel.text = String.format(Locale.getDefault(), "%d / %d", s!!.episodesStarted, s.numEpisodes) + if (s == null) return + binding.startedTotalLabel.text = String.format(Locale.getDefault(), "%d / %d", s.episodesStarted, s.numEpisodes) binding.timePlayedLabel.text = shortLocalizedDuration(requireContext(), s.timePlayed) binding.totalDurationLabel.text = shortLocalizedDuration(requireContext(), s.time) binding.onDeviceLabel.text = String.format(Locale.getDefault(), "%d", s.episodesDownloadCount) diff --git a/app/src/main/res/drawable/baseline_dynamic_feed_24.xml b/app/src/main/res/drawable/baseline_dynamic_feed_24.xml index f33c5257..d69ce5a8 100644 --- a/app/src/main/res/drawable/baseline_dynamic_feed_24.xml +++ b/app/src/main/res/drawable/baseline_dynamic_feed_24.xml @@ -1,9 +1,9 @@ - + - + - + - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc7a41cc..b953adc7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -276,6 +276,8 @@ Add opinion Cancelled on + Choose a tab in the channel + Set played state Mark as read diff --git a/changelog.md b/changelog.md index f520aa96..e8603579 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,15 @@ +# 6.13.5 + +* hopefully fixed youtube playlists/podcasts no-updating issue +* in OnlineFeed, when subscribing to a Youtube channel, a popup allows to choose a tab (if multiples are available) + * tab options include Videos (or customized name, the default tab) and Streams (live tab) + * only one tab can be chosen at a time + * the tab name will be appended to the feed title + * each tab results in a feed/podcast in Podcini +* the shortcut way of subscribing (long-click on a search result) only subscribes to the default tab +* episodes limits on subscribing to YT channels/playlists/podcasts are raised to 2000 +* fixed toolbar contrasts on Queues view + # 6.13.4 * in Queues view, reworked the spinner in Compose and added associated feeds toggle diff --git a/fastlane/metadata/android/en-US/changelogs/3020292.txt b/fastlane/metadata/android/en-US/changelogs/3020292.txt new file mode 100644 index 00000000..e7defcfa --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020292.txt @@ -0,0 +1,11 @@ + Version 6.13.5 + +* hopefully fixed youtube playlists/podcasts no-updating issue +* in OnlineFeed, when subscribing to a Youtube channel, a popup allows to choose a tab (if multiples are available) + * tab options include Videos (or customized name, the default tab) and Streams (live tab) + * only one tab can be chosen at a time + * the tab name will be appended to the feed title + * each tab results in a feed/podcast in Podcini +* the shortcut way of subscribing (long-click on a search result) only subscribes to the default tab +* episodes limits on subscribing to YT channels/playlists/podcasts are raised to 2000 +* fixed toolbar contrasts on Queues view