6.9.0 commit

This commit is contained in:
Xilin Jia 2024-10-06 19:00:44 +01:00
parent 13a4f8c5b2
commit 72f28ce9b7
49 changed files with 1390 additions and 1439 deletions

View File

@ -16,6 +16,7 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin
That means finally: [Nessun dorma](https://www.youtube.com/watch?v=cWc7vYjgnTs) 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. #### 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. #### 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.8.5, Podcini.R is built to target SDK 30 (Android 11), though built with SDK 35 and tested on Android 14. This is to counter 2-year old Google issue ForegroundServiceStartNotAllowedException. For more see [this issue](https://github.com/XilinJia/Podcini/issues/88)
#### 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.
This project was developed from a fork of [AntennaPod](<https://github.com/AntennaPod/AntennaPod>) as of Feb 5 2024. This project was developed from a fork of [AntennaPod](<https://github.com/AntennaPod/AntennaPod>) as of Feb 5 2024.
@ -138,6 +139,7 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
* 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 * 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
* These media are played with the lowest video quality and highest audio quality * These media are played with the lowest video quality and highest audio quality
* If a subscription is set for "audio only", then only audio stream is fetched at play time for every media in the subscription * If a subscription is set for "audio only", then only audio stream is fetched at play time for every media in the subscription
* accepted host names include: youtube.com, www.youtube.com, m.youtube.com, music.youtube.com, and youtu.be
### 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 3020264 versionCode 3020265
versionName "6.8.7" versionName "6.9.0"
applicationId "ac.mdiq.podcini.R" applicationId "ac.mdiq.podcini.R"
def commit = "" def commit = ""

View File

@ -25,10 +25,7 @@ import android.content.Context
import android.util.Log import android.util.Log
import io.realm.kotlin.ext.realmListOf import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.types.RealmList import io.realm.kotlin.types.RealmList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup import org.jsoup.Jsoup
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -50,22 +47,28 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
val service = try { Vista.getService("YouTube") } catch (e: ExtractionException) { throw ExtractionException("YouTube service not found") } val service = try { Vista.getService("YouTube") } catch (e: ExtractionException) { throw ExtractionException("YouTube service not found") }
selectedDownloadUrl = prepareUrl(url) selectedDownloadUrl = prepareUrl(url)
val feed_ = Feed(selectedDownloadUrl, null) val feed_ = Feed(selectedDownloadUrl, null)
feed_.isBuilding = true
feed_.id = Feed.newId() feed_.id = Feed.newId()
feed_.type = Feed.FeedType.YOUTUBE.name feed_.type = Feed.FeedType.YOUTUBE.name
feed_.hasVideoMedia = true feed_.hasVideoMedia = true
feed_.fileUrl = File(feedfilePath, getFeedfileName(feed_)).toString() feed_.fileUrl = File(feedfilePath, getFeedfileName(feed_)).toString()
val eList: RealmList<Episode> = realmListOf() val eList: MutableList<Episode> = mutableListOf()
if (url.startsWith("https://youtube.com/playlist?") || url.startsWith("https://music.youtube.com/playlist?")) { val uURL = URL(url)
// if (url.startsWith("https://youtube.com/playlist?") || url.startsWith("https://music.youtube.com/playlist?")) {
if (uURL.path.startsWith("/playlist") || uURL.path.startsWith("/playlist")) {
val playlistInfo = PlaylistInfo.getInfo(Vista.getService(0), url) ?: return@launch val playlistInfo = PlaylistInfo.getInfo(Vista.getService(0), url) ?: return@launch
feed_.title = playlistInfo.name feed_.title = playlistInfo.name
feed_.description = playlistInfo.description?.content ?: "" feed_.description = playlistInfo.description?.content ?: ""
feed_.author = playlistInfo.uploaderName feed_.author = playlistInfo.uploaderName
feed_.imageUrl = if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null feed_.imageUrl = if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null
feed_.episodes = realmListOf()
var infoItems = playlistInfo.relatedItems var infoItems = playlistInfo.relatedItems
var nextPage = playlistInfo.nextPage var nextPage = playlistInfo.nextPage
Logd(TAG, "infoItems: ${infoItems.size}") Logd(TAG, "infoItems: ${infoItems.size}")
CoroutineScope(Dispatchers.IO).launch {
while (infoItems.isNotEmpty()) { while (infoItems.isNotEmpty()) {
eList.clear()
for (r in infoItems) { for (r in infoItems) {
Logd(TAG, "startFeedBuilding relatedItem: $r") Logd(TAG, "startFeedBuilding relatedItem: $r")
if (r.infoType != InfoItem.InfoType.STREAM) continue if (r.infoType != InfoItem.InfoType.STREAM) continue
@ -74,7 +77,7 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
e.feedId = feed_.id e.feedId = feed_.id
eList.add(e) eList.add(e)
} }
if (nextPage == null || eList.size > 500) break if (nextPage == null || feed_.episodes.size > 1000) break
try { try {
val page = PlaylistInfo.getMoreItems(service, url, nextPage) ?: break val page = PlaylistInfo.getMoreItems(service, url, nextPage) ?: break
nextPage = page.nextPage nextPage = page.nextPage
@ -85,8 +88,10 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
withContext(Dispatchers.Main) { showError(e.message, "") } withContext(Dispatchers.Main) { showError(e.message, "") }
break break
} }
feed_.episodes.addAll(eList)
}
feed_.isBuilding = false
} }
feed_.episodes = eList
withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) } withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) }
} else { } else {
val channelInfo = ChannelInfo.getInfo(service, url) val channelInfo = ChannelInfo.getInfo(service, url)
@ -102,11 +107,14 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
feed_.description = channelInfo.description feed_.description = channelInfo.description
feed_.author = channelInfo.parentChannelName feed_.author = channelInfo.parentChannelName
feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null
feed_.episodes = realmListOf()
var infoItems = channelTabInfo.relatedItems var infoItems = channelTabInfo.relatedItems
var nextPage = channelTabInfo.nextPage var nextPage = channelTabInfo.nextPage
Logd(TAG, "infoItems: ${infoItems.size}") Logd(TAG, "infoItems: ${infoItems.size}")
CoroutineScope(Dispatchers.IO).launch {
while (infoItems.isNotEmpty()) { while (infoItems.isNotEmpty()) {
eList.clear()
for (r in infoItems) { for (r in infoItems) {
Logd(TAG, "startFeedBuilding relatedItem: $r") Logd(TAG, "startFeedBuilding relatedItem: $r")
if (r.infoType != InfoItem.InfoType.STREAM) continue if (r.infoType != InfoItem.InfoType.STREAM) continue
@ -115,7 +123,7 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
e.feedId = feed_.id e.feedId = feed_.id
eList.add(e) eList.add(e)
} }
if (nextPage == null || eList.size > 200) break if (nextPage == null || feed_.episodes.size > 1000) break
try { try {
val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs.first(), nextPage) val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs.first(), nextPage)
nextPage = page.nextPage nextPage = page.nextPage
@ -126,8 +134,10 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
withContext(Dispatchers.Main) { showError(e.message, "") } withContext(Dispatchers.Main) { showError(e.message, "") }
break break
} }
feed_.episodes.addAll(eList)
}
feed_.isBuilding = false
} }
feed_.episodes = eList
withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) } withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) }
} catch (e: Throwable) { } catch (e: Throwable) {
Logd(TAG, "startFeedBuilding error1 ${e.message}") Logd(TAG, "startFeedBuilding error1 ${e.message}")
@ -202,8 +212,11 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
val destinationFile = File(destination) val destinationFile = File(destination)
return try { return try {
val feed = Feed(selectedDownloadUrl, null) val feed = Feed(selectedDownloadUrl, null)
feed.isBuilding = true
feed.fileUrl = destination feed.fileUrl = destination
FeedHandler().parseFeed(feed) val result = FeedHandler().parseFeed(feed)
feed.isBuilding = false
result
} catch (e: FeedHandler.UnsupportedFeedtypeException) { } catch (e: FeedHandler.UnsupportedFeedtypeException) {
Logd(TAG, "Unsupported feed type detected") Logd(TAG, "Unsupported feed type detected")
if ("html".equals(e.rootElement, ignoreCase = true)) { if ("html".equals(e.rootElement, ignoreCase = true)) {
@ -250,6 +263,9 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
} }
fun subscribe(feed: Feed) { fun subscribe(feed: Feed) {
while (feed.isBuilding) {
runBlocking { delay(200) }
}
feed.id = 0L feed.id = 0L
for (item in feed.episodes) { for (item in feed.episodes) {
item.id = 0L item.id = 0L

View File

@ -2,6 +2,9 @@ package ac.mdiq.podcini.net.feed.discovery
import ac.mdiq.podcini.net.sync.gpoddernet.model.GpodnetPodcast import ac.mdiq.podcini.net.sync.gpoddernet.model.GpodnetPodcast
import ac.mdiq.vista.extractor.channel.ChannelInfoItem import ac.mdiq.vista.extractor.channel.ChannelInfoItem
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.setValue
import de.mfietz.fyydlin.SearchHit import de.mfietz.fyydlin.SearchHit
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
@ -18,6 +21,9 @@ class PodcastSearchResult private constructor(
val subscriberCount: Int, val subscriberCount: Int,
val source: String) { val source: String) {
// feedId will be positive if already subscribed
var feedId by mutableLongStateOf(0L)
companion object { companion object {
fun dummy(): PodcastSearchResult { fun dummy(): PodcastSearchResult {
return PodcastSearchResult("", "", "", "", 0, "", -1, "dummy") return PodcastSearchResult("", "", "", "", 0, "", -1, "dummy")

View File

@ -25,7 +25,6 @@ class FeedHandler {
// val tg = TypeGetter() // val tg = TypeGetter()
val type = getType(feed) val type = getType(feed)
val handler = SyndHandler(feed, type) val handler = SyndHandler(feed, type)
if (feed.fileUrl != null) { if (feed.fileUrl != null) {
val factory = SAXParserFactory.newInstance() val factory = SAXParserFactory.newInstance()
factory.isNamespaceAware = true factory.isNamespaceAware = true

View File

@ -436,6 +436,7 @@ object Feeds {
feed.preferences!!.queueId = -2L feed.preferences!!.queueId = -2L
feed.preferences!!.videoModePolicy = if (video) VideoMode.WINDOW_VIEW else VideoMode.AUDIO_ONLY feed.preferences!!.videoModePolicy = if (video) VideoMode.WINDOW_VIEW else VideoMode.AUDIO_ONLY
upsertBlk(feed) {} upsertBlk(feed) {}
EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.ADDED))
return feed return feed
} }
@ -452,6 +453,7 @@ object Feeds {
upsertBlk(episode) {} upsertBlk(episode) {}
feed.episodes.add(episode) feed.episodes.add(episode)
upsertBlk(feed) {} upsertBlk(feed) {}
EventFlow.postStickyEvent(FlowEvent.FeedUpdatingEvent(false))
} }
private fun getMiscSyndicate(): Feed { private fun getMiscSyndicate(): Feed {
@ -470,6 +472,7 @@ object Feeds {
feed.preferences!!.queueId = -2L feed.preferences!!.queueId = -2L
// feed.preferences!!.videoModePolicy = if (video) VideoMode.WINDOW_VIEW else VideoMode.AUDIO_ONLY // feed.preferences!!.videoModePolicy = if (video) VideoMode.WINDOW_VIEW else VideoMode.AUDIO_ONLY
upsertBlk(feed) {} upsertBlk(feed) {}
EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.ADDED))
return feed return feed
} }
@ -485,6 +488,7 @@ object Feeds {
upsertBlk(episode) {} upsertBlk(episode) {}
feed.episodes.add(episode) feed.episodes.add(episode)
upsertBlk(feed) {} upsertBlk(feed) {}
EventFlow.postStickyEvent(FlowEvent.FeedUpdatingEvent(false))
} }
/** /**

View File

@ -135,15 +135,9 @@ class Episode : RealmObject {
return field return field
} }
@Ignore
val downloadState = mutableIntStateOf(if (media?.downloaded == true) DownloadStatus.State.COMPLETED.ordinal else DownloadStatus.State.UNKNOWN.ordinal)
@Ignore @Ignore
val isRemote = mutableStateOf(false) val isRemote = mutableStateOf(false)
@Ignore
val stopMonitoring = mutableStateOf(false)
constructor() { constructor() {
this.playState = PlayState.UNPLAYED.code this.playState = PlayState.UNPLAYED.code
} }
@ -162,13 +156,6 @@ class Episode : RealmObject {
this.feed = feed this.feed = feed
} }
fun copyStates(other: Episode) {
// inQueueState.value = other.inQueueState.value
// isPlayingState.value = other.isPlayingState.value
downloadState.value = other.downloadState.value
stopMonitoring.value = other.stopMonitoring.value
}
fun updateFromOther(other: Episode) { fun updateFromOther(other: Episode) {
if (other.imageUrl != null) this.imageUrl = other.imageUrl if (other.imageUrl != null) this.imageUrl = other.imageUrl
if (other.title != null) title = other.title if (other.title != null) title = other.title
@ -296,10 +283,6 @@ class Episode : RealmObject {
if (isFavorite != other.isFavorite) return false if (isFavorite != other.isFavorite) return false
if (isInProgress != other.isInProgress) return false if (isInProgress != other.isInProgress) return false
if (isDownloaded != other.isDownloaded) return false if (isDownloaded != other.isDownloaded) return false
// if (inQueueState != other.inQueueState) return false
// if (isPlayingState != other.isPlayingState) return false
if (downloadState != other.downloadState) return false
if (stopMonitoring != other.stopMonitoring) return false
return true return true
} }
@ -324,24 +307,9 @@ class Episode : RealmObject {
result = 31 * result + isFavorite.hashCode() result = 31 * result + isFavorite.hashCode()
result = 31 * result + isInProgress.hashCode() result = 31 * result + isInProgress.hashCode()
result = 31 * result + isDownloaded.hashCode() result = 31 * result + isDownloaded.hashCode()
// result = 31 * result + inQueueState.hashCode()
// result = 31 * result + isPlayingState.hashCode()
result = 31 * result + downloadState.hashCode()
result = 31 * result + stopMonitoring.hashCode()
return result return result
} }
// override fun equals(other: Any?): Boolean {
// if (this === other) return true
// if (other !is Episode) return false
// return id == other.id
// }
//
// override fun hashCode(): Int {
// val result = (id xor (id ushr 32)).toInt()
// return result
// }
enum class PlayState(val code: Int) { enum class PlayState(val code: Int) {
UNSPECIFIED(-2), UNSPECIFIED(-2),
NEW(-1), NEW(-1),

View File

@ -4,6 +4,9 @@ import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.model.FeedFunding.Companion.extractPaymentLinks import ac.mdiq.podcini.storage.model.FeedFunding.Companion.extractPaymentLinks
import ac.mdiq.podcini.storage.model.EpisodeSortOrder.Companion.fromCode import ac.mdiq.podcini.storage.model.EpisodeSortOrder.Companion.fromCode
import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.realm.kotlin.ext.realmListOf import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.types.RealmList import io.realm.kotlin.types.RealmList
import io.realm.kotlin.types.RealmObject import io.realm.kotlin.types.RealmObject
@ -141,6 +144,9 @@ class Feed : RealmObject {
@Ignore @Ignore
var sortInfo: String = "" var sortInfo: String = ""
@Ignore
var isBuilding by mutableStateOf(false)
/** /**
* This constructor is used for test purposes. * This constructor is used for test purposes.
*/ */

View File

@ -0,0 +1,568 @@
package ac.mdiq.podcini.ui.actions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.download.service.DownloadServiceInterface
import ac.mdiq.podcini.net.utils.NetworkUtils
import ac.mdiq.podcini.playback.PlaybackServiceStarter
import ac.mdiq.podcini.playback.base.InTheatre
import ac.mdiq.podcini.preferences.UserPreferences.isStreamOverDownload
import ac.mdiq.podcini.playback.base.InTheatre.isCurrentlyPlaying
import ac.mdiq.podcini.playback.base.VideoMode
import ac.mdiq.podcini.playback.service.PlaybackService
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.getPlayerActivityIntent
import ac.mdiq.podcini.preferences.UsageStatistics
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode
import ac.mdiq.podcini.receiver.MediaButtonReceiver
import ac.mdiq.podcini.storage.database.Episodes
import ac.mdiq.podcini.storage.database.RealmDB
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.utils.AudioMediaTools
import ac.mdiq.podcini.storage.utils.FilesUtils
import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment
import ac.mdiq.podcini.ui.utils.LocalDeleteModal
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.IntentUtils
import ac.mdiq.podcini.util.Logd
import android.content.Context
import android.content.DialogInterface
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import android.view.KeyEvent
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.annotation.OptIn
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.text.HtmlCompat
import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.dankito.readability4j.Readability4J
import java.io.File
import java.util.*
import kotlin.math.max
import kotlin.math.min
abstract class EpisodeActionButton internal constructor(@JvmField var item: Episode) {
val TAG = this::class.simpleName ?: "ItemActionButton"
open val visibility: Boolean
get() = true
var processing: Float = -1f
val actionState = mutableIntStateOf(0)
abstract fun getLabel(): Int
abstract fun getDrawable(): Int
abstract fun onClick(context: Context)
@Composable
fun AltActionsDialog(context: Context, showDialog: Boolean, onDismiss: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val label = getLabel()
if (label != R.string.play_label && label != R.string.pause_label && label != R.string.download_label) {
IconButton(onClick = {
PlayActionButton(item).onClick(context)
onDismiss()
}) { Image(painter = painterResource(R.drawable.ic_play_24dp), contentDescription = "Play") }
}
if (label != R.string.stream_label && label != R.string.play_label && label != R.string.pause_label && label != R.string.delete_label) {
IconButton(onClick = {
StreamActionButton(item).onClick(context)
onDismiss()
}) { Image(painter = painterResource(R.drawable.ic_stream), contentDescription = "Stream") }
}
if (label != R.string.download_label && label != R.string.play_label && label != R.string.delete_label) {
IconButton(onClick = {
DownloadActionButton(item).onClick(context)
onDismiss()
}) { Image(painter = painterResource(R.drawable.ic_download), contentDescription = "Download") }
}
if (label != R.string.delete_label && label != R.string.download_label && label != R.string.stream_label) {
IconButton(onClick = {
DeleteActionButton(item).onClick(context)
onDismiss()
}) { Image(painter = painterResource(R.drawable.ic_delete), contentDescription = "Delete") }
}
if (label != R.string.visit_website_label) {
IconButton(onClick = {
VisitWebsiteActionButton(item).onClick(context)
onDismiss()
}) { Image(painter = painterResource(R.drawable.ic_web), contentDescription = "Web") }
}
}
}
}
}
}
@UnstableApi
companion object {
fun forItem(episode: Episode): EpisodeActionButton {
val media = episode.media ?: return TTSActionButton(episode)
val isDownloadingMedia = when (media.downloadUrl) {
null -> false
else -> DownloadServiceInterface.get()?.isDownloadingEpisode(media.downloadUrl!!)?:false
}
Logd("ItemActionButton", "forItem: local feed: ${episode.feed?.isLocalFeed} downloaded: ${media.downloaded} playing: ${isCurrentlyPlaying(media)} ${episode.title} ")
return when {
isCurrentlyPlaying(media) -> PauseActionButton(episode)
episode.feed != null && episode.feed!!.isLocalFeed -> PlayLocalActionButton(episode)
media.downloaded -> PlayActionButton(episode)
isDownloadingMedia -> CancelDownloadActionButton(episode)
isStreamOverDownload || episode.feed == null || episode.feedId == null || episode.feed?.type == Feed.FeedType.YOUTUBE.name
|| episode.feed?.preferences?.prefStreamOverDownload == true -> StreamActionButton(episode)
else -> DownloadActionButton(episode)
}
}
fun playVideoIfNeeded(context: Context, media: Playable) {
val item = (media as? EpisodeMedia)?.episode
if (item?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY
&& videoPlayMode != VideoMode.AUDIO_ONLY.code && videoMode != VideoMode.AUDIO_ONLY
&& media.getMediaType() == MediaType.VIDEO)
context.startActivity(getPlayerActivityIntent(context, MediaType.VIDEO))
}
}
}
class VisitWebsiteActionButton(item: Episode) : EpisodeActionButton(item) {
override val visibility: Boolean
get() = if (item.link.isNullOrEmpty()) false else true
override fun getLabel(): Int {
return R.string.visit_website_label
}
override fun getDrawable(): Int {
return R.drawable.ic_web
}
override fun onClick(context: Context) {
if (!item.link.isNullOrEmpty()) IntentUtils.openInBrowser(context, item.link!!)
actionState.value = getLabel()
}
}
class CancelDownloadActionButton(item: Episode) : EpisodeActionButton(item) {
@StringRes
override fun getLabel(): Int {
return R.string.cancel_download_label
}
@DrawableRes
override fun getDrawable(): Int {
return R.drawable.ic_cancel
}
@UnstableApi
override fun onClick(context: Context) {
val media = item.media
if (media != null) DownloadServiceInterface.get()?.cancel(context, media)
if (UserPreferences.isEnableAutodownload) {
val item_ = RealmDB.upsertBlk(item) {
it.disableAutoDownload()
}
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item_))
}
actionState.value = getLabel()
}
}
class PlayActionButton(item: Episode) : EpisodeActionButton(item) {
override fun getLabel(): Int {
return R.string.play_label
}
override fun getDrawable(): Int {
return R.drawable.ic_play_24dp
}
@UnstableApi
override fun onClick(context: Context) {
Logd("PlayActionButton", "onClick called")
val media = item.media
if (media == null) {
Toast.makeText(context, R.string.no_media_label, Toast.LENGTH_LONG).show()
return
}
if (!media.fileExists()) {
Toast.makeText(context, R.string.error_file_not_found, Toast.LENGTH_LONG).show()
notifyMissingEpisodeMediaFile(context, media)
return
}
if (PlaybackService.playbackService?.isServiceReady() == true && InTheatre.isCurMedia(media)) {
PlaybackService.playbackService?.mPlayer?.resume()
PlaybackService.playbackService?.taskManager?.restartSleepTimer()
} else {
PlaybackService.clearCurTempSpeed()
PlaybackServiceStarter(context, media).callEvenIfRunning(true).start()
EventFlow.postEvent(FlowEvent.PlayEvent(item))
}
playVideoIfNeeded(context, media)
actionState.value = getLabel()
}
/**
* Notifies the database about a missing EpisodeMedia file. This method will correct the EpisodeMedia object's
* values in the DB and send a FeedItemEvent.
*/
fun notifyMissingEpisodeMediaFile(context: Context, media: EpisodeMedia) {
Logd(TAG, "notifyMissingEpisodeMediaFile called")
Log.i(TAG, "The feedmanager was notified about a missing episode. It will update its database now.")
val episode = RealmDB.realm.query(Episode::class).query("id == media.id").first().find()
// val episode = media.episodeOrFetch()
if (episode != null) {
val episode_ = RealmDB.upsertBlk(episode) {
// it.media = media
it.media?.downloaded = false
it.media?.fileUrl = null
}
EventFlow.postEvent(FlowEvent.EpisodeMediaEvent.removed(episode_))
}
EventFlow.postEvent(FlowEvent.MessageEvent(context.getString(R.string.error_file_not_found)))
}
}
class DeleteActionButton(item: Episode) : EpisodeActionButton(item) {
override val visibility: Boolean
get() {
if (item.media != null && (item.media!!.downloaded || item.feed?.isLocalFeed == true)) return true
return false
}
override fun getLabel(): Int {
return R.string.delete_label
}
override fun getDrawable(): Int {
return R.drawable.ic_delete
}
@UnstableApi
override fun onClick(context: Context) {
LocalDeleteModal.deleteEpisodesWarnLocal(context, listOf(item))
actionState.value = getLabel()
}
}
class PauseActionButton(item: Episode) : EpisodeActionButton(item) {
override fun getLabel(): Int {
return R.string.pause_label
}
override fun getDrawable(): Int {
return R.drawable.ic_pause
}
@UnstableApi
override fun onClick(context: Context) {
Logd("PauseActionButton", "onClick called")
val media = item.media ?: return
if (isCurrentlyPlaying(media)) context.sendBroadcast(MediaButtonReceiver.createIntent(context,
KeyEvent.KEYCODE_MEDIA_PAUSE))
// EventFlow.postEvent(FlowEvent.PlayEvent(item, Action.END))
actionState.value = getLabel()
}
}
class DownloadActionButton(item: Episode) : EpisodeActionButton(item) {
override val visibility: Boolean
get() = if (item.feed?.isLocalFeed == true) false else true
override fun getLabel(): Int {
return R.string.download_label
}
override fun getDrawable(): Int {
return R.drawable.ic_download
}
override fun onClick(context: Context) {
if (shouldNotDownload(item.media)) return
UsageStatistics.logAction(UsageStatistics.ACTION_DOWNLOAD)
if (NetworkUtils.isEpisodeDownloadAllowed) DownloadServiceInterface.get()?.downloadNow(context, item, false)
else {
val builder = MaterialAlertDialogBuilder(context)
.setTitle(R.string.confirm_mobile_download_dialog_title)
.setPositiveButton(R.string.confirm_mobile_download_dialog_download_later) { _: DialogInterface?, _: Int ->
DownloadServiceInterface.get()?.downloadNow(context, item, false) }
.setNeutralButton(R.string.confirm_mobile_download_dialog_allow_this_time) { _: DialogInterface?, _: Int ->
DownloadServiceInterface.get()?.downloadNow(context, item, true) }
.setNegativeButton(R.string.cancel_label, null)
if (NetworkUtils.isNetworkRestricted && NetworkUtils.isVpnOverWifi) builder.setMessage(R.string.confirm_mobile_download_dialog_message_vpn)
else builder.setMessage(R.string.confirm_mobile_download_dialog_message)
builder.show()
}
actionState.value = getLabel()
}
private fun shouldNotDownload(media: EpisodeMedia?): Boolean {
if (media?.downloadUrl == null) return true
val isDownloading = DownloadServiceInterface.get()?.isDownloadingEpisode(media.downloadUrl!!)?:false
return isDownloading || media.downloaded
}
}
class StreamActionButton(item: Episode) : EpisodeActionButton(item) {
override fun getLabel(): Int {
return R.string.stream_label
}
override fun getDrawable(): Int {
return R.drawable.ic_stream
}
@UnstableApi
override fun onClick(context: Context) {
if (item.media == null) return
// Logd("StreamActionButton", "item.feed: ${item.feedId}")
val media = if (item.feedId != null) item.media!! else RemoteMedia(item)
UsageStatistics.logAction(UsageStatistics.ACTION_STREAM)
if (!NetworkUtils.isStreamingAllowed) {
StreamingConfirmationDialog(context, media).show()
return
}
stream(context, media)
actionState.value = getLabel()
}
class StreamingConfirmationDialog(private val context: Context, private val playable: Playable) {
@UnstableApi
fun show() {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.stream_label)
.setMessage(R.string.confirm_mobile_streaming_notification_message)
.setPositiveButton(R.string.confirm_mobile_streaming_button_once) { _: DialogInterface?, _: Int -> stream(context, playable) }
.setNegativeButton(R.string.confirm_mobile_streaming_button_always) { _: DialogInterface?, _: Int ->
NetworkUtils.isAllowMobileStreaming = true
stream(context, playable)
}
.setNeutralButton(R.string.cancel_label, null)
.show()
}
}
companion object {
fun stream(context: Context, media: Playable) {
if (media !is EpisodeMedia || !InTheatre.isCurMedia(media)) PlaybackService.clearCurTempSpeed()
PlaybackServiceStarter(context, media).shouldStreamThisTime(true).callEvenIfRunning(true).start()
if (media is EpisodeMedia && media.episode != null) EventFlow.postEvent(FlowEvent.PlayEvent(media.episode!!))
playVideoIfNeeded(context, media)
}
}
}
class TTSActionButton(item: Episode) : EpisodeActionButton(item) {
private var readerText: String? = null
override val visibility: Boolean
get() = if (item.link.isNullOrEmpty()) false else true
override fun getLabel(): Int {
return R.string.TTS_label
}
override fun getDrawable(): Int {
return R.drawable.text_to_speech
}
@OptIn(UnstableApi::class) override fun onClick(context: Context) {
Logd("TTSActionButton", "onClick called")
if (item.link.isNullOrEmpty()) {
Toast.makeText(context, R.string.episode_has_no_content, Toast.LENGTH_LONG).show()
return
}
processing = 0.01f
item.setBuilding()
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
RealmDB.runOnIOScope {
if (item.transcript == null) {
val url = item.link!!
val htmlSource = NetworkUtils.fetchHtmlSource(url)
val article = Readability4J(item.link!!, htmlSource).parse()
readerText = article.textContent
item = RealmDB.upsertBlk(item) {
it.setTranscriptIfLonger(article.contentWithDocumentsCharsetOrUtf8)
}
// persistEpisode(item)
Logd(TAG,
"readability4J: ${readerText?.substring(max(0, readerText!!.length - 100), readerText!!.length)}")
} else readerText = HtmlCompat.fromHtml(item.transcript!!, HtmlCompat.FROM_HTML_MODE_COMPACT).toString()
processing = 0.1f
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
if (!readerText.isNullOrEmpty()) {
while (!FeedEpisodesFragment.ttsReady) runBlocking { delay(100) }
processing = 0.15f
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
while (FeedEpisodesFragment.ttsWorking) runBlocking { delay(100) }
FeedEpisodesFragment.ttsWorking = true
if (item.feed?.language != null) {
val result = FeedEpisodesFragment.tts?.setLanguage(Locale(item.feed!!.language!!))
if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) {
Log.w(TAG, "TTS language not supported ${item.feed!!.language} $result")
withContext(Dispatchers.Main) {
Toast.makeText(context,
context.getString(R.string.language_not_supported_by_tts) + " ${item.feed!!.language} $result",
Toast.LENGTH_LONG).show()
}
}
}
var j = 0
val mediaFile = File(FilesUtils.getMediafilePath(item), FilesUtils.getMediafilename(item))
FeedEpisodesFragment.tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
j++
Logd(TAG, "onDone ${mediaFile.length()} $utteranceId")
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String) {
Log.e(TAG, "onError utterance error: $utteranceId")
Log.e(TAG, "onError $readerText")
}
override fun onError(utteranceId: String, errorCode: Int) {
Log.e(TAG, "onError1 utterance error: $utteranceId $errorCode")
Log.e(TAG, "onError1 $readerText")
}
})
Logd(TAG, "readerText: ${readerText?.length}")
var startIndex = 0
var i = 0
val parts = mutableListOf<String>()
val chunkLength = TextToSpeech.getMaxSpeechInputLength()
var status = TextToSpeech.ERROR
while (startIndex < readerText!!.length) {
Logd(TAG, "working on chunk $i $startIndex")
val endIndex = minOf(startIndex + chunkLength, readerText!!.length)
val chunk = readerText!!.substring(startIndex, endIndex)
val tempFile = File.createTempFile("tts_temp_${i}_", ".wav")
parts.add(tempFile.absolutePath)
status =
FeedEpisodesFragment.tts?.synthesizeToFile(chunk, null, tempFile, tempFile.absolutePath) ?: 0
Logd(TAG, "status: $status chunk: ${chunk.substring(0, min(80, chunk.length))}")
if (status == TextToSpeech.ERROR) {
withContext(Dispatchers.Main) {
Toast.makeText(context,
"Error generating audio file $tempFile.absolutePath",
Toast.LENGTH_LONG).show()
}
break
}
startIndex += chunkLength
i++
while (i - j > 0) runBlocking { delay(100) }
processing = 0.15f + 0.7f * startIndex / readerText!!.length
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
}
processing = 0.85f
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
if (status == TextToSpeech.SUCCESS) {
AudioMediaTools.mergeAudios(parts.toTypedArray(), mediaFile.absolutePath, null)
val mFilename = mediaFile.absolutePath
Logd(TAG, "saving TTS to file $mFilename")
val media = EpisodeMedia(item, null, 0, "audio/*")
media.fileUrl = mFilename
// media.downloaded = true
media.setIsDownloaded()
item = RealmDB.upsertBlk(item) {
it.media = media
it.setTranscriptIfLonger(readerText)
}
// persistEpisode(item)
}
for (p in parts) {
val f = File(p)
f.delete()
}
FeedEpisodesFragment.ttsWorking = false
} else withContext(Dispatchers.Main) {
Toast.makeText(context,
R.string.episode_has_no_content,
Toast.LENGTH_LONG).show()
}
item.setPlayed(false)
processing = 1f
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
actionState.value = getLabel()
}
}
}
class PlayLocalActionButton(item: Episode) : EpisodeActionButton(item) {
override fun getLabel(): Int {
return R.string.play_label
}
override fun getDrawable(): Int {
return R.drawable.ic_play_24dp
}
@UnstableApi
override fun onClick(context: Context) {
Logd("PlayLocalActionButton", "onClick called")
val media = item.media
if (media == null) {
Toast.makeText(context, R.string.no_media_label, Toast.LENGTH_LONG).show()
return
}
if (PlaybackService.playbackService?.isServiceReady() == true && InTheatre.isCurMedia(media)) {
PlaybackService.playbackService?.mPlayer?.resume()
PlaybackService.playbackService?.taskManager?.restartSleepTimer()
} else {
PlaybackService.clearCurTempSpeed()
PlaybackServiceStarter(context, media).callEvenIfRunning(true).start()
EventFlow.postEvent(FlowEvent.PlayEvent(item))
}
if (media.getMediaType() == MediaType.VIDEO) context.startActivity(getPlayerActivityIntent(context,
MediaType.VIDEO))
actionState.value = getLabel()
}
}
class MarkAsPlayedActionButton(item: Episode) : EpisodeActionButton(item) {
override val visibility: Boolean
get() = if (item.isPlayed()) false else true
override fun getLabel(): Int {
return (if (item.media != null) R.string.mark_read_label else R.string.mark_read_no_media_label)
}
override fun getDrawable(): Int {
return R.drawable.ic_check
}
@UnstableApi
override fun onClick(context: Context) {
if (!item.isPlayed()) Episodes.setPlayState(Episode.PlayState.PLAYED.code, true, item)
actionState.value = getLabel()
}
}

View File

@ -1,4 +1,4 @@
package ac.mdiq.podcini.ui.actions.handler package ac.mdiq.podcini.ui.actions
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.sync.SynchronizationSettings.isProviderConnected import ac.mdiq.podcini.net.sync.SynchronizationSettings.isProviderConnected

View File

@ -1,4 +1,4 @@
package ac.mdiq.podcini.ui.actions.handler package ac.mdiq.podcini.ui.actions
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.SelectQueueDialogBinding import ac.mdiq.podcini.databinding.SelectQueueDialogBinding

View File

@ -1,4 +1,4 @@
package ac.mdiq.podcini.ui.actions.handler package ac.mdiq.podcini.ui.actions
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem

View File

@ -1,4 +1,4 @@
package ac.mdiq.podcini.ui.actions.swipeactions package ac.mdiq.podcini.ui.actions
import android.content.Context import android.content.Context
import androidx.annotation.AttrRes import androidx.annotation.AttrRes

View File

@ -1,4 +1,4 @@
package ac.mdiq.podcini.ui.actions.swipeactions package ac.mdiq.podcini.ui.actions
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.playback.base.InTheatre.curQueue
@ -18,8 +18,7 @@ import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.actionbutton.DownloadActionButton import ac.mdiq.podcini.ui.actions.SwipeAction.Companion.NO_ACTION
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction.Companion.NO_ACTION
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.dialog.SwipeActionsDialog import ac.mdiq.podcini.ui.dialog.SwipeActionsDialog
import ac.mdiq.podcini.ui.fragment.AllEpisodesFragment import ac.mdiq.podcini.ui.fragment.AllEpisodesFragment

View File

@ -1,37 +0,0 @@
package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.download.service.DownloadServiceInterface
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.media3.common.util.UnstableApi
class CancelDownloadActionButton(item: Episode) : EpisodeActionButton(item) {
@StringRes
override fun getLabel(): Int {
return R.string.cancel_download_label
}
@DrawableRes
override fun getDrawable(): Int {
return R.drawable.ic_cancel
}
@UnstableApi override fun onClick(context: Context) {
val media = item.media
if (media != null) DownloadServiceInterface.get()?.cancel(context, media)
if (isEnableAutodownload) {
val item_ = upsertBlk(item) {
it.disableAutoDownload()
}
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item_))
}
actionState.value = getLabel()
}
}

View File

@ -1,27 +0,0 @@
package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.ui.utils.LocalDeleteModal.deleteEpisodesWarnLocal
import android.content.Context
import android.view.View
import androidx.media3.common.util.UnstableApi
class DeleteActionButton(item: Episode) : EpisodeActionButton(item) {
override val visibility: Int
get() {
if (item.media != null && (item.media!!.downloaded || item.feed?.isLocalFeed == true)) return View.VISIBLE
return View.INVISIBLE
}
override fun getLabel(): Int {
return R.string.delete_label
}
override fun getDrawable(): Int {
return R.drawable.ic_delete
}
@UnstableApi override fun onClick(context: Context) {
deleteEpisodesWarnLocal(context, listOf(item))
actionState.value = getLabel()
}
}

View File

@ -1,54 +0,0 @@
package ac.mdiq.podcini.ui.actions.actionbutton
import android.content.Context
import android.content.DialogInterface
import android.view.View
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.preferences.UsageStatistics
import ac.mdiq.podcini.preferences.UsageStatistics.logAction
import ac.mdiq.podcini.net.utils.NetworkUtils.isEpisodeDownloadAllowed
import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
import ac.mdiq.podcini.net.utils.NetworkUtils.isVpnOverWifi
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.net.download.service.DownloadServiceInterface
class DownloadActionButton(item: Episode) : EpisodeActionButton(item) {
override val visibility: Int
get() = if (item.feed?.isLocalFeed == true) View.INVISIBLE else View.VISIBLE
override fun getLabel(): Int {
return R.string.download_label
}
override fun getDrawable(): Int {
return R.drawable.ic_download
}
override fun onClick(context: Context) {
if (shouldNotDownload(item.media)) return
logAction(UsageStatistics.ACTION_DOWNLOAD)
if (isEpisodeDownloadAllowed) DownloadServiceInterface.get()?.downloadNow(context, item, false)
else {
val builder = MaterialAlertDialogBuilder(context)
.setTitle(R.string.confirm_mobile_download_dialog_title)
.setPositiveButton(R.string.confirm_mobile_download_dialog_download_later) { _: DialogInterface?, _: Int ->
DownloadServiceInterface.get()?.downloadNow(context, item, false) }
.setNeutralButton(R.string.confirm_mobile_download_dialog_allow_this_time) { _: DialogInterface?, _: Int ->
DownloadServiceInterface.get()?.downloadNow(context, item, true) }
.setNegativeButton(R.string.cancel_label, null)
if (isNetworkRestricted && isVpnOverWifi) builder.setMessage(R.string.confirm_mobile_download_dialog_message_vpn)
else builder.setMessage(R.string.confirm_mobile_download_dialog_message)
builder.show()
}
actionState.value = getLabel()
}
private fun shouldNotDownload(media: EpisodeMedia?): Boolean {
if (media?.downloadUrl == null) return true
val isDownloading = DownloadServiceInterface.get()?.isDownloadingEpisode(media.downloadUrl!!)?:false
return isDownloading || media.downloaded
}
}

View File

@ -1,135 +0,0 @@
package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.download.service.DownloadServiceInterface
import ac.mdiq.podcini.preferences.UserPreferences.isStreamOverDownload
import ac.mdiq.podcini.playback.base.InTheatre.isCurrentlyPlaying
import ac.mdiq.podcini.playback.base.VideoMode
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.getPlayerActivityIntent
import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.util.Logd
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
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.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.media3.common.util.UnstableApi
abstract class EpisodeActionButton internal constructor(@JvmField var item: Episode) {
val TAG = this::class.simpleName ?: "ItemActionButton"
open val visibility: Int
get() = View.VISIBLE
var processing: Float = -1f
val actionState = mutableIntStateOf(0)
abstract fun getLabel(): Int
abstract fun getDrawable(): Int
abstract fun onClick(context: Context)
// fun configure(button: View, icon: ImageView, context: Context) {
// button.visibility = visibility
// button.contentDescription = context.getString(getLabel())
// button.setOnClickListener { onClick(context) }
// button.setOnLongClickListener {
// val composeView = ComposeView(context).apply {
// setContent {
// val showDialog = remember { mutableStateOf(true) }
// CustomTheme(context) { AltActionsDialog(context, showDialog.value, onDismiss = { showDialog.value = false }) }
// }
// }
// (button as? ViewGroup)?.addView(composeView)
// true
// }
// icon.setImageResource(getDrawable())
// }
@Composable
fun AltActionsDialog(context: Context, showDialog: Boolean, onDismiss: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val label = getLabel()
if (label != R.string.play_label && label != R.string.pause_label && label != R.string.download_label) {
IconButton(onClick = {
PlayActionButton(item).onClick(context)
onDismiss()
}) { Image(painter = painterResource(R.drawable.ic_play_24dp), contentDescription = "Play") }
}
if (label != R.string.stream_label && label != R.string.play_label && label != R.string.pause_label && label != R.string.delete_label) {
IconButton(onClick = {
StreamActionButton(item).onClick(context)
onDismiss()
}) { Image(painter = painterResource(R.drawable.ic_stream), contentDescription = "Stream") }
}
if (label != R.string.download_label && label != R.string.play_label && label != R.string.delete_label) {
IconButton(onClick = {
DownloadActionButton(item).onClick(context)
onDismiss()
}) { Image(painter = painterResource(R.drawable.ic_download), contentDescription = "Download") }
}
if (label != R.string.delete_label && label != R.string.download_label && label != R.string.stream_label) {
IconButton(onClick = {
DeleteActionButton(item).onClick(context)
onDismiss()
}) { Image(painter = painterResource(R.drawable.ic_delete), contentDescription = "Delete") }
}
if (label != R.string.visit_website_label) {
IconButton(onClick = {
VisitWebsiteActionButton(item).onClick(context)
onDismiss()
}) { Image(painter = painterResource(R.drawable.ic_web), contentDescription = "Web") }
}
}
}
}
}
}
@UnstableApi
companion object {
fun forItem(episode: Episode): EpisodeActionButton {
val media = episode.media ?: return TTSActionButton(episode)
val isDownloadingMedia = when (media.downloadUrl) {
null -> false
else -> DownloadServiceInterface.get()?.isDownloadingEpisode(media.downloadUrl!!)?:false
}
Logd("ItemActionButton", "forItem: ${episode.feedId} ${episode.feed?.isLocalFeed} ${media.downloaded} ${isCurrentlyPlaying(media)} ${episode.title} ")
return when {
isCurrentlyPlaying(media) -> PauseActionButton(episode)
episode.feed != null && episode.feed!!.isLocalFeed -> PlayLocalActionButton(episode)
media.downloaded -> PlayActionButton(episode)
isDownloadingMedia -> CancelDownloadActionButton(episode)
isStreamOverDownload || episode.feed == null || episode.feedId == null || episode.feed?.type == Feed.FeedType.YOUTUBE.name
|| episode.feed?.preferences?.prefStreamOverDownload == true -> StreamActionButton(episode)
else -> DownloadActionButton(episode)
}
}
fun playVideoIfNeeded(context: Context, media: Playable) {
val item = (media as? EpisodeMedia)?.episode
if (item?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY
&& videoPlayMode != VideoMode.AUDIO_ONLY.code && videoMode != VideoMode.AUDIO_ONLY
&& media.getMediaType() == MediaType.VIDEO)
context.startActivity(getPlayerActivityIntent(context, MediaType.VIDEO))
}
}
}

View File

@ -1,27 +0,0 @@
package ac.mdiq.podcini.ui.actions.actionbutton
import android.content.Context
import android.view.View
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.model.Episode
import androidx.media3.common.util.UnstableApi
class MarkAsPlayedActionButton(item: Episode) : EpisodeActionButton(item) {
override val visibility: Int
get() = if (item.isPlayed()) View.INVISIBLE else View.VISIBLE
override fun getLabel(): Int {
return (if (item.media != null) R.string.mark_read_label else R.string.mark_read_no_media_label)
}
override fun getDrawable(): Int {
return R.drawable.ic_check
}
@UnstableApi override fun onClick(context: Context) {
if (!item.isPlayed()) setPlayState(Episode.PlayState.PLAYED.code, true, item)
actionState.value = getLabel()
}
}

View File

@ -1,27 +0,0 @@
package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R
import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createIntent
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.playback.base.InTheatre.isCurrentlyPlaying
import android.content.Context
import android.view.KeyEvent
import androidx.media3.common.util.UnstableApi
class PauseActionButton(item: Episode) : EpisodeActionButton(item) {
override fun getLabel(): Int {
return R.string.pause_label
}
override fun getDrawable(): Int {
return R.drawable.ic_pause
}
@UnstableApi override fun onClick(context: Context) {
Logd("PauseActionButton", "onClick called")
val media = item.media ?: return
if (isCurrentlyPlaying(media)) context.sendBroadcast(createIntent(context, KeyEvent.KEYCODE_MEDIA_PAUSE))
// EventFlow.postEvent(FlowEvent.PlayEvent(item, Action.END))
actionState.value = getLabel()
}
}

View File

@ -1,72 +0,0 @@
package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R
import ac.mdiq.podcini.playback.PlaybackServiceStarter
import ac.mdiq.podcini.playback.base.InTheatre
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.clearCurTempSpeed
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.media3.common.util.UnstableApi
class PlayActionButton(item: Episode) : EpisodeActionButton(item) {
override fun getLabel(): Int {
return R.string.play_label
}
override fun getDrawable(): Int {
return R.drawable.ic_play_24dp
}
@UnstableApi override fun onClick(context: Context) {
Logd("PlayActionButton", "onClick called")
val media = item.media
if (media == null) {
Toast.makeText(context, R.string.no_media_label, Toast.LENGTH_LONG).show()
return
}
if (!media.fileExists()) {
Toast.makeText(context, R.string.error_file_not_found, Toast.LENGTH_LONG).show()
notifyMissingEpisodeMediaFile(context, media)
return
}
if (playbackService?.isServiceReady() == true && InTheatre.isCurMedia(media)) {
playbackService?.mPlayer?.resume()
playbackService?.taskManager?.restartSleepTimer()
} else {
clearCurTempSpeed()
PlaybackServiceStarter(context, media).callEvenIfRunning(true).start()
EventFlow.postEvent(FlowEvent.PlayEvent(item))
}
playVideoIfNeeded(context, media)
actionState.value = getLabel()
}
/**
* Notifies the database about a missing EpisodeMedia file. This method will correct the EpisodeMedia object's
* values in the DB and send a FeedItemEvent.
*/
fun notifyMissingEpisodeMediaFile(context: Context, media: EpisodeMedia) {
Logd(TAG, "notifyMissingEpisodeMediaFile called")
Log.i(TAG, "The feedmanager was notified about a missing episode. It will update its database now.")
val episode = realm.query(Episode::class).query("id == media.id").first().find()
// val episode = media.episodeOrFetch()
if (episode != null) {
val episode_ = upsertBlk(episode) {
// it.media = media
it.media?.downloaded = false
it.media?.fileUrl = null
}
EventFlow.postEvent(FlowEvent.EpisodeMediaEvent.removed(episode_))
}
EventFlow.postEvent(FlowEvent.MessageEvent(context.getString(R.string.error_file_not_found)))
}
}

View File

@ -1,43 +0,0 @@
package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R
import ac.mdiq.podcini.playback.PlaybackServiceStarter
import ac.mdiq.podcini.playback.base.InTheatre
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.clearCurTempSpeed
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.getPlayerActivityIntent
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.content.Context
import android.widget.Toast
import androidx.media3.common.util.UnstableApi
class PlayLocalActionButton(item: Episode) : EpisodeActionButton(item) {
override fun getLabel(): Int {
return R.string.play_label
}
override fun getDrawable(): Int {
return R.drawable.ic_play_24dp
}
@UnstableApi override fun onClick(context: Context) {
Logd("PlayLocalActionButton", "onClick called")
val media = item.media
if (media == null) {
Toast.makeText(context, R.string.no_media_label, Toast.LENGTH_LONG).show()
return
}
if (playbackService?.isServiceReady() == true && InTheatre.isCurMedia(media)) {
playbackService?.mPlayer?.resume()
playbackService?.taskManager?.restartSleepTimer()
} else {
clearCurTempSpeed()
PlaybackServiceStarter(context, media).callEvenIfRunning(true).start()
EventFlow.postEvent(FlowEvent.PlayEvent(item))
}
if (media.getMediaType() == MediaType.VIDEO) context.startActivity(getPlayerActivityIntent(context, MediaType.VIDEO))
actionState.value = getLabel()
}
}

View File

@ -1,69 +0,0 @@
package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.utils.NetworkUtils.isAllowMobileStreaming
import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed
import ac.mdiq.podcini.playback.PlaybackServiceStarter
import ac.mdiq.podcini.playback.base.InTheatre
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.clearCurTempSpeed
import ac.mdiq.podcini.preferences.UsageStatistics
import ac.mdiq.podcini.preferences.UsageStatistics.logAction
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.model.RemoteMedia
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.content.Context
import android.content.DialogInterface
import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder
class StreamActionButton(item: Episode) : EpisodeActionButton(item) {
override fun getLabel(): Int {
return R.string.stream_label
}
override fun getDrawable(): Int {
return R.drawable.ic_stream
}
@UnstableApi override fun onClick(context: Context) {
if (item.media == null) return
// Logd("StreamActionButton", "item.feed: ${item.feedId}")
val media = if (item.feedId != null) item.media!! else RemoteMedia(item)
logAction(UsageStatistics.ACTION_STREAM)
if (!isStreamingAllowed) {
StreamingConfirmationDialog(context, media).show()
return
}
stream(context, media)
actionState.value = getLabel()
}
class StreamingConfirmationDialog(private val context: Context, private val playable: Playable) {
@UnstableApi
fun show() {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.stream_label)
.setMessage(R.string.confirm_mobile_streaming_notification_message)
.setPositiveButton(R.string.confirm_mobile_streaming_button_once) { _: DialogInterface?, _: Int -> stream(context, playable) }
.setNegativeButton(R.string.confirm_mobile_streaming_button_always) { _: DialogInterface?, _: Int ->
isAllowMobileStreaming = true
stream(context, playable)
}
.setNeutralButton(R.string.cancel_label, null)
.show()
}
}
companion object {
fun stream(context: Context, media: Playable) {
if (media !is EpisodeMedia || !InTheatre.isCurMedia(media)) clearCurTempSpeed()
PlaybackServiceStarter(context, media).shouldStreamThisTime(true).callEvenIfRunning(true).start()
if (media is EpisodeMedia && media.episode != null) EventFlow.postEvent(FlowEvent.PlayEvent(media.episode!!))
playVideoIfNeeded(context, media)
}
}
}

View File

@ -1,163 +0,0 @@
package ac.mdiq.podcini.ui.actions.actionbutton
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.utils.AudioMediaTools.mergeAudios
import ac.mdiq.podcini.storage.utils.FilesUtils.getMediafilePath
import ac.mdiq.podcini.storage.utils.FilesUtils.getMediafilename
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment.Companion.tts
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment.Companion.ttsReady
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment.Companion.ttsWorking
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.content.Context
import android.speech.tts.TextToSpeech
import android.speech.tts.TextToSpeech.getMaxSpeechInputLength
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.core.text.HtmlCompat
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.dankito.readability4j.Readability4J
import java.io.File
import java.util.*
import kotlin.math.max
import kotlin.math.min
class TTSActionButton(item: Episode) : EpisodeActionButton(item) {
private var readerText: String? = null
override val visibility: Int
get() = if (item.link.isNullOrEmpty()) View.INVISIBLE else View.VISIBLE
override fun getLabel(): Int {
return R.string.TTS_label
}
override fun getDrawable(): Int {
return R.drawable.text_to_speech
}
@OptIn(UnstableApi::class) override fun onClick(context: Context) {
Logd("TTSActionButton", "onClick called")
if (item.link.isNullOrEmpty()) {
Toast.makeText(context, R.string.episode_has_no_content, Toast.LENGTH_LONG).show()
return
}
processing = 0.01f
item.setBuilding()
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
runOnIOScope {
if (item.transcript == null) {
val url = item.link!!
val htmlSource = fetchHtmlSource(url)
val article = Readability4J(item.link!!, htmlSource).parse()
readerText = article.textContent
item = upsertBlk(item) {
it.setTranscriptIfLonger(article.contentWithDocumentsCharsetOrUtf8)
}
// persistEpisode(item)
Logd(TAG, "readability4J: ${readerText?.substring(max(0, readerText!!.length-100), readerText!!.length)}")
} else readerText = HtmlCompat.fromHtml(item.transcript!!, HtmlCompat.FROM_HTML_MODE_COMPACT).toString()
processing = 0.1f
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
if (!readerText.isNullOrEmpty()) {
while (!ttsReady) runBlocking { delay(100) }
processing = 0.15f
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
while (ttsWorking) runBlocking { delay(100) }
ttsWorking = true
if (item.feed?.language != null) {
val result = tts?.setLanguage(Locale(item.feed!!.language!!))
if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) {
Log.w(TAG, "TTS language not supported ${item.feed!!.language} $result")
withContext(Dispatchers.Main) { Toast.makeText(context, context.getString(R.string.language_not_supported_by_tts) + " ${item.feed!!.language} $result", Toast.LENGTH_LONG).show() }
}
}
var j = 0
val mediaFile = File(getMediafilePath(item), getMediafilename(item))
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
j++
Logd(TAG, "onDone ${mediaFile.length()} $utteranceId")
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String) {
Log.e(TAG, "onError utterance error: $utteranceId")
Log.e(TAG, "onError $readerText")
}
override fun onError(utteranceId: String, errorCode: Int) {
Log.e(TAG, "onError1 utterance error: $utteranceId $errorCode")
Log.e(TAG, "onError1 $readerText")
}
})
Logd(TAG, "readerText: ${readerText?.length}")
var startIndex = 0
var i = 0
val parts = mutableListOf<String>()
val chunkLength = getMaxSpeechInputLength()
var status = TextToSpeech.ERROR
while (startIndex < readerText!!.length) {
Logd(TAG, "working on chunk $i $startIndex")
val endIndex = minOf(startIndex + chunkLength, readerText!!.length)
val chunk = readerText!!.substring(startIndex, endIndex)
val tempFile = File.createTempFile("tts_temp_${i}_", ".wav")
parts.add(tempFile.absolutePath)
status = tts?.synthesizeToFile(chunk, null, tempFile, tempFile.absolutePath) ?: 0
Logd(TAG, "status: $status chunk: ${chunk.substring(0, min(80, chunk.length))}")
if (status == TextToSpeech.ERROR) {
withContext(Dispatchers.Main) { Toast.makeText(context, "Error generating audio file $tempFile.absolutePath", Toast.LENGTH_LONG).show() }
break
}
startIndex += chunkLength
i++
while (i-j > 0) runBlocking { delay(100) }
processing = 0.15f + 0.7f * startIndex / readerText!!.length
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
}
processing = 0.85f
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
if (status == TextToSpeech.SUCCESS) {
mergeAudios(parts.toTypedArray(), mediaFile.absolutePath, null)
val mFilename = mediaFile.absolutePath
Logd(TAG, "saving TTS to file $mFilename")
val media = EpisodeMedia(item, null, 0, "audio/*")
media.fileUrl = mFilename
// media.downloaded = true
media.setIsDownloaded()
item = upsertBlk(item) {
it.media = media
it.setTranscriptIfLonger(readerText)
}
// persistEpisode(item)
}
for (p in parts) {
val f = File(p)
f.delete()
}
ttsWorking = false
} else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.episode_has_no_content, Toast.LENGTH_LONG).show() }
item.setPlayed(false)
processing = 1f
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
actionState.value = getLabel()
}
}
}

View File

@ -1,25 +0,0 @@
package ac.mdiq.podcini.ui.actions.actionbutton
import android.content.Context
import android.view.View
import ac.mdiq.podcini.R
import ac.mdiq.podcini.util.IntentUtils.openInBrowser
import ac.mdiq.podcini.storage.model.Episode
class VisitWebsiteActionButton(item: Episode) : EpisodeActionButton(item) {
override val visibility: Int
get() = if (item.link.isNullOrEmpty()) View.INVISIBLE else View.VISIBLE
override fun getLabel(): Int {
return R.string.visit_website_label
}
override fun getDrawable(): Int {
return R.drawable.ic_web
}
override fun onClick(context: Context) {
if (!item.link.isNullOrEmpty()) openInBrowser(context, item.link!!)
actionState.value = getLabel()
}
}

View File

@ -23,7 +23,7 @@ import ac.mdiq.podcini.receiver.PlayerWidget
import ac.mdiq.podcini.storage.database.Feeds.buildTags import ac.mdiq.podcini.storage.database.Feeds.buildTags
import ac.mdiq.podcini.storage.database.Feeds.monitorFeeds import ac.mdiq.podcini.storage.database.Feeds.monitorFeeds
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
import ac.mdiq.podcini.ui.dialog.RatingDialog import ac.mdiq.podcini.ui.dialog.RatingDialog
import ac.mdiq.podcini.ui.fragment.* import ac.mdiq.podcini.ui.fragment.*
@ -65,7 +65,6 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController import androidx.media3.session.MediaController

View File

@ -1,13 +1,11 @@
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.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.confirmAddYoutubeEpisode
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.vista.extractor.Vista import ac.mdiq.vista.extractor.services.youtube.YoutubeParsingHelper.isYoutubeServiceURL
import ac.mdiq.vista.extractor.playlist.PlaylistInfo import ac.mdiq.vista.extractor.services.youtube.YoutubeParsingHelper.isYoutubeURL
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
@ -16,20 +14,11 @@ import android.util.Log
import androidx.activity.compose.setContent 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.runtime.mutableStateOf
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.remember
import androidx.compose.material3.*
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 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 java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.net.URLDecoder import java.net.URLDecoder
class ShareReceiverActivity : AppCompatActivity() { class ShareReceiverActivity : AppCompatActivity() {
@ -54,6 +43,7 @@ class ShareReceiverActivity : AppCompatActivity() {
if (urlString != null) sharedUrl = URLDecoder.decode(urlString, "UTF-8") if (urlString != null) sharedUrl = URLDecoder.decode(urlString, "UTF-8")
} }
Logd(TAG, "feedUrl: $sharedUrl") Logd(TAG, "feedUrl: $sharedUrl")
val url = URL(sharedUrl)
when { when {
// plain text // plain text
sharedUrl!!.matches(Regex("^[^\\s<>/]+\$")) -> { sharedUrl!!.matches(Regex("^[^\\s<>/]+\$")) -> {
@ -62,12 +52,13 @@ class ShareReceiverActivity : AppCompatActivity() {
finish() finish()
} }
// Youtube media // Youtube media
sharedUrl!!.startsWith("https://youtube.com/watch?") || sharedUrl!!.startsWith("https://music.youtube.com/watch?") -> { // sharedUrl!!.startsWith("https://youtube.com/watch?") || sharedUrl!!.startsWith("https://www.youtube.com/watch?") || sharedUrl!!.startsWith("https://music.youtube.com/watch?") -> {
(isYoutubeURL(url) && url.path.startsWith("/watch")) || isYoutubeServiceURL(url) -> {
Logd(TAG, "got youtube media") Logd(TAG, "got youtube media")
setContent { setContent {
val showDialog = remember { mutableStateOf(true) } val showDialog = remember { mutableStateOf(true) }
CustomTheme(this@ShareReceiverActivity) { CustomTheme(this@ShareReceiverActivity) {
confirmAddEpisode(showDialog.value, onDismissRequest = { confirmAddYoutubeEpisode(listOf(sharedUrl!!), showDialog.value, onDismissRequest = {
showDialog.value = false showDialog.value = false
finish() finish()
}) })
@ -85,49 +76,44 @@ class ShareReceiverActivity : AppCompatActivity() {
} }
} }
@Composable // @Composable
fun confirmAddEpisode(showDialog: Boolean, onDismissRequest: () -> Unit) { // fun confirmAddEpisode(sharedUrl: String, showDialog: Boolean, onDismissRequest: () -> Unit) {
if (showDialog) { // var showToast by remember { mutableStateOf(false) }
Dialog(onDismissRequest = { onDismissRequest() }) { // var toastMassege by remember { mutableStateOf("")}
Card( // if (showToast) CustomToast(message = toastMassege, onDismiss = { showToast = false })
modifier = Modifier //
.wrapContentSize(align = Alignment.Center) // if (showDialog) {
.padding(16.dp), // Dialog(onDismissRequest = { onDismissRequest() }) {
shape = RoundedCornerShape(16.dp), // Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
) { // Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) {
Column( // var audioOnly by remember { mutableStateOf(false) }
modifier = Modifier.padding(16.dp), // Row(Modifier.fillMaxWidth()) {
verticalArrangement = Arrangement.Center // Checkbox(checked = audioOnly, onCheckedChange = { audioOnly = it })
) { // Text(text = stringResource(R.string.pref_video_mode_audio_only), style = MaterialTheme.typography.bodyLarge.merge())
var audioOnly by remember { mutableStateOf(false) } // }
Row(Modifier.fillMaxWidth()) { // Button(onClick = {
Checkbox(checked = audioOnly, // CoroutineScope(Dispatchers.IO).launch {
onCheckedChange = { // try {
audioOnly = it // val info = StreamInfo.getInfo(Vista.getService(0), sharedUrl)
} // Logd(TAG, "info: $info")
) // val episode = episodeFromStreamInfo(info)
Text( // Logd(TAG, "episode: $episode")
text = stringResource(R.string.pref_video_mode_audio_only), // addToYoutubeSyndicate(episode, !audioOnly)
style = MaterialTheme.typography.bodyLarge.merge(), // } catch (e: Throwable) {
) // toastMassege = "Receive share error: ${e.message}"
} // Log.e(TAG, toastMassege)
Button(onClick = { // showToast = true
CoroutineScope(Dispatchers.IO).launch { // }
val info = StreamInfo.getInfo(Vista.getService(0), sharedUrl!!) // }
Logd(TAG, "info: $info") // onDismissRequest()
val episode = episodeFromStreamInfo(info) // }) {
Logd(TAG, "episode: $episode") // Text("Confirm")
addToYoutubeSyndicate(episode, !audioOnly) // }
} // }
onDismissRequest() // }
}) { // }
Text("Confirm") // }
} // }
}
}
}
}
}
private fun showNoPodcastFoundError() { private fun showNoPodcastFoundError() {
runOnUiThread { runOnUiThread {

View File

@ -1,8 +1,18 @@
package ac.mdiq.podcini.ui.compose package ac.mdiq.podcini.ui.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -43,3 +53,19 @@ fun Spinner(
} }
} }
} }
@Composable
fun CustomToast(message: String, durationMillis: Long = 2000L, onDismiss: () -> Unit) {
// Launch a coroutine to auto-dismiss the toast after a certain time
LaunchedEffect(message) {
delay(durationMillis)
onDismiss()
}
// Box to display the toast message at the bottom of the screen
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.BottomCenter) {
Box(modifier = Modifier.background(Color.Black, RoundedCornerShape(8.dp)).padding(8.dp)) {
Text(text = message, color = Color.White, style = MaterialTheme.typography.bodyMedium)
}
}
}

View File

@ -7,8 +7,10 @@ import ac.mdiq.podcini.playback.base.InTheatre
import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.status import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.status
import ac.mdiq.podcini.storage.database.Episodes import ac.mdiq.podcini.storage.database.Episodes
import ac.mdiq.podcini.storage.database.Episodes.episodeFromStreamInfo
import ac.mdiq.podcini.storage.database.Episodes.setPlayState import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Feeds.addToMiscSyndicate import ac.mdiq.podcini.storage.database.Feeds.addToMiscSyndicate
import ac.mdiq.podcini.storage.database.Feeds.addToYoutubeSyndicate
import ac.mdiq.podcini.storage.database.Queues import ac.mdiq.podcini.storage.database.Queues
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.realm
@ -16,16 +18,21 @@ import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.MediaType import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.ui.actions.actionbutton.EpisodeActionButton import ac.mdiq.podcini.ui.actions.EpisodeActionButton
import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler.PutToQueueDialog import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler.PutToQueueDialog
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment
import ac.mdiq.podcini.ui.fragment.FeedInfoFragment import ac.mdiq.podcini.ui.fragment.FeedInfoFragment
import ac.mdiq.podcini.ui.utils.LocalDeleteModal import ac.mdiq.podcini.ui.utils.LocalDeleteModal
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev
import ac.mdiq.vista.extractor.Vista
import ac.mdiq.vista.extractor.services.youtube.YoutubeParsingHelper.isYoutubeServiceURL
import ac.mdiq.vista.extractor.services.youtube.YoutubeParsingHelper.isYoutubeURL
import ac.mdiq.vista.extractor.stream.StreamInfo
import android.text.format.Formatter import android.text.format.Formatter
import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -36,6 +43,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@ -57,14 +65,13 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayout
import coil.compose.AsyncImage import coil.compose.AsyncImage
import io.realm.kotlin.notifications.SingleQueryChange import io.realm.kotlin.notifications.SingleQueryChange
import io.realm.kotlin.notifications.UpdatedObject import io.realm.kotlin.notifications.UpdatedObject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers import java.net.URL
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@ -86,10 +93,37 @@ fun InforBar(text: MutableState<String>, leftAction: MutableState<SwipeAction?>,
var queueChanged by mutableIntStateOf(0) var queueChanged by mutableIntStateOf(0)
@Stable
class EpisodeVM(var episode: Episode) {
var positionState by mutableStateOf(episode.media?.position?:0)
var playedState by mutableStateOf(episode.isPlayed())
var isPlayingState by mutableStateOf(false)
var farvoriteState by mutableStateOf(episode.isFavorite)
var inProgressState by mutableStateOf(episode.isInProgress)
var downloadState by mutableIntStateOf(if (episode.media?.downloaded == true) DownloadStatus.State.COMPLETED.ordinal else DownloadStatus.State.UNKNOWN.ordinal)
var isRemote by mutableStateOf(false)
var actionButton by mutableStateOf<EpisodeActionButton?>(null)
var actionRes by mutableIntStateOf(R.drawable.ic_questionmark)
var showAltActionsDialog by mutableStateOf(false)
var dlPercent by mutableIntStateOf(0)
var inQueueState by mutableStateOf(curQueue.contains(episode))
var isSelected by mutableStateOf(false)
var prog by mutableFloatStateOf(0f)
var episodeMonitor: Job? by mutableStateOf(null)
var mediaMonitor: Job? by mutableStateOf(null)
fun stopMonitoring() {
episodeMonitor?.cancel()
mediaMonitor?.cancel()
Logd("EpisodeVM", "cancel monitoring")
}
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episode>, refreshCB: (()->Unit)? = null, fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>, refreshCB: (()->Unit)? = null,
leftSwipeCB: ((Episode) -> Unit)? = null, rightSwipeCB: ((Episode) -> Unit)? = null, actionButton_: ((Episode)->EpisodeActionButton)? = null) { leftSwipeCB: ((Episode) -> Unit)? = null, rightSwipeCB: ((Episode) -> Unit)? = null, actionButton_: ((Episode)-> EpisodeActionButton)? = null) {
val TAG = "EpisodeLazyColumn" val TAG = "EpisodeLazyColumn"
var selectMode by remember { mutableStateOf(false) } var selectMode by remember { mutableStateOf(false) }
var selectedSize by remember { mutableStateOf(0) } var selectedSize by remember { mutableStateOf(0) }
@ -97,10 +131,16 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
var longPressIndex by remember { mutableIntStateOf(-1) } var longPressIndex by remember { mutableIntStateOf(-1) }
val dls = remember { DownloadServiceInterface.get() }
val showConfirmYoutubeDialog = remember { mutableStateOf(false) }
val youtubeUrls = remember { mutableListOf<String>() }
confirmAddYoutubeEpisode(youtubeUrls, showConfirmYoutubeDialog.value, onDismissRequest = {
showConfirmYoutubeDialog.value = false
})
@Composable @Composable
fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList<Episode>, modifier: Modifier = Modifier) { fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList<Episode>, modifier: Modifier = Modifier) {
val TAG = "EpisodeSpeedDial ${selected.size}"
var isExpanded by remember { mutableStateOf(false) } var isExpanded by remember { mutableStateOf(false) }
val options = mutableListOf<@Composable () -> Unit>( val options = mutableListOf<@Composable () -> Unit>(
{ Row(modifier = Modifier.padding(horizontal = 16.dp) { Row(modifier = Modifier.padding(horizontal = 16.dp)
@ -193,7 +233,18 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
selectMode = false selectMode = false
Logd(TAG, "reserve: ${selected.size}") Logd(TAG, "reserve: ${selected.size}")
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
for (e in selected) { addToMiscSyndicate(e) } youtubeUrls.clear()
for (e in selected) {
Logd(TAG, "downloadUrl: ${e.media?.downloadUrl}")
val url = URL(e.media?.downloadUrl?: "")
if ((isYoutubeURL(url) && url.path.startsWith("/watch")) || isYoutubeServiceURL(url)) {
youtubeUrls.add(e.media!!.downloadUrl!!)
} else addToMiscSyndicate(e)
}
Logd(TAG, "youtubeUrls: ${youtubeUrls.size}")
withContext(Dispatchers.Main) {
showConfirmYoutubeDialog.value = youtubeUrls.isNotEmpty()
}
} }
}, verticalAlignment = Alignment.CenterVertically }, verticalAlignment = Alignment.CenterVertically
) { ) {
@ -216,66 +267,53 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
var refreshing by remember { mutableStateOf(false)} var refreshing by remember { mutableStateOf(false)}
PullToRefreshBox(modifier = Modifier.fillMaxWidth(), isRefreshing = refreshing, indicator = {}, onRefresh = { PullToRefreshBox(modifier = Modifier.fillMaxWidth(), isRefreshing = refreshing, indicator = {}, onRefresh = {
// coroutineScope.launch {
refreshing = true refreshing = true
refreshCB?.invoke() refreshCB?.invoke()
// if (swipeRefresh) FeedUpdateManager.runOnceOrAsk(activity)
refreshing = false refreshing = false
// }
}) { }) {
LazyColumn(state = lazyListState, LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp),
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(episodes, key = {index, episode -> episode.id}) { index, episode -> itemsIndexed(vms, key = {index, vm -> vm.episode.id}) { index, vm ->
var positionState by remember { mutableStateOf(episode.media?.position?:0) } if (vm.episodeMonitor == null) {
var playedState by remember { mutableStateOf(episode.isPlayed()) } vm.episodeMonitor = CoroutineScope(Dispatchers.Default).launch {
var farvoriteState by remember { mutableStateOf(episode.isFavorite) } val item_ = realm.query(Episode::class).query("id == ${vm.episode.id}").first()
var inProgressState by remember { mutableStateOf(episode.isInProgress) } Logd(TAG, "start monitoring episode: $index ${vm.episode.title}")
var episodeMonitor: Job? by remember { mutableStateOf(null) }
var mediaMonitor: Job? by remember { mutableStateOf(null) }
if (episodeMonitor == null) {
episodeMonitor = CoroutineScope(Dispatchers.Default).launch {
val item_ = realm.query(Episode::class).query("id == ${episode.id}").first()
Logd(TAG, "start monitoring episode: $index ${episode.title}")
val episodeFlow = item_.asFlow() val episodeFlow = item_.asFlow()
episodeFlow.collect { changes: SingleQueryChange<Episode> -> episodeFlow.collect { changes: SingleQueryChange<Episode> ->
when (changes) { when (changes) {
is UpdatedObject -> { is UpdatedObject -> {
Logd(TAG, "episodeMonitor UpdatedObject $index ${changes.obj.title} ${changes.changedFields.joinToString()}") Logd(TAG, "episodeMonitor UpdatedObject $index ${changes.obj.title} ${changes.changedFields.joinToString()}")
Logd(TAG, "episodeMonitor $index ${changes.obj.id} ${episodes[index].id} ${episode.id}") if (index < vms.size && vms[index].episode.id == changes.obj.id) {
if (index < episodes.size && episodes[index].id == changes.obj.id) { withContext(Dispatchers.Main) {
playedState = changes.obj.isPlayed() vms[index].playedState = changes.obj.isPlayed()
farvoriteState = changes.obj.isFavorite vms[index].farvoriteState = changes.obj.isFavorite
episodes[index] = changes.obj // direct assignment doesn't update member like media?? vms[index].episode = changes.obj // direct assignment doesn't update member like media??
changes.obj.copyStates(episodes[index])
// remove action could possibly conflict with the one in mediaMonitor
// episodes.removeAt(index)
// episodes.add(index, changes.obj)
} }
Logd(TAG, "episodeMonitor $index ${vms[index].playedState} ${vm.playedState} ")
} else Logd(TAG, "episodeMonitor index out bound: $index ${vms.size}")
} }
else -> {} else -> {}
} }
} }
} }
} }
if (mediaMonitor == null) { if (vm.mediaMonitor == null) {
mediaMonitor = CoroutineScope(Dispatchers.Default).launch { vm.mediaMonitor = CoroutineScope(Dispatchers.Default).launch {
val item_ = realm.query(Episode::class).query("id == ${episode.id}").first() val item_ = realm.query(Episode::class).query("id == ${vm.episode.id}").first()
Logd(TAG, "start monitoring media: $index ${episode.title}") Logd(TAG, "start monitoring media: $index ${vm.episode.title}")
val episodeFlow = item_.asFlow(listOf("media.*")) val episodeFlow = item_.asFlow(listOf("media.*"))
episodeFlow.collect { changes: SingleQueryChange<Episode> -> episodeFlow.collect { changes: SingleQueryChange<Episode> ->
when (changes) { when (changes) {
is UpdatedObject -> { is UpdatedObject -> {
Logd(TAG, "mediaMonitor UpdatedObject $index ${changes.obj.title} ${changes.changedFields.joinToString()}") Logd(TAG, "mediaMonitor UpdatedObject $index ${changes.obj.title} ${changes.changedFields.joinToString()}")
if (index < episodes.size && episodes[index].id == changes.obj.id) { if (index < vms.size && vms[index].episode.id == changes.obj.id) {
positionState = changes.obj.media?.position ?: 0 withContext(Dispatchers.Main) {
inProgressState = changes.obj.isInProgress vms[index].positionState = changes.obj.media?.position ?: 0
// episodes[index] = changes.obj // direct assignment doesn't update member like media?? vms[index].inProgressState = changes.obj.isInProgress
changes.obj.copyStates(episodes[index]) Logd(TAG, "mediaMonitor $index ${vm.positionState} ${vm.inProgressState} ${vm.episode.title}")
episodes.removeAt(index) vms[index].episode = changes.obj
episodes.add(index, changes.obj)
} }
} else Logd(TAG, "mediaMonitor index out bound: $index ${vms.size}")
} }
else -> {} else -> {}
} }
@ -285,14 +323,16 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
Logd(TAG, "cancelling monitoring $index") Logd(TAG, "cancelling monitoring $index")
episodeMonitor?.cancel() vm.episodeMonitor?.cancel()
mediaMonitor?.cancel() vm.mediaMonitor?.cancel()
} }
} }
if (episodes[index].stopMonitoring.value) { LaunchedEffect(vm.actionButton) {
Logd(TAG, "cancel monitoring: ${episodes[index].title}") Logd(TAG, "LaunchedEffect init actionButton")
episodeMonitor?.cancel() if (vm.actionButton == null) {
mediaMonitor?.cancel() vm.actionButton = if (actionButton_ != null) actionButton_(vm.episode) else EpisodeActionButton.forItem(vm.episode)
vm.actionRes = vm.actionButton!!.getDrawable()
}
} }
val velocityTracker = remember { VelocityTracker() } val velocityTracker = remember { VelocityTracker() }
val offsetX = remember { Animatable(0f) } val offsetX = remember { Animatable(0f) }
@ -308,8 +348,11 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
coroutineScope.launch { coroutineScope.launch {
val velocity = velocityTracker.calculateVelocity().x val velocity = velocityTracker.calculateVelocity().x
if (velocity > 1000f || velocity < -1000f) { if (velocity > 1000f || velocity < -1000f) {
if (velocity > 0) rightSwipeCB?.invoke(episodes[index]) Logd(TAG, "velocity: $velocity")
else leftSwipeCB?.invoke(episodes[index]) // if (velocity > 0) rightSwipeCB?.invoke(vms[index].episode)
// else leftSwipeCB?.invoke(vms[index].episode)
if (velocity > 0) rightSwipeCB?.invoke(vm.episode)
else leftSwipeCB?.invoke(vm.episode)
} }
offsetX.animateTo( offsetX.animateTo(
targetValue = 0f, // Back to the initial position targetValue = 0f, // Back to the initial position
@ -320,18 +363,17 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
) )
}.offset { IntOffset(offsetX.value.roundToInt(), 0) } }.offset { IntOffset(offsetX.value.roundToInt(), 0) }
) { ) {
var isSelected by remember { mutableStateOf(false) }
LaunchedEffect(key1 = selectMode, key2 = selectedSize) { LaunchedEffect(key1 = selectMode, key2 = selectedSize) {
isSelected = selectMode && episode in selected vm.isSelected = selectMode && vm.episode in selected
// Logd(TAG, "LaunchedEffect $index $isSelected ${selected.size}") // Logd(TAG, "LaunchedEffect $index $isSelected ${selected.size}")
} }
fun toggleSelected() { fun toggleSelected() {
isSelected = !isSelected vm.isSelected = !vm.isSelected
if (isSelected) selected.add(episodes[index]) if (vm.isSelected) selected.add(vms[index].episode)
else selected.remove(episodes[index]) else selected.remove(vms[index].episode)
} }
val textColor = MaterialTheme.colorScheme.onSurface val textColor = MaterialTheme.colorScheme.onSurface
Row (Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)) { Row (Modifier.background(if (vm.isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)) {
if (false) { if (false) {
val typedValue = TypedValue() val typedValue = TypedValue()
LocalContext.current.theme.resolveAttribute(R.attr.dragview_background, typedValue, true) LocalContext.current.theme.resolveAttribute(R.attr.dragview_background, typedValue, true)
@ -341,7 +383,7 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
} }
ConstraintLayout(modifier = Modifier.width(56.dp).height(56.dp)) { ConstraintLayout(modifier = Modifier.width(56.dp).height(56.dp)) {
val (imgvCover, checkMark) = createRefs() val (imgvCover, checkMark) = createRefs()
val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(episode) val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(vm.episode)
AsyncImage(model = imgLoc, contentDescription = "imgvCover", AsyncImage(model = imgLoc, contentDescription = "imgvCover",
placeholder = painterResource(R.mipmap.ic_launcher), placeholder = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(56.dp).height(56.dp) modifier = Modifier.width(56.dp).height(56.dp)
@ -352,10 +394,10 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
}.clickable(onClick = { }.clickable(onClick = {
Logd(TAG, "icon clicked!") Logd(TAG, "icon clicked!")
if (selectMode) toggleSelected() if (selectMode) toggleSelected()
else activity.loadChildFragment(FeedInfoFragment.newInstance(episode.feed!!)) else if (vm.episode.feed != null) activity.loadChildFragment(FeedInfoFragment.newInstance(vm.episode.feed!!))
})) }))
val alpha = if (playedState) 1.0f else 0f val alpha = if (vm.playedState) 1.0f else 0f
if (playedState) Icon(painter = painterResource(R.drawable.ic_check), tint = textColor, contentDescription = "played_mark", if (vm.playedState) Icon(painter = painterResource(R.drawable.ic_check), tint = textColor, contentDescription = "played_mark",
modifier = Modifier.background(Color.Green).alpha(alpha).constrainAs(checkMark) { modifier = Modifier.background(Color.Green).alpha(alpha).constrainAs(checkMark) {
bottom.linkTo(parent.bottom) bottom.linkTo(parent.bottom)
end.linkTo(parent.end) end.linkTo(parent.end)
@ -363,84 +405,84 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
} }
Column(Modifier.weight(1f).padding(start = 6.dp, end = 6.dp) Column(Modifier.weight(1f).padding(start = 6.dp, end = 6.dp)
.combinedClickable(onClick = { .combinedClickable(onClick = {
Logd(TAG, "clicked: ${episode.title}") Logd(TAG, "clicked: ${vm.episode.title}")
if (selectMode) toggleSelected() if (selectMode) toggleSelected()
else activity.loadChildFragment(EpisodeInfoFragment.newInstance(episode)) else activity.loadChildFragment(EpisodeInfoFragment.newInstance(vm.episode))
}, onLongClick = { }, onLongClick = {
selectMode = !selectMode selectMode = !selectMode
isSelected = selectMode vm.isSelected = selectMode
if (selectMode) { if (selectMode) {
selected.add(episodes[index]) selected.add(vms[index].episode)
longPressIndex = index longPressIndex = index
} else { } else {
selected.clear() selected.clear()
selectedSize = 0 selectedSize = 0
longPressIndex = -1 longPressIndex = -1
} }
Logd(TAG, "long clicked: ${episode.title}") Logd(TAG, "long clicked: ${vm.episode.title}")
})) { })) {
var inQueueState by remember { mutableStateOf(false) }
LaunchedEffect(key1 = queueChanged) { LaunchedEffect(key1 = queueChanged) {
if (index>=episodes.size) return@LaunchedEffect if (index>=vms.size) return@LaunchedEffect
inQueueState = curQueue.contains(episodes[index]) vms[index].inQueueState = curQueue.contains(vms[index].episode)
} }
val dur = episode.media!!.getDuration() val dur = vm.episode.media!!.getDuration()
val durText = DurationConverter.getDurationStringLong(dur) val durText = DurationConverter.getDurationStringLong(dur)
Row { Row {
if (episode.media?.getMediaType() == MediaType.VIDEO) if (vm.episode.media?.getMediaType() == MediaType.VIDEO)
Icon(painter = painterResource(R.drawable.ic_videocam), tint = textColor, contentDescription = "isVideo", modifier = Modifier.width(14.dp).height(14.dp)) Icon(painter = painterResource(R.drawable.ic_videocam), tint = textColor, contentDescription = "isVideo", modifier = Modifier.width(14.dp).height(14.dp))
if (farvoriteState) if (vm.farvoriteState)
Icon(painter = painterResource(R.drawable.ic_star), tint = textColor, contentDescription = "isFavorite", modifier = Modifier.width(14.dp).height(14.dp)) Icon(painter = painterResource(R.drawable.ic_star), tint = textColor, contentDescription = "isFavorite", modifier = Modifier.width(14.dp).height(14.dp))
if (inQueueState) if (vm.inQueueState)
Icon(painter = painterResource(R.drawable.ic_playlist_play), tint = textColor, contentDescription = "ivInPlaylist", modifier = Modifier.width(14.dp).height(14.dp)) Icon(painter = painterResource(R.drawable.ic_playlist_play), tint = textColor, contentDescription = "ivInPlaylist", modifier = Modifier.width(14.dp).height(14.dp))
val curContext = LocalContext.current val curContext = LocalContext.current
val dateSizeText = " · " + formatAbbrev(curContext, episode.getPubDate()) + " · " + durText + " · " + if((episode.media?.size?:0) > 0) Formatter.formatShortFileSize(curContext, episode.media!!.size) else "" val dateSizeText = " · " + formatAbbrev(curContext, vm.episode.getPubDate()) + " · " + durText + " · " + if((vm.episode.media?.size?:0) > 0) Formatter.formatShortFileSize(curContext, vm.episode.media!!.size) else ""
Text(dateSizeText, color = textColor, style = MaterialTheme.typography.bodyMedium) Text(dateSizeText, color = textColor, style = MaterialTheme.typography.bodyMedium)
} }
Text(episode.title?:"", color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis) Text(vm.episode.title?:"", color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (InTheatre.isCurMedia(episode.media) || inProgressState) { if (InTheatre.isCurMedia(vm.episode.media) || vm.inProgressState) {
val pos = positionState val pos = vm.positionState
val prog = if (dur > 0 && pos >= 0 && dur >= pos) 1.0f * pos / dur else 0f vm.prog = if (dur > 0 && pos >= 0 && dur >= pos) 1.0f * pos / dur else 0f
Logd(TAG, "$index vm.prog: ${vm.prog}")
Row { Row {
Text(DurationConverter.getDurationStringLong(pos), color = textColor, style = MaterialTheme.typography.bodySmall) Text(DurationConverter.getDurationStringLong(vm.positionState), color = textColor, style = MaterialTheme.typography.bodySmall)
LinearProgressIndicator(progress = { prog }, modifier = Modifier.weight(1f).height(4.dp).align(Alignment.CenterVertically)) LinearProgressIndicator(progress = { vm.prog }, modifier = Modifier.weight(1f).height(4.dp).align(Alignment.CenterVertically))
Text(durText, color = textColor, style = MaterialTheme.typography.bodySmall) Text(durText, color = textColor, style = MaterialTheme.typography.bodySmall)
} }
} }
} }
var actionButton by remember { mutableStateOf(if (actionButton_ == null) EpisodeActionButton.forItem(episodes[index]) else actionButton_(episodes[index])) }
var actionRes by mutableIntStateOf(actionButton.getDrawable())
var showAltActionsDialog by remember { mutableStateOf(false) }
val dls = remember { DownloadServiceInterface.get() }
var dlPercent by remember { mutableIntStateOf(0) }
fun isDownloading(): Boolean { fun isDownloading(): Boolean {
return episodes[index].downloadState.value > DownloadStatus.State.UNKNOWN.ordinal && episodes[index].downloadState.value < DownloadStatus.State.COMPLETED.ordinal return vms[index].downloadState > DownloadStatus.State.UNKNOWN.ordinal && vms[index].downloadState < DownloadStatus.State.COMPLETED.ordinal
} }
if (actionButton_ == null) { if (actionButton_ == null) {
LaunchedEffect(episodes[index].downloadState.value) { LaunchedEffect(vms[index].downloadState) {
if (index>=episodes.size) return@LaunchedEffect if (index>=vms.size) return@LaunchedEffect
if (isDownloading()) dlPercent = dls?.getProgress(episodes[index].media!!.downloadUrl!!) ?: 0 if (isDownloading()) vm.dlPercent = dls?.getProgress(vms[index].episode.media!!.downloadUrl!!) ?: 0
// Logd(TAG, "downloadState: ${episodes[index].downloadState.value} ${episode.media?.downloaded} $dlPercent") Logd(TAG, "LaunchedEffect $index downloadState: ${vms[index].downloadState} ${vm.episode.media?.downloaded} ${vm.dlPercent}")
actionButton = EpisodeActionButton.forItem(episodes[index]) vm.actionButton = EpisodeActionButton.forItem(vms[index].episode)
actionRes = actionButton.getDrawable() vm.actionRes = vm.actionButton!!.getDrawable()
} }
LaunchedEffect(key1 = status) { LaunchedEffect(key1 = status) {
if (index>=episodes.size) return@LaunchedEffect if (index>=vms.size) return@LaunchedEffect
// Logd(TAG, "$index isPlayingState: ${episodes[index].isPlayingState.value} ${episodes[index].title}") Logd(TAG, "LaunchedEffect $index isPlayingState: ${vms[index].isPlayingState} ${vms[index].episode.title}")
actionButton = EpisodeActionButton.forItem(episodes[index]) vm.actionButton = EpisodeActionButton.forItem(vms[index].episode)
actionRes = actionButton.getDrawable() vm.actionRes = vm.actionButton!!.getDrawable()
} }
// LaunchedEffect(vm.isPlayingState) {
// Logd(TAG, "LaunchedEffect isPlayingState: $index ${vms[index].isPlayingState} ${vm.isPlayingState}")
// vms[index].actionButton = EpisodeActionButton.forItem(vms[index].episode)
// vms[index].actionRes = vm.actionButton.getDrawable()
// }
} }
Box(modifier = Modifier.width(40.dp).height(40.dp).padding(end = 10.dp).align(Alignment.CenterVertically).pointerInput(Unit) { Box(modifier = Modifier.width(40.dp).height(40.dp).padding(end = 10.dp).align(Alignment.CenterVertically).pointerInput(Unit) {
detectTapGestures(onLongPress = { showAltActionsDialog = true }, onTap = { detectTapGestures(onLongPress = { vm.showAltActionsDialog = true }, onTap = {
actionButton.onClick(activity) vm.actionButton?.onClick(activity)
}) })
}, contentAlignment = Alignment.Center) { }, contentAlignment = Alignment.Center) {
// actionRes = actionButton.getDrawable() // actionRes = actionButton.getDrawable()
Icon(painter = painterResource(actionRes), tint = textColor, contentDescription = null, modifier = Modifier.width(28.dp).height(32.dp)) Icon(painter = painterResource(vm.actionRes), tint = textColor, contentDescription = null, modifier = Modifier.width(28.dp).height(32.dp))
if (isDownloading() && dlPercent >= 0) CircularProgressIndicator(progress = { 0.01f * dlPercent}, strokeWidth = 4.dp, color = textColor, modifier = Modifier.width(30.dp).height(35.dp)) if (isDownloading() && vm.dlPercent >= 0) CircularProgressIndicator(progress = { 0.01f * vm.dlPercent}, strokeWidth = 4.dp, color = textColor, modifier = Modifier.width(30.dp).height(35.dp))
} }
if (showAltActionsDialog) actionButton.AltActionsDialog(activity, showAltActionsDialog, onDismiss = { showAltActionsDialog = false }) if (vm.showAltActionsDialog) vm.actionButton?.AltActionsDialog(activity, vm.showAltActionsDialog, onDismiss = { vm.showAltActionsDialog = false })
} }
} }
} }
@ -451,7 +493,7 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
.clickable(onClick = { .clickable(onClick = {
selected.clear() selected.clear()
for (i in 0..longPressIndex) { for (i in 0..longPressIndex) {
selected.add(episodes[i]) selected.add(vms[i].episode)
} }
selectedSize = selected.size selectedSize = selected.size
Logd(TAG, "selectedIds: ${selected.size}") Logd(TAG, "selectedIds: ${selected.size}")
@ -459,8 +501,8 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
Icon(painter = painterResource(R.drawable.baseline_arrow_downward_24), tint = Color.Black, contentDescription = null, modifier = Modifier.width(35.dp).height(35.dp).padding(end = 10.dp) Icon(painter = painterResource(R.drawable.baseline_arrow_downward_24), tint = Color.Black, contentDescription = null, modifier = Modifier.width(35.dp).height(35.dp).padding(end = 10.dp)
.clickable(onClick = { .clickable(onClick = {
selected.clear() selected.clear()
for (i in longPressIndex..<episodes.size) { for (i in longPressIndex..<vms.size) {
selected.add(episodes[i]) selected.add(vms[i].episode)
} }
selectedSize = selected.size selectedSize = selected.size
Logd(TAG, "selectedIds: ${selected.size}") Logd(TAG, "selectedIds: ${selected.size}")
@ -468,12 +510,11 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
var selectAllRes by remember { mutableIntStateOf(R.drawable.ic_select_all) } var selectAllRes by remember { mutableIntStateOf(R.drawable.ic_select_all) }
Icon(painter = painterResource(selectAllRes), tint = Color.Black, contentDescription = null, modifier = Modifier.width(35.dp).height(35.dp) Icon(painter = painterResource(selectAllRes), tint = Color.Black, contentDescription = null, modifier = Modifier.width(35.dp).height(35.dp)
.clickable(onClick = { .clickable(onClick = {
if (selectedSize != episodes.size) { if (selectedSize != vms.size) {
selected.clear() selected.clear()
selected.addAll(episodes) for (vm in vms) {
// for (e in episodes) { selected.add(vm.episode)
// selected.add(e) }
// }
selectAllRes = R.drawable.ic_select_none selectAllRes = R.drawable.ic_select_none
} else { } else {
selected.clear() selected.clear()
@ -488,3 +529,43 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
} }
} }
} }
@Composable
fun confirmAddYoutubeEpisode(sharedUrls: List<String>, showDialog: Boolean, onDismissRequest: () -> Unit) {
val TAG = "confirmAddEpisode"
var showToast by remember { mutableStateOf(false) }
var toastMassege by remember { mutableStateOf("")}
if (showToast) CustomToast(message = toastMassege, onDismiss = { showToast = false })
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 audioOnly by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth()) {
Checkbox(checked = audioOnly, onCheckedChange = { audioOnly = it })
Text(text = stringResource(R.string.pref_video_mode_audio_only), style = MaterialTheme.typography.bodyLarge.merge())
}
Button(onClick = {
CoroutineScope(Dispatchers.IO).launch {
try {
for (url in sharedUrls) {
val info = StreamInfo.getInfo(Vista.getService(0), url)
val episode = episodeFromStreamInfo(info)
addToYoutubeSyndicate(episode, !audioOnly)
}
} catch (e: Throwable) {
toastMassege = "Receive share error: ${e.message}"
Log.e(TAG, toastMassege)
showToast = true
}
}
onDismissRequest()
}) {
Text("Confirm")
}
}
}
}
}
}

View File

@ -4,24 +4,26 @@ import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.feed.FeedBuilder import ac.mdiq.podcini.net.feed.FeedBuilder
import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment
import ac.mdiq.podcini.ui.fragment.OnlineFeedFragment import ac.mdiq.podcini.ui.fragment.OnlineFeedFragment
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.formatNumber import ac.mdiq.podcini.util.MiscFormatter.formatNumber
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.*
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.constraintlayout.compose.ConstraintLayout
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -35,16 +37,8 @@ fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult) {
fun confirmSubscribe(feed: PodcastSearchResult, showDialog: Boolean, onDismissRequest: () -> Unit) { fun confirmSubscribe(feed: PodcastSearchResult, showDialog: Boolean, onDismissRequest: () -> Unit) {
if (showDialog) { if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) { Dialog(onDismissRequest = { onDismissRequest() }) {
Card( Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
modifier = Modifier Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) {
.wrapContentSize(align = Alignment.Center)
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
Text("Subscribe: \"${feed.title}\" ?") Text("Subscribe: \"${feed.title}\" ?")
Button(onClick = { Button(onClick = {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
@ -73,15 +67,36 @@ fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult) {
Column(Modifier.padding(start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp).combinedClickable( Column(Modifier.padding(start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp).combinedClickable(
onClick = { onClick = {
if (feed.feedUrl != null) { if (feed.feedUrl != null) {
if (feed.feedId > 0) {
val fragment = FeedEpisodesFragment.newInstance(feed.feedId)
activity.loadChildFragment(fragment)
} else {
val fragment = OnlineFeedFragment.newInstance(feed.feedUrl) val fragment = OnlineFeedFragment.newInstance(feed.feedUrl)
fragment.feedSource = feed.source fragment.feedSource = feed.source
activity.loadChildFragment(fragment) activity.loadChildFragment(fragment)
} }
}
}, onLongClick = { showSubscribeDialog.value = true })) { }, onLongClick = { showSubscribeDialog.value = true })) {
val textColor = MaterialTheme.colorScheme.onSurface 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 { Row {
AsyncImage(model = feed.imageUrl, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), modifier = Modifier.width(65.dp).height(65.dp)) ConstraintLayout(modifier = Modifier.width(56.dp).height(56.dp)) {
val (imgvCover, checkMark) = createRefs()
AsyncImage(model = feed.imageUrl,
contentDescription = "imgvCover",
placeholder = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(65.dp).height(65.dp).constrainAs(imgvCover) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
})
val alpha = if (feed.feedId > 0) 1.0f else 0f
if (feed.feedId > 0) Icon(painter = painterResource(R.drawable.ic_check), tint = textColor, contentDescription = "played_mark",
modifier = Modifier.background(Color.Green).alpha(alpha).constrainAs(checkMark) {
bottom.linkTo(parent.bottom)
end.linkTo(parent.end)
})
}
Column(Modifier.padding(start = 10.dp)) { Column(Modifier.padding(start = 10.dp)) {
var authorText by remember { mutableStateOf("") } var authorText by remember { mutableStateOf("") }
authorText = when { authorText = when {

View File

@ -14,11 +14,11 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.* import ac.mdiq.podcini.databinding.*
import ac.mdiq.podcini.ui.fragment.* import ac.mdiq.podcini.ui.fragment.*
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions.Companion.getPrefsWithDefaults import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.getPrefsWithDefaults
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions.Companion.getSharedPrefs import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.getSharedPrefs
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions.Companion.isSwipeActionEnabled import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.isSwipeActionEnabled
import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi

View File

@ -32,7 +32,7 @@ import ac.mdiq.podcini.storage.utils.ChapterUtils
import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.storage.utils.TimeSpeedConverter import ac.mdiq.podcini.storage.utils.TimeSpeedConverter
import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler import ac.mdiq.podcini.ui.actions.EpisodeMenuHandler
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode
import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter
@ -61,7 +61,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign

View File

@ -3,16 +3,13 @@ package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.BaseEpisodesListFragmentBinding import ac.mdiq.podcini.databinding.BaseEpisodesListFragmentBinding
import ac.mdiq.podcini.net.download.DownloadStatus import ac.mdiq.podcini.net.download.DownloadStatus
import ac.mdiq.podcini.net.download.DownloadStatus.State.UNKNOWN
import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.*
import ac.mdiq.podcini.ui.compose.EpisodeLazyColumn
import ac.mdiq.podcini.ui.compose.InforBar
import ac.mdiq.podcini.ui.utils.EmptyViewHandler import ac.mdiq.podcini.ui.utils.EmptyViewHandler
import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.FlowEvent
@ -21,6 +18,7 @@ import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.* import android.view.*
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.core.util.Pair import androidx.core.util.Pair
@ -54,7 +52,8 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
lateinit var toolbar: MaterialToolbar lateinit var toolbar: MaterialToolbar
lateinit var swipeActions: SwipeActions lateinit var swipeActions: SwipeActions
val episodes = mutableStateListOf<Episode>() val episodes = mutableListOf<Episode>()
private val vms = mutableStateListOf<EpisodeVM>()
@UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState) super.onCreateView(inflater, container, savedInstanceState)
@ -79,19 +78,24 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
swipeActions = SwipeActions(this, TAG) swipeActions = SwipeActions(this, TAG)
lifecycle.addObserver(swipeActions) lifecycle.addObserver(swipeActions)
binding.infobar.setContent {
CustomTheme(requireContext()) {
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
}
}
binding.lazyColumn.setContent { binding.lazyColumn.setContent {
CustomTheme(requireContext()) { CustomTheme(requireContext()) {
EpisodeLazyColumn(activity as MainActivity, episodes = episodes, Column {
leftSwipeCB = { if (leftActionState.value == null) swipeActions.showDialog() else leftActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())}, InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
rightSwipeCB = { if (rightActionState.value == null) swipeActions.showDialog() else rightActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())}, EpisodeLazyColumn(
activity as MainActivity, vms = vms,
leftSwipeCB = {
if (leftActionState.value == null) swipeActions.showDialog()
else leftActionState.value?.performAction(it, this@BaseEpisodesFragment, swipeActions.filter ?: EpisodeFilter())
},
rightSwipeCB = {
if (rightActionState.value == null) swipeActions.showDialog()
else rightActionState.value?.performAction(it, this@BaseEpisodesFragment, swipeActions.filter ?: EpisodeFilter())
},
) )
} }
} }
}
swipeActions.setFilter(getFilter()) swipeActions.setFilter(getFilter())
refreshSwipeTelltale() refreshSwipeTelltale()
@ -143,6 +147,7 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
Logd(TAG, "onDestroyView") Logd(TAG, "onDestroyView")
_binding = null _binding = null
episodes.clear() episodes.clear()
vms.clear()
super.onDestroyView() super.onDestroyView()
} }
@ -160,8 +165,10 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
for (url in event.urls) { for (url in event.urls) {
// if (!event.isCompleted(url)) continue // if (!event.isCompleted(url)) continue
val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(episodes, url) val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(episodes, url)
if (pos >= 0) episodes[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal if (pos >= 0) {
// episodes[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
vms[pos].downloadState = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
}
} }
} }
@ -220,10 +227,12 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
val data = withContext(Dispatchers.IO) { val data = withContext(Dispatchers.IO) {
Pair(loadData().toMutableList(), loadTotalItemCount()) Pair(loadData().toMutableList(), loadTotalItemCount())
} }
withContext(Dispatchers.Main) {
val restoreScrollPosition = episodes.isEmpty() val restoreScrollPosition = episodes.isEmpty()
episodes.clear() episodes.clear()
episodes.addAll(data.first) episodes.addAll(data.first)
withContext(Dispatchers.Main) {
vms.clear()
for (e in data.first) { vms.add(EpisodeVM(e)) }
// if (restoreScrollPosition) recyclerView.restoreScrollPosition(getPrefName()) // if (restoreScrollPosition) recyclerView.restoreScrollPosition(getPrefName())
updateToolbar() updateToolbar()
} }

View File

@ -13,7 +13,7 @@ import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.utils.DownloadResultComparator import ac.mdiq.podcini.storage.utils.DownloadResultComparator
import ac.mdiq.podcini.ui.actions.actionbutton.DownloadActionButton import ac.mdiq.podcini.ui.actions.DownloadActionButton
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.dialog.DownloadLogDetailsDialog import ac.mdiq.podcini.ui.dialog.DownloadLogDetailsDialog
import ac.mdiq.podcini.ui.utils.EmptyViewHandler import ac.mdiq.podcini.ui.utils.EmptyViewHandler

View File

@ -15,14 +15,11 @@ import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.EpisodeSortOrder import ac.mdiq.podcini.storage.model.EpisodeSortOrder
import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.actionbutton.DeleteActionButton import ac.mdiq.podcini.ui.actions.DeleteActionButton
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.*
import ac.mdiq.podcini.ui.compose.EpisodeLazyColumn
import ac.mdiq.podcini.ui.compose.InforBar
import ac.mdiq.podcini.ui.compose.queueChanged
import ac.mdiq.podcini.ui.dialog.EpisodeFilterDialog import ac.mdiq.podcini.ui.dialog.EpisodeFilterDialog
import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog
import ac.mdiq.podcini.ui.dialog.SwitchQueueDialog import ac.mdiq.podcini.ui.dialog.SwitchQueueDialog
@ -38,6 +35,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -61,7 +59,8 @@ import java.util.*
private val binding get() = _binding!! private val binding get() = _binding!!
private var runningDownloads: Set<String> = HashSet() private var runningDownloads: Set<String> = HashSet()
private val episodes = mutableStateListOf<Episode>() private val episodes = mutableListOf<Episode>()
private val vms = mutableStateListOf<EpisodeVM>()
private var infoBarText = mutableStateOf("") private var infoBarText = mutableStateOf("")
private var leftActionState = mutableStateOf<SwipeAction?>(null) private var leftActionState = mutableStateOf<SwipeAction?>(null)
@ -93,18 +92,21 @@ import java.util.*
swipeActions = SwipeActions(this, TAG) swipeActions = SwipeActions(this, TAG)
swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.downloaded.name)) swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.downloaded.name))
binding.infobar.setContent {
CustomTheme(requireContext()) {
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
}
}
binding.lazyColumn.setContent { binding.lazyColumn.setContent {
CustomTheme(requireContext()) { CustomTheme(requireContext()) {
EpisodeLazyColumn(activity as MainActivity, episodes = episodes, Column {
leftSwipeCB = { if (leftActionState.value == null) swipeActions.showDialog() else leftActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())}, InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
rightSwipeCB = { if (rightActionState.value == null) swipeActions.showDialog() else rightActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())}, EpisodeLazyColumn(activity as MainActivity, vms = vms,
actionButton_ = { DeleteActionButton(it) } ) leftSwipeCB = {
if (leftActionState.value == null) swipeActions.showDialog() else leftActionState.value?.performAction(
it, this@DownloadsFragment, swipeActions.filter ?: EpisodeFilter())
},
rightSwipeCB = {
if (rightActionState.value == null) swipeActions.showDialog() else rightActionState.value?.performAction(
it, this@DownloadsFragment, swipeActions.filter ?: EpisodeFilter())
},
actionButton_ = { DeleteActionButton(it) })
}
} }
} }
// recyclerView.setRecycledViewPool((activity as MainActivity).recycledViewPool) // recyclerView.setRecycledViewPool((activity as MainActivity).recycledViewPool)
@ -143,11 +145,10 @@ import java.util.*
override fun onDestroyView() { override fun onDestroyView() {
Logd(TAG, "onDestroyView") Logd(TAG, "onDestroyView")
_binding = null _binding = null
// adapter.endSelectMode()
// adapter.clearData()
toolbar.setOnMenuItemClickListener(null) toolbar.setOnMenuItemClickListener(null)
toolbar.setOnLongClickListener(null) toolbar.setOnLongClickListener(null)
episodes.clear() episodes.clear()
vms.clear()
super.onDestroyView() super.onDestroyView()
} }
@ -295,8 +296,12 @@ import java.util.*
val pos = EpisodeUtil.indexOfItemWithId(episodes, item.id) val pos = EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) { if (pos >= 0) {
episodes.removeAt(pos) episodes.removeAt(pos)
vms.removeAt(pos)
val media = item.media val media = item.media
if (media != null && media.downloaded) episodes.add(pos, item) if (media != null && media.downloaded) {
episodes.add(pos, item)
vms.add(pos, EpisodeVM(item))
}
} }
} }
// have to do this as adapter.notifyItemRemoved(pos) when pos == 0 causes crash // have to do this as adapter.notifyItemRemoved(pos) when pos == 0 causes crash
@ -313,8 +318,12 @@ import java.util.*
val pos = EpisodeUtil.indexOfItemWithId(episodes, item.id) val pos = EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) { if (pos >= 0) {
episodes.removeAt(pos) episodes.removeAt(pos)
vms.removeAt(pos)
val media = item.media val media = item.media
if (media != null && media.downloaded) episodes.add(pos, item) if (media != null && media.downloaded) {
episodes.add(pos, item)
vms.add(pos, EpisodeVM(item))
}
} }
} }
// have to do this as adapter.notifyItemRemoved(pos) when pos == 0 causes crash // have to do this as adapter.notifyItemRemoved(pos) when pos == 0 causes crash
@ -330,6 +339,7 @@ import java.util.*
private var loadItemsRunning = false private var loadItemsRunning = false
private fun loadItems() { private fun loadItems() {
emptyView.hide() emptyView.hide()
Logd(TAG, "loadItems() called")
if (!loadItemsRunning) { if (!loadItemsRunning) {
loadItemsRunning = true loadItemsRunning = true
lifecycleScope.launch { lifecycleScope.launch {
@ -353,6 +363,10 @@ import java.util.*
episodes.addAll(currentDownloads) episodes.addAll(currentDownloads)
} }
episodes.retainAll { filter.matchesForQueues(it) } episodes.retainAll { filter.matchesForQueues(it) }
withContext(Dispatchers.Main) {
vms.clear()
for (e in episodes) vms.add(EpisodeVM(e))
}
} }
withContext(Dispatchers.Main) { refreshInfoBar() } withContext(Dispatchers.Main) { refreshInfoBar() }
} catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e))
@ -364,13 +378,13 @@ import java.util.*
private fun getEpisdesWithUrl(urls: List<String>): List<Episode> { private fun getEpisdesWithUrl(urls: List<String>): List<Episode> {
Logd(TAG, "getEpisdesWithUrl() called ") Logd(TAG, "getEpisdesWithUrl() called ")
if (urls.isEmpty()) return listOf() if (urls.isEmpty()) return listOf()
val episodes: MutableList<Episode> = mutableListOf() val episodes_: MutableList<Episode> = mutableListOf()
for (url in urls) { for (url in urls) {
val media = realm.query(EpisodeMedia::class).query("downloadUrl == $0", url).first().find() ?: continue val media = realm.query(EpisodeMedia::class).query("downloadUrl == $0", url).first().find() ?: continue
val item_ = media.episodeOrFetch() val item_ = media.episodeOrFetch()
if (item_ != null) episodes.add(item_) if (item_ != null) episodes_.add(item_)
} }
return realm.copyFromRealm(episodes) return realm.copyFromRealm(episodes_)
} }
private fun refreshInfoBar() { private fun refreshInfoBar() {
@ -380,7 +394,7 @@ import java.util.*
for (item in episodes) sizeMB += item.media?.size ?: 0 for (item in episodes) sizeMB += item.media?.size ?: 0
info += "" + (sizeMB / 1000000) + " MB" info += "" + (sizeMB / 1000000) + " MB"
} }
Logd(TAG, "filter value: ${getFilter().values.size} ${getFilter().values.joinToString()}") Logd(TAG, "refreshInfoBar filter value: ${getFilter().values.size} ${getFilter().values.joinToString()}")
if (getFilter().values.size > 1) info += " - ${getString(R.string.filtered_label)}" if (getFilter().values.size > 1) info += " - ${getString(R.string.filtered_label)}"
infoBarText.value = info infoBarText.value = info
} }

View File

@ -20,8 +20,8 @@ import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.ui.actions.actionbutton.* import ac.mdiq.podcini.ui.actions.*
import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler import ac.mdiq.podcini.ui.actions.EpisodeMenuHandler
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.utils.ShownotesCleaner
@ -61,7 +61,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView

View File

@ -17,8 +17,8 @@ import ac.mdiq.podcini.storage.model.EpisodeSortOrder
import ac.mdiq.podcini.storage.model.EpisodeSortOrder.Companion.fromCode import ac.mdiq.podcini.storage.model.EpisodeSortOrder.Companion.fromCode
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.* import ac.mdiq.podcini.ui.compose.*
import ac.mdiq.podcini.ui.dialog.* import ac.mdiq.podcini.ui.dialog.*
@ -79,7 +79,10 @@ import java.util.concurrent.Semaphore
private var headerCreated = false private var headerCreated = false
private var feedID: Long = 0 private var feedID: Long = 0
private var feed by mutableStateOf<Feed?>(null) private var feed by mutableStateOf<Feed?>(null)
private val episodes = mutableStateListOf<Episode>()
private val episodes = mutableListOf<Episode>()
private val vms = mutableStateListOf<EpisodeVM>()
private var ieMap: Map<Long, Int> = mapOf() private var ieMap: Map<Long, Int> = mapOf()
private var ueMap: Map<String, Int> = mapOf() private var ueMap: Map<String, Int> = mapOf()
@ -143,6 +146,8 @@ import java.util.concurrent.Semaphore
episodes.addAll(etmp) episodes.addAll(etmp)
ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index } ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index }
ueMap = episodes.mapIndexedNotNull { index, episode -> episode.media?.downloadUrl?.let { it to index } }.toMap() ueMap = episodes.mapIndexedNotNull { index, episode -> episode.media?.downloadUrl?.let { it to index } }.toMap()
vms.clear()
for (e in etmp) { vms.add(EpisodeVM(e)) }
loadItemsRunning = false loadItemsRunning = false
} }
} }
@ -151,16 +156,22 @@ import java.util.concurrent.Semaphore
FeedEpisodesHeader(activity = (activity as MainActivity), feed = feed, filterButColor = filterButColor.value, filterClickCB = {filterClick()}, filterLongClickCB = {filterLongClick()}) FeedEpisodesHeader(activity = (activity as MainActivity), feed = feed, filterButColor = filterButColor.value, filterClickCB = {filterClick()}, filterLongClickCB = {filterLongClick()})
} }
} }
binding.infobar.setContent {
CustomTheme(requireContext()) {
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
}
}
binding.lazyColumn.setContent { binding.lazyColumn.setContent {
Column {
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
CustomTheme(requireContext()) { CustomTheme(requireContext()) {
EpisodeLazyColumn(activity as MainActivity, episodes = episodes, refreshCB = {FeedUpdateManager.runOnceOrAsk(requireContext(), feed)}, EpisodeLazyColumn(activity as MainActivity, vms = vms,
leftSwipeCB = { if (leftActionState.value == null) swipeActions.showDialog() else leftActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())}, refreshCB = { FeedUpdateManager.runOnceOrAsk(requireContext(), feed) },
rightSwipeCB = { if (rightActionState.value == null) swipeActions.showDialog() else rightActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())}, ) leftSwipeCB = {
if (leftActionState.value == null) swipeActions.showDialog()
else leftActionState.value?.performAction(it, this@FeedEpisodesFragment, swipeActions.filter ?: EpisodeFilter())
},
rightSwipeCB = {
if (rightActionState.value == null) swipeActions.showDialog()
else rightActionState.value?.performAction(it, this@FeedEpisodesFragment, swipeActions.filter ?: EpisodeFilter())
},
)
}
} }
} }
@ -284,6 +295,7 @@ import java.util.concurrent.Semaphore
ieMap = mapOf() ieMap = mapOf()
ueMap = mapOf() ueMap = mapOf()
episodes.clear() episodes.clear()
vms.clear()
tts?.stop() tts?.stop()
tts?.shutdown() tts?.shutdown()
ttsWorking = false ttsWorking = false
@ -384,14 +396,12 @@ import java.util.concurrent.Semaphore
private fun onPlayEvent(event: FlowEvent.PlayEvent) { private fun onPlayEvent(event: FlowEvent.PlayEvent) {
// Logd(TAG, "onPlayEvent ${event.episode.title}") // Logd(TAG, "onPlayEvent ${event.episode.title}")
// if (feed != null) { if (feed != null) {
// val pos: Int = ieMap[event.episode.id] ?: -1 val pos: Int = ieMap[event.episode.id] ?: -1
// if (pos >= 0) { if (pos >= 0) {
//// if (!filterOutEpisode(event.episode)) episodes[pos].isPlayingState.value = event.isPlaying() if (!filterOutEpisode(event.episode)) vms[pos].isPlayingState = event.isPlaying()
//// if (filterOutEpisode(event.episode)) adapter.updateItems(episodes) }
//// else adapter.notifyItemChangedCompat(pos) }
// }
// }
} }
private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) {
@ -403,8 +413,8 @@ import java.util.concurrent.Semaphore
val pos: Int = ueMap[url] ?: -1 val pos: Int = ueMap[url] ?: -1
if (pos >= 0) { if (pos >= 0) {
Logd(TAG, "onEpisodeDownloadEvent $pos ${event.map[url]?.state} ${episodes[pos].media?.downloaded} ${episodes[pos].title}") Logd(TAG, "onEpisodeDownloadEvent $pos ${event.map[url]?.state} ${episodes[pos].media?.downloaded} ${episodes[pos].title}")
episodes[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal // episodes[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
// adapter.notifyItemChangedCompat(pos) vms[pos].downloadState = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
} }
} }
} }
@ -533,15 +543,15 @@ import java.util.concurrent.Semaphore
while (loadItemsRunning) Thread.sleep(50) while (loadItemsRunning) Thread.sleep(50)
} }
// private fun filterOutEpisode(episode: Episode): Boolean { private fun filterOutEpisode(episode: Episode): Boolean {
// if (enableFilter && !feed?.preferences?.filterString.isNullOrEmpty() && !feed!!.episodeFilter.matches(episode)) { if (enableFilter && !feed?.preferences?.filterString.isNullOrEmpty() && !feed!!.episodeFilter.matches(episode)) {
// episodes.remove(episode) episodes.remove(episode)
// ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index } ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index }
// ueMap = episodes.mapIndexedNotNull { index, episode_ -> episode_.media?.downloadUrl?.let { it to index } }.toMap() ueMap = episodes.mapIndexedNotNull { index, episode_ -> episode_.media?.downloadUrl?.let { it to index } }.toMap()
// return true return true
// } }
// return false return false
// } }
// private fun redoFilter(list: List<Episode>? = null) { // private fun redoFilter(list: List<Episode>? = null) {
// if (enableFilter && !feed?.preferences?.filterString.isNullOrEmpty()) { // if (enableFilter && !feed?.preferences?.filterString.isNullOrEmpty()) {
@ -574,6 +584,10 @@ import java.util.concurrent.Semaphore
episodes.addAll(etmp) episodes.addAll(etmp)
ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index } ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index }
ueMap = episodes.mapIndexedNotNull { index, episode -> episode.media?.downloadUrl?.let { it to index } }.toMap() ueMap = episodes.mapIndexedNotNull { index, episode -> episode.media?.downloadUrl?.let { it to index } }.toMap()
withContext(Dispatchers.Main) {
vms.clear()
for (e in etmp) { vms.add(EpisodeVM(e)) }
}
if (onInit) { if (onInit) {
var hasNonMediaItems = false var hasNonMediaItems = false
for (item in episodes) { for (item in episodes) {

View File

@ -12,10 +12,9 @@ import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.storage.database.Feeds.getFeed import ac.mdiq.podcini.storage.database.Feeds.getFeed
import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences
import ac.mdiq.podcini.storage.database.Feeds.updateFeed
import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr import ac.mdiq.podcini.ui.compose.CustomTheme
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.podcini.util.Logd import ac.mdiq.podcini.util.Logd
@ -23,7 +22,6 @@ import android.app.Dialog
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.SharedPreferences import android.content.SharedPreferences
import android.graphics.LightingColorFilter
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.Spannable import android.text.Spannable
@ -34,15 +32,24 @@ import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import coil.load import coil.compose.AsyncImage
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -72,6 +79,13 @@ class OnlineFeedFragment : Fragment() {
private var feedUrl: String = "" private var feedUrl: String = ""
private lateinit var feedBuilder: FeedBuilder private lateinit var feedBuilder: FeedBuilder
private var showFeedDisplay by mutableStateOf(false)
private var showProgress by mutableStateOf(true)
private var autoDownloadChecked by mutableStateOf(false)
private var enableSubscribe by mutableStateOf(true)
private var enableEpisodes by mutableStateOf(true)
private var subButTextRes by mutableIntStateOf(R.string.subscribing_label)
private val feedId: Long private val feedId: Long
get() { get() {
if (feeds == null) return 0 if (feeds == null) return 0
@ -83,6 +97,7 @@ class OnlineFeedFragment : Fragment() {
@Volatile @Volatile
private var feeds: List<Feed>? = null private var feeds: List<Feed>? = null
private var feed by mutableStateOf<Feed?>(null)
private var selectedDownloadUrl: String? = null private var selectedDownloadUrl: String? = null
// private var downloader: Downloader? = null // private var downloader: Downloader? = null
private var username: String? = null private var username: String? = null
@ -97,25 +112,25 @@ class OnlineFeedFragment : Fragment() {
@OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
Logd(TAG, "fragment onCreateView") Logd(TAG, "fragment onCreateView")
_binding = OnlineFeedviewFragmentBinding.inflate(layoutInflater) _binding = OnlineFeedviewFragmentBinding.inflate(layoutInflater)
binding.closeButton.visibility = View.INVISIBLE
binding.card.setOnClickListener(null)
binding.card.setCardBackgroundColor(getColorFromAttr(requireContext(), com.google.android.material.R.attr.colorSurface))
displayUpArrow = parentFragmentManager.backStackEntryCount != 0 displayUpArrow = parentFragmentManager.backStackEntryCount != 0
if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW) if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW)
(activity as MainActivity).setupToolbarToggle(binding.toolbar, displayUpArrow) (activity as MainActivity).setupToolbarToggle(binding.toolbar, displayUpArrow)
feedUrl = requireArguments().getString(ARG_FEEDURL) ?: "" feedUrl = requireArguments().getString(ARG_FEEDURL) ?: ""
Logd(TAG, "feedUrl: $feedUrl") Logd(TAG, "feedUrl: $feedUrl")
feedBuilder = FeedBuilder(requireContext()) { message, details -> showErrorDialog(message, details) } feedBuilder = FeedBuilder(requireContext()) { message, details -> showErrorDialog(message, details) }
binding.mainView.setContent {
CustomTheme(requireContext()) {
MainView()
}
}
if (feedUrl.isEmpty()) { if (feedUrl.isEmpty()) {
Log.e(TAG, "feedUrl is null.") Log.e(TAG, "feedUrl is null.")
showNoPodcastFoundError() showNoPodcastFoundError()
} else { } else {
Logd(TAG, "Activity was started with url $feedUrl") Logd(TAG, "Activity was started with url $feedUrl")
setLoadingLayout() showProgress = true
// Remove subscribeonandroid.com from feed URL in order to subscribe to the actual feed URL // Remove subscribeonandroid.com from feed URL in order to subscribe to the actual feed URL
if (feedUrl.contains("subscribeonandroid.com")) feedUrl = feedUrl.replaceFirst("((www.)?(subscribeonandroid.com/))".toRegex(), "") if (feedUrl.contains("subscribeonandroid.com")) feedUrl = feedUrl.replaceFirst("((www.)?(subscribeonandroid.com/))".toRegex(), "")
if (savedInstanceState != null) { if (savedInstanceState != null) {
@ -127,11 +142,6 @@ class OnlineFeedFragment : Fragment() {
return binding.root return binding.root
} }
private fun setLoadingLayout() {
binding.progressBar.visibility = View.VISIBLE
binding.feedDisplayContainer.visibility = View.GONE
}
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
isPaused = false isPaused = false
@ -164,9 +174,10 @@ class OnlineFeedFragment : Fragment() {
val urlString = PodcastSearcherRegistry.lookupUrl1(url) val urlString = PodcastSearcherRegistry.lookupUrl1(url)
try { try {
feeds = getFeedList() feeds = getFeedList()
feedBuilder.startFeedBuilding(urlString, username, password) { feed, map -> feedBuilder.startFeedBuilding(urlString, username, password) { feed_, map ->
selectedDownloadUrl = feedBuilder.selectedDownloadUrl selectedDownloadUrl = feedBuilder.selectedDownloadUrl
showFeedInformation(feed, map) feed = feed_
showFeedInformation(feed_, map)
} }
} catch (e: FeedUrlNotFoundException) { tryToRetrieveFeedUrlBySearch(e) } catch (e: FeedUrlNotFoundException) { tryToRetrieveFeedUrlBySearch(e)
} catch (e: Throwable) { } catch (e: Throwable) {
@ -196,9 +207,10 @@ class OnlineFeedFragment : Fragment() {
Logd(TAG, "Successfully retrieve feed url") Logd(TAG, "Successfully retrieve feed url")
isFeedFoundBySearch = true isFeedFoundBySearch = true
feeds = getFeedList() feeds = getFeedList()
feedBuilder.startFeedBuilding(url, username, password) { feed, map -> feedBuilder.startFeedBuilding(url, username, password) { feed_, map ->
selectedDownloadUrl = feedBuilder.selectedDownloadUrl selectedDownloadUrl = feedBuilder.selectedDownloadUrl
showFeedInformation(feed, map) feed = feed_
showFeedInformation(feed_, map)
} }
} else { } else {
showNoPodcastFoundError() showNoPodcastFoundError()
@ -268,77 +280,140 @@ class OnlineFeedFragment : Fragment() {
* This method is executed on the GUI thread. * This method is executed on the GUI thread.
*/ */
@UnstableApi private fun showFeedInformation(feed: Feed, alternateFeedUrls: Map<String, String>) { @UnstableApi private fun showFeedInformation(feed: Feed, alternateFeedUrls: Map<String, String>) {
binding.progressBar.visibility = View.GONE showProgress = false
binding.feedDisplayContainer.visibility = View.VISIBLE // binding.feedDisplayContainer.visibility = View.VISIBLE
showFeedDisplay = true
if (isFeedFoundBySearch) { if (isFeedFoundBySearch) {
val resId = R.string.no_feed_url_podcast_found_by_search val resId = R.string.no_feed_url_podcast_found_by_search
Snackbar.make(binding.root, resId, Snackbar.LENGTH_LONG).show() Snackbar.make(binding.root, resId, Snackbar.LENGTH_LONG).show()
} }
binding.backgroundImage.colorFilter = LightingColorFilter(-0x7d7d7e, 0x000000)
binding.episodeLabel.setOnClickListener { showEpisodes(feed.episodes)} // if (alternateFeedUrls.isEmpty()) binding.alternateUrlsSpinner.visibility = View.GONE
if (!feed.imageUrl.isNullOrBlank()) { // else {
binding.coverImage.load(feed.imageUrl) { // binding.alternateUrlsSpinner.visibility = View.VISIBLE
placeholder(R.color.light_gray) // val alternateUrlsList: MutableList<String> = ArrayList()
error(R.mipmap.ic_launcher) // val alternateUrlsTitleList: MutableList<String?> = ArrayList()
// if (feed.downloadUrl != null) alternateUrlsList.add(feed.downloadUrl!!)
// alternateUrlsTitleList.add(feed.title)
// alternateUrlsList.addAll(alternateFeedUrls.keys)
// for (url in alternateFeedUrls.keys) {
// alternateUrlsTitleList.add(alternateFeedUrls[url])
// }
// val adapter: ArrayAdapter<String> = object : ArrayAdapter<String>(requireContext(),
// R.layout.alternate_urls_item, alternateUrlsTitleList) {
// override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
// // reusing the old view causes a visual bug on Android <= 10
// return super.getDropDownView(position, null, parent)
// }
// }
// adapter.setDropDownViewResource(R.layout.alternate_urls_dropdown_item)
// binding.alternateUrlsSpinner.adapter = adapter
// binding.alternateUrlsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
// override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
// selectedDownloadUrl = alternateUrlsList[position]
// }
// override fun onNothingSelected(parent: AdapterView<*>?) {}
// }
// }
handleUpdatedFeedStatus()
} }
@Composable
fun MainView() {
val textColor = MaterialTheme.colorScheme.onSurface
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
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)
}) {
if (showFeedDisplay) ConstraintLayout(modifier = Modifier.fillMaxWidth().height(120.dp).background(MaterialTheme.colorScheme.surface)) {
val (backgroundImage, coverImage, taColumn, buttons, closeButton) = createRefs()
if (false) Image(painter = painterResource(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",
Modifier.width(100.dp).height(100.dp).padding(start = 10.dp, end = 16.dp, bottom = 10.dp).constrainAs(coverImage) {
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
}.clickable(onClick = {}))
Column(Modifier.constrainAs(taColumn) {
top.linkTo(coverImage.top)
start.linkTo(coverImage.end) }) {
Text(feed?.title?:"No title", color = textColor, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(feed?.author?:"", color = textColor, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
binding.titleLabel.text = feed.title Row(Modifier.constrainAs(buttons) {
binding.authorLabel.text = feed.author start.linkTo(coverImage.end)
binding.txtvDescription.text = HtmlToPlainText.getPlainText(feed.description?:"") top.linkTo(taColumn.bottom)
binding.txtvTechInfo.text = "${feed.episodes.size} episodes\n" + end.linkTo(parent.end)
"${feed.mostRecentItem?.title ?: ""}\n\n" + }) {
"${feed.language} ${feed.type ?: ""} ${feed.lastUpdate ?: ""}\n" + Spacer(modifier = Modifier.weight(0.2f))
"${feed.link}\n" + if (enableSubscribe) Button(onClick = {
"${feed.downloadUrl}" if (feedInFeedlist()) (activity as MainActivity).loadFeedFragmentById(feedId, null)
binding.subscribeButton.setOnClickListener {
if (feedInFeedlist()) openFeed()
else { else {
lifecycleScope.launch { enableSubscribe = false
binding.progressBar.visibility = View.VISIBLE enableEpisodes = false
withContext(Dispatchers.IO) { feedBuilder.subscribe(feed) } CoroutineScope(Dispatchers.IO).launch {
feedBuilder.subscribe(feed!!)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
binding.progressBar.visibility = View.GONE enableSubscribe = true
didPressSubscribe = true didPressSubscribe = true
handleUpdatedFeedStatus() handleUpdatedFeedStatus()
} }
} }
} }
}) {
Text(stringResource(subButTextRes))
} }
if (feedSource != "VistaGuide" && isEnableAutodownload) Spacer(modifier = Modifier.weight(0.1f))
binding.autoDownloadCheckBox.isChecked = prefs!!.getBoolean(PREF_LAST_AUTO_DOWNLOAD, true) if (enableEpisodes && feed != null) Button(onClick = { showEpisodes(feed!!.episodes) }) {
Text(stringResource(R.string.episodes_label))
if (alternateFeedUrls.isEmpty()) binding.alternateUrlsSpinner.visibility = View.GONE
else {
binding.alternateUrlsSpinner.visibility = View.VISIBLE
val alternateUrlsList: MutableList<String> = ArrayList()
val alternateUrlsTitleList: MutableList<String?> = ArrayList()
if (feed.downloadUrl != null) alternateUrlsList.add(feed.downloadUrl!!)
alternateUrlsTitleList.add(feed.title)
alternateUrlsList.addAll(alternateFeedUrls.keys)
for (url in alternateFeedUrls.keys) {
alternateUrlsTitleList.add(alternateFeedUrls[url])
} }
val adapter: ArrayAdapter<String> = object : ArrayAdapter<String>(requireContext(), Spacer(modifier = Modifier.weight(0.2f))
R.layout.alternate_urls_item, alternateUrlsTitleList) { }
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { if (false) Icon(painter = painterResource(R.drawable.ic_close_white), contentDescription = null, modifier = Modifier
// reusing the old view causes a visual bug on Android <= 10 .constrainAs(closeButton) {
return super.getDropDownView(position, null, parent) top.linkTo(parent.top)
end.linkTo(parent.end)
})
}
Column {
// alternate_urls_spinner
if (feedSource != "VistaGuide" && isEnableAutodownload) Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = autoDownloadChecked, onCheckedChange = { autoDownloadChecked = it })
Text(text = stringResource(R.string.auto_download_label),
style = MaterialTheme.typography.bodyMedium.merge(), color = textColor,
modifier = Modifier.padding(start = 16.dp)
)
} }
} }
adapter.setDropDownViewResource(R.layout.alternate_urls_dropdown_item) val scrollState = rememberScrollState()
binding.alternateUrlsSpinner.adapter = adapter var numEpisodes by remember { mutableIntStateOf(feed?.episodes?.size?:0) }
binding.alternateUrlsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { LaunchedEffect(Unit) {
override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) { while (true) {
selectedDownloadUrl = alternateUrlsList[position] delay(1000)
} numEpisodes = feed?.episodes?.size?:0
override fun onNothingSelected(parent: AdapterView<*>?) {} }
}
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) {
Text("$numEpisodes episodes", color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 5.dp, bottom = 10.dp))
Text(stringResource(R.string.description_label), color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 5.dp, bottom = 4.dp))
Text(HtmlToPlainText.getPlainText(feed?.description?:""), color = textColor, style = MaterialTheme.typography.bodyMedium)
Text(feed?.mostRecentItem?.title ?: "", color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 5.dp, bottom = 4.dp))
Text("${feed?.language?:""} ${feed?.type ?: ""} ${feed?.lastUpdate ?: ""}", color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 5.dp, bottom = 4.dp))
Text(feed?.link?:"", color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 5.dp, bottom = 4.dp))
Text(feed?.downloadUrl?:"", color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 5.dp, bottom = 4.dp))
} }
} }
handleUpdatedFeedStatus()
} }
@UnstableApi private fun openFeed() {
(activity as MainActivity).loadFeedFragmentById(feedId, null)
} }
@UnstableApi private fun showEpisodes(episodes: MutableList<Episode>) { @UnstableApi private fun showEpisodes(episodes: MutableList<Episode>) {
@ -364,12 +439,12 @@ class OnlineFeedFragment : Fragment() {
// binding.subscribeButton.isEnabled = false // binding.subscribeButton.isEnabled = false
// } // }
dli.isDownloadingEpisode(selectedDownloadUrl!!) -> { dli.isDownloadingEpisode(selectedDownloadUrl!!) -> {
binding.subscribeButton.isEnabled = false enableSubscribe = false
binding.subscribeButton.setText(R.string.subscribing_label) subButTextRes = R.string.subscribing_label
} }
feedInFeedlist() -> { feedInFeedlist() -> {
binding.subscribeButton.isEnabled = true enableSubscribe = true
binding.subscribeButton.setText(R.string.open) subButTextRes = R.string.open
if (didPressSubscribe) { if (didPressSubscribe) {
didPressSubscribe = false didPressSubscribe = false
val feed1 = getFeed(feedId, true)?: return val feed1 = getFeed(feedId, true)?: return
@ -379,7 +454,7 @@ class OnlineFeedFragment : Fragment() {
feed1.preferences!!.prefStreamOverDownload = true feed1.preferences!!.prefStreamOverDownload = true
feed1.preferences!!.autoDownload = false feed1.preferences!!.autoDownload = false
} else if (isEnableAutodownload) { } else if (isEnableAutodownload) {
val autoDownload = binding.autoDownloadCheckBox.isChecked val autoDownload = autoDownloadChecked
feed1.preferences!!.autoDownload = autoDownload feed1.preferences!!.autoDownload = autoDownload
val editor = prefs!!.edit() val editor = prefs!!.edit()
editor.putBoolean(PREF_LAST_AUTO_DOWNLOAD, autoDownload) editor.putBoolean(PREF_LAST_AUTO_DOWNLOAD, autoDownload)
@ -390,13 +465,12 @@ class OnlineFeedFragment : Fragment() {
feed1.preferences!!.password = password feed1.preferences!!.password = password
} }
persistFeedPreferences(feed1) persistFeedPreferences(feed1)
openFeed() // openFeed()
} }
} }
else -> { else -> {
binding.subscribeButton.isEnabled = true enableSubscribe = true
binding.subscribeButton.setText(R.string.subscribe_label) subButTextRes = R.string.subscribing_label
if (feedSource != "VistaGuide" && isEnableAutodownload) binding.autoDownloadCheckBox.visibility = View.VISIBLE
} }
} }
} }

View File

@ -26,13 +26,10 @@ import ac.mdiq.podcini.storage.model.PlayQueue
import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.*
import ac.mdiq.podcini.ui.compose.EpisodeLazyColumn
import ac.mdiq.podcini.ui.compose.InforBar
import ac.mdiq.podcini.ui.compose.queueChanged
import ac.mdiq.podcini.ui.dialog.ConfirmationDialog import ac.mdiq.podcini.ui.dialog.ConfirmationDialog
import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog
import ac.mdiq.podcini.ui.utils.EmptyViewHandler import ac.mdiq.podcini.ui.utils.EmptyViewHandler
@ -77,7 +74,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
import com.leinardi.android.speeddial.SpeedDialActionItem
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -113,7 +109,8 @@ import kotlin.math.max
private lateinit var queues: List<PlayQueue> private lateinit var queues: List<PlayQueue>
private var displayUpArrow = false private var displayUpArrow = false
private val queueItems = mutableStateListOf<Episode>() private val queueItems = mutableListOf<Episode>()
private val vms = mutableStateListOf<EpisodeVM>()
private var showBin by mutableStateOf(false) private var showBin by mutableStateOf(false)
@ -183,7 +180,7 @@ import kotlin.math.max
swipeActions = SwipeActions(this, TAG) swipeActions = SwipeActions(this, TAG)
swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name)) swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name))
swipeActionsBin = SwipeActions(this, TAG) swipeActionsBin = SwipeActions(this, TAG+".Bin")
swipeActionsBin.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name)) swipeActionsBin.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name))
binding.lazyColumn.setContent { binding.lazyColumn.setContent {
@ -199,7 +196,7 @@ import kotlin.math.max
if (rightActionStateBin.value == null) swipeActionsBin.showDialog() if (rightActionStateBin.value == null) swipeActionsBin.showDialog()
else rightActionStateBin.value?.performAction(episode, this@QueuesFragment, swipeActionsBin.filter ?: EpisodeFilter()) else rightActionStateBin.value?.performAction(episode, this@QueuesFragment, swipeActionsBin.filter ?: EpisodeFilter())
} }
EpisodeLazyColumn(activity as MainActivity, episodes = queueItems, leftSwipeCB = { leftCB(it) }, rightSwipeCB = { rightCB(it) }) EpisodeLazyColumn(activity as MainActivity, vms = vms, leftSwipeCB = { leftCB(it) }, rightSwipeCB = { rightCB(it) })
} }
} else { } else {
Column { Column {
@ -212,7 +209,7 @@ import kotlin.math.max
if (rightActionState.value == null) swipeActions.showDialog() if (rightActionState.value == null) swipeActions.showDialog()
else rightActionState.value?.performAction(episode, this@QueuesFragment, swipeActions.filter ?: EpisodeFilter()) else rightActionState.value?.performAction(episode, this@QueuesFragment, swipeActions.filter ?: EpisodeFilter())
} }
EpisodeLazyColumn(activity as MainActivity, episodes = queueItems, leftSwipeCB = { leftCB(it) }, rightSwipeCB = { rightCB(it) }) EpisodeLazyColumn(activity as MainActivity, vms = vms, leftSwipeCB = { leftCB(it) }, rightSwipeCB = { rightCB(it) })
} }
} }
} }
@ -317,11 +314,16 @@ import kotlin.math.max
if (showBin) return if (showBin) return
when (event.action) { when (event.action) {
FlowEvent.QueueEvent.Action.ADDED -> { FlowEvent.QueueEvent.Action.ADDED -> {
if (event.episodes.isNotEmpty() && !curQueue.contains(event.episodes[0])) queueItems.addAll(event.episodes) if (event.episodes.isNotEmpty() && !curQueue.contains(event.episodes[0])) {
queueItems.addAll(event.episodes)
for (e in event.episodes) vms.add(EpisodeVM(e))
}
} }
FlowEvent.QueueEvent.Action.SET_QUEUE, FlowEvent.QueueEvent.Action.SORTED -> { FlowEvent.QueueEvent.Action.SET_QUEUE, FlowEvent.QueueEvent.Action.SORTED -> {
queueItems.clear() queueItems.clear()
queueItems.addAll(event.episodes) queueItems.addAll(event.episodes)
vms.clear()
for (e in event.episodes) vms.add(EpisodeVM(e))
} }
FlowEvent.QueueEvent.Action.REMOVED, FlowEvent.QueueEvent.Action.IRREVERSIBLE_REMOVED -> { FlowEvent.QueueEvent.Action.REMOVED, FlowEvent.QueueEvent.Action.IRREVERSIBLE_REMOVED -> {
if (event.episodes.isNotEmpty()) { if (event.episodes.isNotEmpty()) {
@ -329,8 +331,10 @@ import kotlin.math.max
val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, e.id) val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, e.id)
if (pos >= 0) { if (pos >= 0) {
Logd(TAG, "removing episode $pos ${queueItems[pos].title} $e") Logd(TAG, "removing episode $pos ${queueItems[pos].title} $e")
queueItems[pos].stopMonitoring.value = true // queueItems[pos].stopMonitoring.value = true
queueItems.removeAt(pos) queueItems.removeAt(pos)
vms[pos].stopMonitoring()
vms.removeAt(pos)
} else { } else {
Log.e(TAG, "Trying to remove item non-existent from queue ${e.id} ${e.title}") Log.e(TAG, "Trying to remove item non-existent from queue ${e.id} ${e.title}")
continue continue
@ -344,6 +348,7 @@ import kotlin.math.max
} }
FlowEvent.QueueEvent.Action.CLEARED -> { FlowEvent.QueueEvent.Action.CLEARED -> {
queueItems.clear() queueItems.clear()
vms.clear()
} }
FlowEvent.QueueEvent.Action.MOVED, FlowEvent.QueueEvent.Action.DELETED_MEDIA -> return FlowEvent.QueueEvent.Action.MOVED, FlowEvent.QueueEvent.Action.DELETED_MEDIA -> return
} }
@ -357,7 +362,7 @@ import kotlin.math.max
private fun onPlayEvent(event: FlowEvent.PlayEvent) { private fun onPlayEvent(event: FlowEvent.PlayEvent) {
val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, event.episode.id) val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, event.episode.id)
Logd(TAG, "onPlayEvent action: ${event.action} pos: $pos ${event.episode.title}") Logd(TAG, "onPlayEvent action: ${event.action} pos: $pos ${event.episode.title}")
// if (pos >= 0) queueItems[pos].isPlayingState.value = event.isPlaying() if (pos >= 0) vms[pos].isPlayingState = event.isPlaying()
} }
private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) {
@ -366,7 +371,10 @@ import kotlin.math.max
for (url in event.urls) { for (url in event.urls) {
// if (!event.isCompleted(url)) continue // if (!event.isCompleted(url)) continue
val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(queueItems.toList(), url) val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(queueItems.toList(), url)
if (pos >= 0) queueItems[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal if (pos >= 0) {
// queueItems[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
vms[pos].downloadState = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
}
} }
} }
@ -417,6 +425,7 @@ import kotlin.math.max
Logd(TAG, "onDestroyView") Logd(TAG, "onDestroyView")
_binding = null _binding = null
queueItems.clear() queueItems.clear()
vms.clear()
toolbar.setOnMenuItemClickListener(null) toolbar.setOnMenuItemClickListener(null)
toolbar.setOnLongClickListener(null) toolbar.setOnLongClickListener(null)
super.onDestroyView() super.onDestroyView()
@ -662,12 +671,14 @@ import kotlin.math.max
while (curQueue.name.isEmpty()) runBlocking { delay(100) } while (curQueue.name.isEmpty()) runBlocking { delay(100) }
if (queueItems.isNotEmpty()) emptyViewHandler.hide() if (queueItems.isNotEmpty()) emptyViewHandler.hide()
queueItems.clear() queueItems.clear()
vms.clear()
if (showBin) queueItems.addAll(realm.query(Episode::class, "id IN $0", curQueue.idsBinList) if (showBin) queueItems.addAll(realm.query(Episode::class, "id IN $0", curQueue.idsBinList)
.find().sortedByDescending { curQueue.idsBinList.indexOf(it.id) }) .find().sortedByDescending { curQueue.idsBinList.indexOf(it.id) })
else { else {
curQueue.episodes.clear() curQueue.episodes.clear()
queueItems.addAll(curQueue.episodes) queueItems.addAll(curQueue.episodes)
} }
for (e in queueItems) vms.add(EpisodeVM(e))
Logd(TAG, "loadCurQueue() curQueue.episodes: ${curQueue.episodes.size}") Logd(TAG, "loadCurQueue() curQueue.episodes: ${curQueue.episodes.size}")
// if (restoreScrollPosition) recyclerView.restoreScrollPosition(TAG) // if (restoreScrollPosition) recyclerView.restoreScrollPosition(TAG)

View File

@ -10,18 +10,19 @@ import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.handler.MenuItemUtils import ac.mdiq.podcini.ui.actions.MenuItemUtils
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.EpisodeLazyColumn import ac.mdiq.podcini.ui.compose.EpisodeLazyColumn
import ac.mdiq.podcini.ui.compose.EpisodeVM
import ac.mdiq.podcini.ui.dialog.CustomFeedNameDialog import ac.mdiq.podcini.ui.dialog.CustomFeedNameDialog
import ac.mdiq.podcini.ui.dialog.RemoveFeedDialog import ac.mdiq.podcini.ui.dialog.RemoveFeedDialog
import ac.mdiq.podcini.ui.dialog.TagSettingsDialog import ac.mdiq.podcini.ui.dialog.TagSettingsDialog
import ac.mdiq.podcini.ui.utils.EmptyViewHandler import ac.mdiq.podcini.ui.utils.EmptyViewHandler
import ac.mdiq.podcini.ui.view.SquareImageView import ac.mdiq.podcini.ui.view.SquareImageView
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.podcini.util.Logd
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
@ -68,7 +69,8 @@ class SearchFragment : Fragment() {
private lateinit var chip: Chip private lateinit var chip: Chip
private lateinit var automaticSearchDebouncer: Handler private lateinit var automaticSearchDebouncer: Handler
private val results = mutableStateListOf<Episode>() private val results = mutableListOf<Episode>()
private val vms = mutableStateListOf<EpisodeVM>()
private var lastQueryChange: Long = 0 private var lastQueryChange: Long = 0
private var isOtherViewInFoucus = false private var isOtherViewInFoucus = false
@ -86,7 +88,7 @@ class SearchFragment : Fragment() {
binding.lazyColumn.setContent { binding.lazyColumn.setContent {
CustomTheme(requireContext()) { CustomTheme(requireContext()) {
EpisodeLazyColumn(activity as MainActivity, episodes = results) EpisodeLazyColumn(activity as MainActivity, vms = vms)
} }
} }
@ -137,6 +139,7 @@ class SearchFragment : Fragment() {
Logd(TAG, "onDestroyView") Logd(TAG, "onDestroyView")
_binding = null _binding = null
results.clear() results.clear()
vms.clear()
super.onDestroyView() super.onDestroyView()
} }
@ -229,7 +232,10 @@ class SearchFragment : Fragment() {
private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) {
for (url in event.urls) { for (url in event.urls) {
val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(results, url) val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(results, url)
if (pos >= 0) results[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal if (pos >= 0) {
// results[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
vms[pos].downloadState = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
}
} }
} }
@ -252,6 +258,8 @@ class SearchFragment : Fragment() {
val first_ = results_.first!!.toMutableList() val first_ = results_.first!!.toMutableList()
results.clear() results.clear()
results.addAll(first_) results.addAll(first_)
vms.clear()
for (e in first_) { vms.add(EpisodeVM(e)) }
} }
if (requireArguments().getLong(ARG_FEED, 0) == 0L) { if (requireArguments().getLong(ARG_FEED, 0) == 0L) {
if (results_.second != null) adapterFeeds.updateData(results_.second!!) if (results_.second != null) adapterFeeds.updateData(results_.second!!)

View File

@ -5,11 +5,11 @@ import ac.mdiq.podcini.databinding.FragmentSearchResultsBinding
import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult
import ac.mdiq.podcini.net.feed.discovery.PodcastSearcher import ac.mdiq.podcini.net.feed.discovery.PodcastSearcher
import ac.mdiq.podcini.net.feed.discovery.PodcastSearcherRegistry import ac.mdiq.podcini.net.feed.discovery.PodcastSearcherRegistry
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.OnlineFeedItem import ac.mdiq.podcini.ui.compose.OnlineFeedItem
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.formatNumber
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
@ -20,8 +20,10 @@ import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -31,15 +33,12 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import coil.compose.AsyncImage
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -168,8 +167,18 @@ class SearchResultsFragment : Fragment() {
private fun search(query: String) { private fun search(query: String) {
showOnlyProgressBar() showOnlyProgressBar()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val feeds = getFeedList()
fun feedId(r: PodcastSearchResult): Long {
for (f in feeds) {
if (f.downloadUrl == r.feedUrl) return f.id
}
return 0L
}
try { try {
val result = searchProvider?.search(query) ?: listOf() val result = searchProvider?.search(query) ?: listOf()
for (r in result) {
r.feedId = feedId(r)
}
searchResults.clear() searchResults.clear()
searchResults.addAll(result) searchResults.addAll(result)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {

View File

@ -847,9 +847,12 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Column(Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface) Column(Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
.combinedClickable(onClick = { .combinedClickable(onClick = {
Logd(TAG, "clicked: ${feed.title}") Logd(TAG, "clicked: ${feed.title}")
if (!feed.isBuilding) {
if (selectMode) toggleSelected() if (selectMode) toggleSelected()
else (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id)) else (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id))
}
}, onLongClick = { }, onLongClick = {
if (!feed.isBuilding) {
selectMode = !selectMode selectMode = !selectMode
isSelected = selectMode isSelected = selectMode
if (selectMode) { if (selectMode) {
@ -859,6 +862,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
selectedSize = 0 selectedSize = 0
longPressIndex = -1 longPressIndex = -1
} }
}
Logd(TAG, "long clicked: ${feed.title}") Logd(TAG, "long clicked: ${feed.title}")
})) { })) {
val textColor = MaterialTheme.colorScheme.onSurface val textColor = MaterialTheme.colorScheme.onSurface
@ -914,16 +918,21 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
modifier = Modifier.width(80.dp).height(80.dp) modifier = Modifier.width(80.dp).height(80.dp)
.clickable(onClick = { .clickable(onClick = {
Logd(TAG, "icon clicked!") Logd(TAG, "icon clicked!")
if (!feed.isBuilding) {
if (selectMode) toggleSelected() if (selectMode) toggleSelected()
else (activity as MainActivity).loadChildFragment(FeedInfoFragment.newInstance(feed)) else (activity as MainActivity).loadChildFragment(FeedInfoFragment.newInstance(feed))
}
}) })
) )
val textColor = MaterialTheme.colorScheme.onSurface val textColor = MaterialTheme.colorScheme.onSurface
Column(Modifier.weight(1f).padding(start = 10.dp).combinedClickable(onClick = { Column(Modifier.weight(1f).padding(start = 10.dp).combinedClickable(onClick = {
Logd(TAG, "clicked: ${feed.title}") Logd(TAG, "clicked: ${feed.title}")
if (!feed.isBuilding) {
if (selectMode) toggleSelected() if (selectMode) toggleSelected()
else (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id)) else (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id))
}
}, onLongClick = { }, onLongClick = {
if (!feed.isBuilding) {
selectMode = !selectMode selectMode = !selectMode
isSelected = selectMode isSelected = selectMode
if (selectMode) { if (selectMode) {
@ -934,6 +943,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
selectedSize = 0 selectedSize = 0
longPressIndex = -1 longPressIndex = -1
} }
}
Logd(TAG, "long clicked: ${feed.title}") Logd(TAG, "long clicked: ${feed.title}")
})) { })) {
Text(feed.title ?: "No title", color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis, Text(feed.title ?: "No title", color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis,
@ -944,9 +954,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
color = textColor, style = MaterialTheme.typography.bodyMedium) color = textColor, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
var feedSortInfo by remember { mutableStateOf(feed.sortInfo) } var feedSortInfo by remember { mutableStateOf(feed.sortInfo) }
LaunchedEffect(feedSorted) { LaunchedEffect(feedSorted) { feedSortInfo = feed.sortInfo }
feedSortInfo = feed.sortInfo
}
Text(feedSortInfo, color = textColor, style = MaterialTheme.typography.bodyMedium) Text(feedSortInfo, color = textColor, style = MaterialTheme.typography.bodyMedium)
} }
} }

View File

@ -3,7 +3,7 @@ package ac.mdiq.podcini.ui.view
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.utils.NetworkUtils import ac.mdiq.podcini.net.utils.NetworkUtils
import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.ui.actions.handler.MenuItemUtils import ac.mdiq.podcini.ui.actions.MenuItemUtils
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.utils.ShownotesCleaner
import ac.mdiq.podcini.util.* import ac.mdiq.podcini.util.*

View File

@ -21,10 +21,10 @@
app:navigationContentDescription="@string/toolbar_back_button_content_description" app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" /> app:navigationIcon="?homeAsUpIndicator" />
<androidx.compose.ui.platform.ComposeView <!-- <androidx.compose.ui.platform.ComposeView-->
android:id="@+id/infobar" <!-- android:id="@+id/infobar"-->
android:layout_width="match_parent" <!-- android:layout_width="match_parent"-->
android:layout_height="wrap_content"/> <!-- android:layout_height="wrap_content"/>-->
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>

View File

@ -20,10 +20,10 @@
app:navigationContentDescription="@string/toolbar_back_button_content_description" app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" /> app:navigationIcon="?homeAsUpIndicator" />
<androidx.compose.ui.platform.ComposeView <!-- <androidx.compose.ui.platform.ComposeView-->
android:id="@+id/infobar" <!-- android:id="@+id/infobar"-->
android:layout_width="match_parent" <!-- android:layout_width="match_parent"-->
android:layout_height="wrap_content"/> <!-- android:layout_height="wrap_content"/>-->
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>

View File

@ -25,10 +25,10 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"/> android:layout_height="wrap_content"/>
<androidx.compose.ui.platform.ComposeView <!-- <androidx.compose.ui.platform.ComposeView-->
android:id="@+id/infobar" <!-- android:id="@+id/infobar"-->
android:layout_width="match_parent" <!-- android:layout_width="match_parent"-->
android:layout_height="wrap_content"/> <!-- android:layout_height="wrap_content"/>-->
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>

View File

@ -25,232 +25,9 @@
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<LinearLayout <androidx.compose.ui.platform.ComposeView
xmlns:tools="http://schemas.android.com/tools" android:id="@+id/mainView"
android:id="@+id/transparentBackground"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="wrap_content"/>
<androidx.cardview.widget.CardView
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardCornerRadius="8dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
style="?android:attr/progressBarStyle" />
<LinearLayout
android:id="@+id/feed_display_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="@dimen/feeditemlist_header_height"
android:layout_marginBottom="12dp"
android:background="@color/feed_image_bg">
<ImageView
android:id="@+id/backgroundImage"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
<ImageView
android:id="@+id/coverImage"
android:layout_width="@dimen/thumbnail_length_onlinefeedview"
android:layout_height="@dimen/thumbnail_length_onlinefeedview"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_centerVertical="true"
android:layout_marginBottom="12dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="20dp"
android:background="@drawable/bg_rounded_corners"
android:clipToOutline="true"
android:importantForAccessibility="no"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/titleLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_marginBottom="5dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="10dp"
android:layout_marginRight="24dp"
android:layout_marginEnd="24dp"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:layout_toRightOf="@id/coverImage"
android:layout_toEndOf="@id/coverImage"
android:ellipsize="end"
android:maxLines="2"
android:shadowColor="@color/black"
android:shadowRadius="3"
android:textColor="@color/white"
android:textSize="18sp"
android:textFontWeight="800"
tools:text="Podcast title" />
<TextView
android:id="@+id/author_label"
android:layout_width="0dip"
android:layout_height="wrap_content"
android:layout_below="@id/titleLabel"
android:layout_marginBottom="8dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginRight="16dp"
android:layout_marginEnd="16dp"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:layout_toRightOf="@id/coverImage"
android:layout_toEndOf="@id/coverImage"
android:ellipsize="end"
android:lines="1"
android:shadowColor="@color/black"
android:shadowRadius="3"
android:textColor="@color/white"
android:textSize="@dimen/text_size_small"
tools:text="Podcast author" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/author_label"
android:layout_toRightOf="@id/coverImage"
android:layout_toEndOf="@id/coverImage"
android:orientation="horizontal"
android:gravity="center">
<Button
android:id="@+id/subscribeButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginStart="10dp"
android:layout_marginRight="16dp"
android:layout_marginEnd="16dp"
android:text="@string/subscribe_label" />
<Button
android:id="@+id/episodeLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginRight="10dp"
android:layout_marginEnd="10dp"
android:text="@string/episodes_label"/>
</LinearLayout>
<ImageButton
android:id="@+id/closeButton"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_alignParentRight="true"
android:layout_marginTop="12dp"
android:layout_marginRight="12dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_close_white" />
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:orientation="vertical">
<Spinner
android:id="@+id/alternate_urls_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:dropDownWidth="match_parent"
android:padding="8dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="@dimen/text_size_micro" />
<CheckBox
android:id="@+id/autoDownloadCheckBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="left"
android:checked="true"
android:layout_marginTop="8dp"
android:text="@string/auto_download_label"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:scrollbars="vertical">
<LinearLayout
android:id="@+id/online_feed_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="16dp"
android:paddingLeft="20dp"
android:paddingRight="20dp"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/description_label"
style="@style/TextAppearance.Material3.TitleMedium" />
<TextView
android:id="@+id/txtvDescription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="15dp"
android:ellipsize="end"
android:lineHeight="20dp"
style="@style/Podcini.TextView.ListItemBody"
tools:text="@string/design_time_lorem_ipsum" />
<TextView
android:id="@+id/txtvTechInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:ellipsize="end"
style="@style/Podcini.TextView.ListItemBody"
tools:text="@string/design_time_lorem_ipsum" />
</LinearLayout>
</ScrollView>
</LinearLayout>
</FrameLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</LinearLayout> </LinearLayout>

View File

@ -1,3 +1,20 @@
# 6.9.0
* re-worked Compose states handling for Episodes lists, might have issues related bugs
* opening OnlineFeed of Youtube channel is made more responsive with more background processing on constructing episodes
* you can subscribe at any time
* if you open Episodes, you will see the episodes constructed at the moment
* episodes limit for Youtube channel, playlist and YTMusicplaylist is now at 1000
* in OnlineFeed view, after subscribe, the FeedEpisode view does not open automatically, presenting options to open it or return to the SearchResults view
* in online SearchResults, if an item is already subscribed, a check mark appears on the cover image, and when clicked, FeedEpisodes view is opened.
* added FlowEvent posting when adding shared youtube media or reserved online episodes
* receiving shared contents from Youtube now should support hostnames youtube.com, www.youtube.com, m.youtube.com, music.youtube.com, and youtu.be
* when reserving episodes from a Youtube channel list, like receiving shared media from Youtube, you can choose for "audio only"
* the reserved episodes will be added into synthetic podcast of either "Youtube Syndicate" or "Youtube Syndicate Audio" rather than "Misc Syndicate" for other types of episodes
* fixed independence of swipe actions in Queues Bin
* OnlineFeed is in Jetpack Compose
* in SharedReceiver activity, added error notice for shared Youtube media
# 6.8.7 # 6.8.7
* clear history really clears it * clear history really clears it
@ -27,7 +44,7 @@
* migrated mostly the following view to Jetpack Compose: * migrated mostly the following view to Jetpack Compose:
* Queues, AudioPlayer, Subscriptions, FeedInfo, EpisodeInfo, FeedEpisodes, AllEpisodes, History, Search, and OnlineFeed * Queues, AudioPlayer, Subscriptions, FeedInfo, EpisodeInfo, FeedEpisodes, AllEpisodes, History, Search, and OnlineFeed
* to counter this nasty issue that Google can't fix over 2 years: ForegroundServiceStartNotAllowedException * to counter this nasty issue that Google can't fix over 2 years: ForegroundServiceStartNotAllowedException
* for this and near future releases, target SDK is set to 30 (Android 12), though built with SDK 35 and tested on Android 14 * for this and near future releases, target SDK is set to 30 (Android 11), though built with SDK 35 and tested on Android 14
* supposedly notification will not disappear and play will not stop through a playlist * supposedly notification will not disappear and play will not stop through a playlist
* please voice any irregularities you may see * please voice any irregularities you may see
* on episode lists, show duration on the top row * on episode lists, show duration on the top row

View File

@ -0,0 +1,16 @@
Version 6.9.0
* re-worked Compose states handling for Episodes lists, might have issues related bugs
* opening OnlineFeed of Youtube channel is made more responsive with more background processing on constructing episodes
* you can subscribe at any time
* if you open Episodes, you will see the episodes constructed at the moment
* episodes limit for Youtube channel, playlist and YTMusicplaylist is now at 1000
* in OnlineFeed view, after subscribe, the FeedEpisode view does not open automatically, presenting options to open it or return to the SearchResults view
* in online SearchResults, if an item is already subscribed, a check mark appears on the cover image, and when clicked, FeedEpisodes view is opened.
* added FlowEvent posting when adding shared youtube media or reserved online episodes
* receiving shared contents from Youtube now should support hostnames youtube.com, www.youtube.com, m.youtube.com, music.youtube.com, and youtu.be
* when reserving episodes from a Youtube channel list, like receiving shared media from Youtube, you can choose for "audio only"
* the reserved episodes will be added into synthetic podcast of either "Youtube Syndicate" or "Youtube Syndicate Audio" rather than "Misc Syndicate" for other types of episodes
* fixed independence of swipe actions in Queues Bin
* OnlineFeed is in Jetpack Compose
* in SharedReceiver activity, added error notice for shared Youtube media