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)
#### For Podcini to show up on car's HUD with Android Auto, please read AnroidAuto.md for instructions.
#### If you need to cast to an external speaker, you should install the "play" apk, not the "free" apk, that's about the difference between the two.
#### Since version 6.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.
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
* 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
* accepted host names include: youtube.com, www.youtube.com, m.youtube.com, music.youtube.com, and youtu.be
### Instant (or Wifi) sync

View File

@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020264
versionName "6.8.7"
versionCode 3020265
versionName "6.9.0"
applicationId "ac.mdiq.podcini.R"
def commit = ""

View File

@ -25,10 +25,7 @@ import android.content.Context
import android.util.Log
import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.types.RealmList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import org.jsoup.Jsoup
import java.io.File
import java.io.IOException
@ -50,43 +47,51 @@ 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") }
selectedDownloadUrl = prepareUrl(url)
val feed_ = Feed(selectedDownloadUrl, null)
feed_.isBuilding = true
feed_.id = Feed.newId()
feed_.type = Feed.FeedType.YOUTUBE.name
feed_.hasVideoMedia = true
feed_.fileUrl = File(feedfilePath, getFeedfileName(feed_)).toString()
val eList: 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
feed_.title = playlistInfo.name
feed_.description = playlistInfo.description?.content ?: ""
feed_.author = playlistInfo.uploaderName
feed_.imageUrl = if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null
feed_.episodes = realmListOf()
var infoItems = playlistInfo.relatedItems
var nextPage = playlistInfo.nextPage
Logd(TAG, "infoItems: ${infoItems.size}")
while (infoItems.isNotEmpty()) {
for (r in infoItems) {
Logd(TAG, "startFeedBuilding relatedItem: $r")
if (r.infoType != InfoItem.InfoType.STREAM) continue
val e = episodeFromStreamInfoItem(r)
e.feed = feed_
e.feedId = feed_.id
eList.add(e)
}
if (nextPage == null || eList.size > 500) break
try {
val page = PlaylistInfo.getMoreItems(service, url, nextPage) ?: break
nextPage = page.nextPage
infoItems = page.items
Logd(TAG, "more infoItems: ${infoItems.size}")
} catch (e: Throwable) {
Logd(TAG, "PlaylistInfo.getMoreItems error: ${e.message}")
withContext(Dispatchers.Main) { showError(e.message, "") }
break
CoroutineScope(Dispatchers.IO).launch {
while (infoItems.isNotEmpty()) {
eList.clear()
for (r in infoItems) {
Logd(TAG, "startFeedBuilding relatedItem: $r")
if (r.infoType != InfoItem.InfoType.STREAM) continue
val e = episodeFromStreamInfoItem(r)
e.feed = feed_
e.feedId = feed_.id
eList.add(e)
}
if (nextPage == null || feed_.episodes.size > 1000) break
try {
val page = PlaylistInfo.getMoreItems(service, url, nextPage) ?: break
nextPage = page.nextPage
infoItems = page.items
Logd(TAG, "more infoItems: ${infoItems.size}")
} catch (e: Throwable) {
Logd(TAG, "PlaylistInfo.getMoreItems error: ${e.message}")
withContext(Dispatchers.Main) { showError(e.message, "") }
break
}
feed_.episodes.addAll(eList)
}
feed_.isBuilding = false
}
feed_.episodes = eList
withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) }
} else {
val channelInfo = ChannelInfo.getInfo(service, url)
@ -102,32 +107,37 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
feed_.description = channelInfo.description
feed_.author = channelInfo.parentChannelName
feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null
feed_.episodes = realmListOf()
var infoItems = channelTabInfo.relatedItems
var nextPage = channelTabInfo.nextPage
Logd(TAG, "infoItems: ${infoItems.size}")
while (infoItems.isNotEmpty()) {
for (r in infoItems) {
Logd(TAG, "startFeedBuilding relatedItem: $r")
if (r.infoType != InfoItem.InfoType.STREAM) continue
val e = episodeFromStreamInfoItem(r as StreamInfoItem)
e.feed = feed_
e.feedId = feed_.id
eList.add(e)
}
if (nextPage == null || eList.size > 200) break
try {
val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs.first(), nextPage)
nextPage = page.nextPage
infoItems = page.items
Logd(TAG, "more infoItems: ${infoItems.size}")
} catch (e: Throwable) {
Logd(TAG, "ChannelTabInfo.getMoreItems error: ${e.message}")
withContext(Dispatchers.Main) { showError(e.message, "") }
break
CoroutineScope(Dispatchers.IO).launch {
while (infoItems.isNotEmpty()) {
eList.clear()
for (r in infoItems) {
Logd(TAG, "startFeedBuilding relatedItem: $r")
if (r.infoType != InfoItem.InfoType.STREAM) continue
val e = episodeFromStreamInfoItem(r as StreamInfoItem)
e.feed = feed_
e.feedId = feed_.id
eList.add(e)
}
if (nextPage == null || feed_.episodes.size > 1000) break
try {
val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs.first(), nextPage)
nextPage = page.nextPage
infoItems = page.items
Logd(TAG, "more infoItems: ${infoItems.size}")
} catch (e: Throwable) {
Logd(TAG, "ChannelTabInfo.getMoreItems error: ${e.message}")
withContext(Dispatchers.Main) { showError(e.message, "") }
break
}
feed_.episodes.addAll(eList)
}
feed_.isBuilding = false
}
feed_.episodes = eList
withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) }
} catch (e: Throwable) {
Logd(TAG, "startFeedBuilding error1 ${e.message}")
@ -202,8 +212,11 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
val destinationFile = File(destination)
return try {
val feed = Feed(selectedDownloadUrl, null)
feed.isBuilding = true
feed.fileUrl = destination
FeedHandler().parseFeed(feed)
val result = FeedHandler().parseFeed(feed)
feed.isBuilding = false
result
} catch (e: FeedHandler.UnsupportedFeedtypeException) {
Logd(TAG, "Unsupported feed type detected")
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) {
while (feed.isBuilding) {
runBlocking { delay(200) }
}
feed.id = 0L
for (item in feed.episodes) {
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.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 org.json.JSONException
import org.json.JSONObject
@ -18,6 +21,9 @@ class PodcastSearchResult private constructor(
val subscriberCount: Int,
val source: String) {
// feedId will be positive if already subscribed
var feedId by mutableLongStateOf(0L)
companion object {
fun dummy(): PodcastSearchResult {
return PodcastSearchResult("", "", "", "", 0, "", -1, "dummy")

View File

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

View File

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

View File

@ -135,15 +135,9 @@ class Episode : RealmObject {
return field
}
@Ignore
val downloadState = mutableIntStateOf(if (media?.downloaded == true) DownloadStatus.State.COMPLETED.ordinal else DownloadStatus.State.UNKNOWN.ordinal)
@Ignore
val isRemote = mutableStateOf(false)
@Ignore
val stopMonitoring = mutableStateOf(false)
constructor() {
this.playState = PlayState.UNPLAYED.code
}
@ -162,13 +156,6 @@ class Episode : RealmObject {
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) {
if (other.imageUrl != null) this.imageUrl = other.imageUrl
if (other.title != null) title = other.title
@ -296,10 +283,6 @@ class Episode : RealmObject {
if (isFavorite != other.isFavorite) return false
if (isInProgress != other.isInProgress) 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
}
@ -324,24 +307,9 @@ class Episode : RealmObject {
result = 31 * result + isFavorite.hashCode()
result = 31 * result + isInProgress.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
}
// 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) {
UNSPECIFIED(-2),
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.EpisodeSortOrder.Companion.fromCode
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.types.RealmList
import io.realm.kotlin.types.RealmObject
@ -141,6 +144,9 @@ class Feed : RealmObject {
@Ignore
var sortInfo: String = ""
@Ignore
var isBuilding by mutableStateOf(false)
/**
* 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.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.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.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 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.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.EpisodeMedia
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.actionbutton.DownloadActionButton
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction.Companion.NO_ACTION
import ac.mdiq.podcini.ui.actions.SwipeAction.Companion.NO_ACTION
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.dialog.SwipeActionsDialog
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.monitorFeeds
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.dialog.RatingDialog
import ac.mdiq.podcini.ui.fragment.*
@ -65,7 +65,6 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController

View File

@ -1,13 +1,11 @@
package ac.mdiq.podcini.ui.activity
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.Episodes.episodeFromStreamInfo
import ac.mdiq.podcini.storage.database.Feeds.addToYoutubeSyndicate
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.confirmAddYoutubeEpisode
import ac.mdiq.podcini.util.Logd
import ac.mdiq.vista.extractor.Vista
import ac.mdiq.vista.extractor.playlist.PlaylistInfo
import ac.mdiq.vista.extractor.stream.StreamInfo
import ac.mdiq.vista.extractor.services.youtube.YoutubeParsingHelper.isYoutubeServiceURL
import ac.mdiq.vista.extractor.services.youtube.YoutubeParsingHelper.isYoutubeURL
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
@ -16,20 +14,11 @@ import android.util.Log
import androidx.activity.compose.setContent
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.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.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.net.URL
import java.net.URLDecoder
class ShareReceiverActivity : AppCompatActivity() {
@ -54,6 +43,7 @@ class ShareReceiverActivity : AppCompatActivity() {
if (urlString != null) sharedUrl = URLDecoder.decode(urlString, "UTF-8")
}
Logd(TAG, "feedUrl: $sharedUrl")
val url = URL(sharedUrl)
when {
// plain text
sharedUrl!!.matches(Regex("^[^\\s<>/]+\$")) -> {
@ -62,12 +52,13 @@ class ShareReceiverActivity : AppCompatActivity() {
finish()
}
// 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")
setContent {
val showDialog = remember { mutableStateOf(true) }
CustomTheme(this@ShareReceiverActivity) {
confirmAddEpisode(showDialog.value, onDismissRequest = {
confirmAddYoutubeEpisode(listOf(sharedUrl!!), showDialog.value, onDismissRequest = {
showDialog.value = false
finish()
})
@ -85,49 +76,44 @@ class ShareReceiverActivity : AppCompatActivity() {
}
}
@Composable
fun confirmAddEpisode(showDialog: Boolean, onDismissRequest: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(
modifier = Modifier
.wrapContentSize(align = Alignment.Center)
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
var 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 {
val info = StreamInfo.getInfo(Vista.getService(0), sharedUrl!!)
Logd(TAG, "info: $info")
val episode = episodeFromStreamInfo(info)
Logd(TAG, "episode: $episode")
addToYoutubeSyndicate(episode, !audioOnly)
}
onDismissRequest()
}) {
Text("Confirm")
}
}
}
}
}
}
// @Composable
// fun confirmAddEpisode(sharedUrl: String, showDialog: Boolean, onDismissRequest: () -> Unit) {
// 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 {
// val info = StreamInfo.getInfo(Vista.getService(0), sharedUrl)
// Logd(TAG, "info: $info")
// val episode = episodeFromStreamInfo(info)
// Logd(TAG, "episode: $episode")
// addToYoutubeSyndicate(episode, !audioOnly)
// } catch (e: Throwable) {
// toastMassege = "Receive share error: ${e.message}"
// Log.e(TAG, toastMassege)
// showToast = true
// }
// }
// onDismissRequest()
// }) {
// Text("Confirm")
// }
// }
// }
// }
// }
// }
private fun showNoPodcastFoundError() {
runOnUiThread {

View File

@ -1,8 +1,18 @@
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.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.unit.dp
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@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.MediaPlayerBase.Companion.status
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.Feeds.addToMiscSyndicate
import ac.mdiq.podcini.storage.database.Feeds.addToYoutubeSyndicate
import ac.mdiq.podcini.storage.database.Queues
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
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.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.ui.actions.actionbutton.EpisodeActionButton
import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler.PutToQueueDialog
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction
import ac.mdiq.podcini.ui.actions.EpisodeActionButton
import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler.PutToQueueDialog
import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment
import ac.mdiq.podcini.ui.fragment.FeedInfoFragment
import ac.mdiq.podcini.ui.utils.LocalDeleteModal
import ac.mdiq.podcini.util.Logd
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.util.Log
import android.util.TypedValue
import androidx.compose.animation.core.Animatable
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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle
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.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.constraintlayout.compose.ConstraintLayout
import coil.compose.AsyncImage
import io.realm.kotlin.notifications.SingleQueryChange
import io.realm.kotlin.notifications.UpdatedObject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import java.net.URL
import kotlin.math.roundToInt
@Composable
@ -86,10 +93,37 @@ fun InforBar(text: MutableState<String>, leftAction: MutableState<SwipeAction?>,
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)
@Composable
fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episode>, refreshCB: (()->Unit)? = null,
leftSwipeCB: ((Episode) -> Unit)? = null, rightSwipeCB: ((Episode) -> Unit)? = null, actionButton_: ((Episode)->EpisodeActionButton)? = null) {
fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>, refreshCB: (()->Unit)? = null,
leftSwipeCB: ((Episode) -> Unit)? = null, rightSwipeCB: ((Episode) -> Unit)? = null, actionButton_: ((Episode)-> EpisodeActionButton)? = null) {
val TAG = "EpisodeLazyColumn"
var selectMode by remember { mutableStateOf(false) }
var selectedSize by remember { mutableStateOf(0) }
@ -97,10 +131,16 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
val coroutineScope = rememberCoroutineScope()
val lazyListState = rememberLazyListState()
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
fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList<Episode>, modifier: Modifier = Modifier) {
val TAG = "EpisodeSpeedDial ${selected.size}"
var isExpanded by remember { mutableStateOf(false) }
val options = mutableListOf<@Composable () -> Unit>(
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
@ -193,7 +233,18 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
selectMode = false
Logd(TAG, "reserve: ${selected.size}")
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
) {
@ -216,66 +267,53 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
var refreshing by remember { mutableStateOf(false)}
PullToRefreshBox(modifier = Modifier.fillMaxWidth(), isRefreshing = refreshing, indicator = {}, onRefresh = {
// coroutineScope.launch {
refreshing = true
refreshCB?.invoke()
// if (swipeRefresh) FeedUpdateManager.runOnceOrAsk(activity)
refreshing = false
// }
}) {
LazyColumn(state = lazyListState,
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp),
LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(episodes, key = {index, episode -> episode.id}) { index, episode ->
var positionState by remember { mutableStateOf(episode.media?.position?:0) }
var playedState by remember { mutableStateOf(episode.isPlayed()) }
var farvoriteState by remember { mutableStateOf(episode.isFavorite) }
var inProgressState by remember { mutableStateOf(episode.isInProgress) }
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}")
itemsIndexed(vms, key = {index, vm -> vm.episode.id}) { index, vm ->
if (vm.episodeMonitor == null) {
vm.episodeMonitor = CoroutineScope(Dispatchers.Default).launch {
val item_ = realm.query(Episode::class).query("id == ${vm.episode.id}").first()
Logd(TAG, "start monitoring episode: $index ${vm.episode.title}")
val episodeFlow = item_.asFlow()
episodeFlow.collect { changes: SingleQueryChange<Episode> ->
when (changes) {
is UpdatedObject -> {
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 < episodes.size && episodes[index].id == changes.obj.id) {
playedState = changes.obj.isPlayed()
farvoriteState = changes.obj.isFavorite
episodes[index] = 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)
}
if (index < vms.size && vms[index].episode.id == changes.obj.id) {
withContext(Dispatchers.Main) {
vms[index].playedState = changes.obj.isPlayed()
vms[index].farvoriteState = changes.obj.isFavorite
vms[index].episode = changes.obj // direct assignment doesn't update member like media??
}
Logd(TAG, "episodeMonitor $index ${vms[index].playedState} ${vm.playedState} ")
} else Logd(TAG, "episodeMonitor index out bound: $index ${vms.size}")
}
else -> {}
}
}
}
}
if (mediaMonitor == null) {
mediaMonitor = CoroutineScope(Dispatchers.Default).launch {
val item_ = realm.query(Episode::class).query("id == ${episode.id}").first()
Logd(TAG, "start monitoring media: $index ${episode.title}")
if (vm.mediaMonitor == null) {
vm.mediaMonitor = CoroutineScope(Dispatchers.Default).launch {
val item_ = realm.query(Episode::class).query("id == ${vm.episode.id}").first()
Logd(TAG, "start monitoring media: $index ${vm.episode.title}")
val episodeFlow = item_.asFlow(listOf("media.*"))
episodeFlow.collect { changes: SingleQueryChange<Episode> ->
when (changes) {
is UpdatedObject -> {
Logd(TAG, "mediaMonitor UpdatedObject $index ${changes.obj.title} ${changes.changedFields.joinToString()}")
if (index < episodes.size && episodes[index].id == changes.obj.id) {
positionState = changes.obj.media?.position ?: 0
inProgressState = changes.obj.isInProgress
// episodes[index] = changes.obj // direct assignment doesn't update member like media??
changes.obj.copyStates(episodes[index])
episodes.removeAt(index)
episodes.add(index, changes.obj)
}
if (index < vms.size && vms[index].episode.id == changes.obj.id) {
withContext(Dispatchers.Main) {
vms[index].positionState = changes.obj.media?.position ?: 0
vms[index].inProgressState = changes.obj.isInProgress
Logd(TAG, "mediaMonitor $index ${vm.positionState} ${vm.inProgressState} ${vm.episode.title}")
vms[index].episode = changes.obj
}
} else Logd(TAG, "mediaMonitor index out bound: $index ${vms.size}")
}
else -> {}
}
@ -285,14 +323,16 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
DisposableEffect(Unit) {
onDispose {
Logd(TAG, "cancelling monitoring $index")
episodeMonitor?.cancel()
mediaMonitor?.cancel()
vm.episodeMonitor?.cancel()
vm.mediaMonitor?.cancel()
}
}
if (episodes[index].stopMonitoring.value) {
Logd(TAG, "cancel monitoring: ${episodes[index].title}")
episodeMonitor?.cancel()
mediaMonitor?.cancel()
LaunchedEffect(vm.actionButton) {
Logd(TAG, "LaunchedEffect init actionButton")
if (vm.actionButton == null) {
vm.actionButton = if (actionButton_ != null) actionButton_(vm.episode) else EpisodeActionButton.forItem(vm.episode)
vm.actionRes = vm.actionButton!!.getDrawable()
}
}
val velocityTracker = remember { VelocityTracker() }
val offsetX = remember { Animatable(0f) }
@ -308,8 +348,11 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
coroutineScope.launch {
val velocity = velocityTracker.calculateVelocity().x
if (velocity > 1000f || velocity < -1000f) {
if (velocity > 0) rightSwipeCB?.invoke(episodes[index])
else leftSwipeCB?.invoke(episodes[index])
Logd(TAG, "velocity: $velocity")
// 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(
targetValue = 0f, // Back to the initial position
@ -320,18 +363,17 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
)
}.offset { IntOffset(offsetX.value.roundToInt(), 0) }
) {
var isSelected by remember { mutableStateOf(false) }
LaunchedEffect(key1 = selectMode, key2 = selectedSize) {
isSelected = selectMode && episode in selected
vm.isSelected = selectMode && vm.episode in selected
// Logd(TAG, "LaunchedEffect $index $isSelected ${selected.size}")
}
fun toggleSelected() {
isSelected = !isSelected
if (isSelected) selected.add(episodes[index])
else selected.remove(episodes[index])
vm.isSelected = !vm.isSelected
if (vm.isSelected) selected.add(vms[index].episode)
else selected.remove(vms[index].episode)
}
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) {
val typedValue = TypedValue()
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)) {
val (imgvCover, checkMark) = createRefs()
val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(episode)
val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(vm.episode)
AsyncImage(model = imgLoc, contentDescription = "imgvCover",
placeholder = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(56.dp).height(56.dp)
@ -352,10 +394,10 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
}.clickable(onClick = {
Logd(TAG, "icon clicked!")
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
if (playedState) Icon(painter = painterResource(R.drawable.ic_check), tint = textColor, contentDescription = "played_mark",
val alpha = if (vm.playedState) 1.0f else 0f
if (vm.playedState) 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)
@ -363,84 +405,84 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
}
Column(Modifier.weight(1f).padding(start = 6.dp, end = 6.dp)
.combinedClickable(onClick = {
Logd(TAG, "clicked: ${episode.title}")
Logd(TAG, "clicked: ${vm.episode.title}")
if (selectMode) toggleSelected()
else activity.loadChildFragment(EpisodeInfoFragment.newInstance(episode))
else activity.loadChildFragment(EpisodeInfoFragment.newInstance(vm.episode))
}, onLongClick = {
selectMode = !selectMode
isSelected = selectMode
vm.isSelected = selectMode
if (selectMode) {
selected.add(episodes[index])
selected.add(vms[index].episode)
longPressIndex = index
} else {
selected.clear()
selectedSize = 0
longPressIndex = -1
}
Logd(TAG, "long clicked: ${episode.title}")
Logd(TAG, "long clicked: ${vm.episode.title}")
})) {
var inQueueState by remember { mutableStateOf(false) }
LaunchedEffect(key1 = queueChanged) {
if (index>=episodes.size) return@LaunchedEffect
inQueueState = curQueue.contains(episodes[index])
if (index>=vms.size) return@LaunchedEffect
vms[index].inQueueState = curQueue.contains(vms[index].episode)
}
val dur = episode.media!!.getDuration()
val dur = vm.episode.media!!.getDuration()
val durText = DurationConverter.getDurationStringLong(dur)
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))
if (farvoriteState)
if (vm.farvoriteState)
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))
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(episode.title?:"", color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (InTheatre.isCurMedia(episode.media) || inProgressState) {
val pos = positionState
val prog = if (dur > 0 && pos >= 0 && dur >= pos) 1.0f * pos / dur else 0f
Text(vm.episode.title?:"", color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (InTheatre.isCurMedia(vm.episode.media) || vm.inProgressState) {
val pos = vm.positionState
vm.prog = if (dur > 0 && pos >= 0 && dur >= pos) 1.0f * pos / dur else 0f
Logd(TAG, "$index vm.prog: ${vm.prog}")
Row {
Text(DurationConverter.getDurationStringLong(pos), color = textColor, style = MaterialTheme.typography.bodySmall)
LinearProgressIndicator(progress = { prog }, modifier = Modifier.weight(1f).height(4.dp).align(Alignment.CenterVertically))
Text(DurationConverter.getDurationStringLong(vm.positionState), color = textColor, style = MaterialTheme.typography.bodySmall)
LinearProgressIndicator(progress = { vm.prog }, modifier = Modifier.weight(1f).height(4.dp).align(Alignment.CenterVertically))
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 {
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) {
LaunchedEffect(episodes[index].downloadState.value) {
if (index>=episodes.size) return@LaunchedEffect
if (isDownloading()) dlPercent = dls?.getProgress(episodes[index].media!!.downloadUrl!!) ?: 0
// Logd(TAG, "downloadState: ${episodes[index].downloadState.value} ${episode.media?.downloaded} $dlPercent")
actionButton = EpisodeActionButton.forItem(episodes[index])
actionRes = actionButton.getDrawable()
LaunchedEffect(vms[index].downloadState) {
if (index>=vms.size) return@LaunchedEffect
if (isDownloading()) vm.dlPercent = dls?.getProgress(vms[index].episode.media!!.downloadUrl!!) ?: 0
Logd(TAG, "LaunchedEffect $index downloadState: ${vms[index].downloadState} ${vm.episode.media?.downloaded} ${vm.dlPercent}")
vm.actionButton = EpisodeActionButton.forItem(vms[index].episode)
vm.actionRes = vm.actionButton!!.getDrawable()
}
LaunchedEffect(key1 = status) {
if (index>=episodes.size) return@LaunchedEffect
// Logd(TAG, "$index isPlayingState: ${episodes[index].isPlayingState.value} ${episodes[index].title}")
actionButton = EpisodeActionButton.forItem(episodes[index])
actionRes = actionButton.getDrawable()
if (index>=vms.size) return@LaunchedEffect
Logd(TAG, "LaunchedEffect $index isPlayingState: ${vms[index].isPlayingState} ${vms[index].episode.title}")
vm.actionButton = EpisodeActionButton.forItem(vms[index].episode)
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) {
detectTapGestures(onLongPress = { showAltActionsDialog = true }, onTap = {
actionButton.onClick(activity)
detectTapGestures(onLongPress = { vm.showAltActionsDialog = true }, onTap = {
vm.actionButton?.onClick(activity)
})
}, contentAlignment = Alignment.Center) {
// actionRes = actionButton.getDrawable()
Icon(painter = painterResource(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))
Icon(painter = painterResource(vm.actionRes), tint = textColor, contentDescription = null, modifier = Modifier.width(28.dp).height(32.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 = {
selected.clear()
for (i in 0..longPressIndex) {
selected.add(episodes[i])
selected.add(vms[i].episode)
}
selectedSize = 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)
.clickable(onClick = {
selected.clear()
for (i in longPressIndex..<episodes.size) {
selected.add(episodes[i])
for (i in longPressIndex..<vms.size) {
selected.add(vms[i].episode)
}
selectedSize = 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) }
Icon(painter = painterResource(selectAllRes), tint = Color.Black, contentDescription = null, modifier = Modifier.width(35.dp).height(35.dp)
.clickable(onClick = {
if (selectedSize != episodes.size) {
if (selectedSize != vms.size) {
selected.clear()
selected.addAll(episodes)
// for (e in episodes) {
// selected.add(e)
// }
for (vm in vms) {
selected.add(vm.episode)
}
selectAllRes = R.drawable.ic_select_none
} else {
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.discovery.PodcastSearchResult
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.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.formatNumber
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.constraintlayout.compose.ConstraintLayout
import coil.compose.AsyncImage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -35,16 +37,8 @@ fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult) {
fun confirmSubscribe(feed: PodcastSearchResult, showDialog: Boolean, onDismissRequest: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(
modifier = Modifier
.wrapContentSize(align = Alignment.Center)
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) {
Text("Subscribe: \"${feed.title}\" ?")
Button(onClick = {
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(
onClick = {
if (feed.feedUrl != null) {
val fragment = OnlineFeedFragment.newInstance(feed.feedUrl)
fragment.feedSource = feed.source
activity.loadChildFragment(fragment)
if (feed.feedId > 0) {
val fragment = FeedEpisodesFragment.newInstance(feed.feedId)
activity.loadChildFragment(fragment)
} else {
val fragment = OnlineFeedFragment.newInstance(feed.feedUrl)
fragment.feedSource = feed.source
activity.loadChildFragment(fragment)
}
}
}, onLongClick = { showSubscribeDialog.value = true })) {
val textColor = MaterialTheme.colorScheme.onSurface
Text(feed.title, color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = 4.dp))
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)) {
var authorText by remember { mutableStateOf("") }
authorText = when {

View File

@ -14,11 +14,11 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.*
import ac.mdiq.podcini.ui.fragment.*
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions.Companion.getPrefsWithDefaults
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions.Companion.getSharedPrefs
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions.Companion.isSwipeActionEnabled
import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.getPrefsWithDefaults
import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.getSharedPrefs
import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.isSwipeActionEnabled
import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr
import androidx.annotation.OptIn
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.ImageResourceUtils
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.VideoplayerActivity.Companion.videoMode
import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter
@ -61,7 +61,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.databinding.BaseEpisodesListFragmentBinding
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.EpisodeFilter
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.EpisodeLazyColumn
import ac.mdiq.podcini.ui.compose.InforBar
import ac.mdiq.podcini.ui.compose.*
import ac.mdiq.podcini.ui.utils.EmptyViewHandler
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
@ -21,6 +18,7 @@ import android.os.Bundle
import android.util.Log
import android.view.*
import androidx.appcompat.widget.Toolbar
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.core.util.Pair
@ -54,7 +52,8 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
lateinit var toolbar: MaterialToolbar
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 {
super.onCreateView(inflater, container, savedInstanceState)
@ -79,17 +78,22 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
swipeActions = SwipeActions(this, TAG)
lifecycle.addObserver(swipeActions)
binding.infobar.setContent {
CustomTheme(requireContext()) {
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
}
}
binding.lazyColumn.setContent {
CustomTheme(requireContext()) {
EpisodeLazyColumn(activity as MainActivity, episodes = episodes,
leftSwipeCB = { if (leftActionState.value == null) swipeActions.showDialog() else leftActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())},
rightSwipeCB = { if (rightActionState.value == null) swipeActions.showDialog() else rightActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())},
Column {
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
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())
},
)
}
}
}
@ -143,6 +147,7 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
Logd(TAG, "onDestroyView")
_binding = null
episodes.clear()
vms.clear()
super.onDestroyView()
}
@ -160,8 +165,10 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
for (url in event.urls) {
// if (!event.isCompleted(url)) continue
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) {
Pair(loadData().toMutableList(), loadTotalItemCount())
}
val restoreScrollPosition = episodes.isEmpty()
episodes.clear()
episodes.addAll(data.first)
withContext(Dispatchers.Main) {
val restoreScrollPosition = episodes.isEmpty()
episodes.clear()
episodes.addAll(data.first)
vms.clear()
for (e in data.first) { vms.add(EpisodeVM(e)) }
// if (restoreScrollPosition) recyclerView.restoreScrollPosition(getPrefName())
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.Feed
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.dialog.DownloadLogDetailsDialog
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.EpisodeSortOrder
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.actionbutton.DeleteActionButton
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.actions.DeleteActionButton
import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme
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.compose.*
import ac.mdiq.podcini.ui.dialog.EpisodeFilterDialog
import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog
import ac.mdiq.podcini.ui.dialog.SwitchQueueDialog
@ -38,6 +35,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.*
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
@ -61,7 +59,8 @@ import java.util.*
private val binding get() = _binding!!
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 leftActionState = mutableStateOf<SwipeAction?>(null)
@ -93,18 +92,21 @@ import java.util.*
swipeActions = SwipeActions(this, TAG)
swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.downloaded.name))
binding.infobar.setContent {
CustomTheme(requireContext()) {
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
}
}
binding.lazyColumn.setContent {
CustomTheme(requireContext()) {
EpisodeLazyColumn(activity as MainActivity, episodes = episodes,
leftSwipeCB = { if (leftActionState.value == null) swipeActions.showDialog() else leftActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())},
rightSwipeCB = { if (rightActionState.value == null) swipeActions.showDialog() else rightActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())},
actionButton_ = { DeleteActionButton(it) } )
Column {
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
EpisodeLazyColumn(activity as MainActivity, vms = vms,
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)
@ -143,11 +145,10 @@ import java.util.*
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
// adapter.endSelectMode()
// adapter.clearData()
toolbar.setOnMenuItemClickListener(null)
toolbar.setOnLongClickListener(null)
episodes.clear()
vms.clear()
super.onDestroyView()
}
@ -295,8 +296,12 @@ import java.util.*
val pos = EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes.removeAt(pos)
vms.removeAt(pos)
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
@ -313,8 +318,12 @@ import java.util.*
val pos = EpisodeUtil.indexOfItemWithId(episodes, item.id)
if (pos >= 0) {
episodes.removeAt(pos)
vms.removeAt(pos)
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
@ -330,6 +339,7 @@ import java.util.*
private var loadItemsRunning = false
private fun loadItems() {
emptyView.hide()
Logd(TAG, "loadItems() called")
if (!loadItemsRunning) {
loadItemsRunning = true
lifecycleScope.launch {
@ -353,6 +363,10 @@ import java.util.*
episodes.addAll(currentDownloads)
}
episodes.retainAll { filter.matchesForQueues(it) }
withContext(Dispatchers.Main) {
vms.clear()
for (e in episodes) vms.add(EpisodeVM(e))
}
}
withContext(Dispatchers.Main) { refreshInfoBar() }
} catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e))
@ -364,13 +378,13 @@ import java.util.*
private fun getEpisdesWithUrl(urls: List<String>): List<Episode> {
Logd(TAG, "getEpisdesWithUrl() called ")
if (urls.isEmpty()) return listOf()
val episodes: MutableList<Episode> = mutableListOf()
val episodes_: MutableList<Episode> = mutableListOf()
for (url in urls) {
val media = realm.query(EpisodeMedia::class).query("downloadUrl == $0", url).first().find() ?: continue
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() {
@ -380,7 +394,7 @@ import java.util.*
for (item in episodes) sizeMB += item.media?.size ?: 0
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)}"
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.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.ui.actions.actionbutton.*
import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler
import ac.mdiq.podcini.ui.actions.*
import ac.mdiq.podcini.ui.actions.EpisodeMenuHandler
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme
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.res.painterResource
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.unit.dp
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.Feed
import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.*
import ac.mdiq.podcini.ui.dialog.*
@ -79,7 +79,10 @@ import java.util.concurrent.Semaphore
private var headerCreated = false
private var feedID: Long = 0
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 ueMap: Map<String, Int> = mapOf()
@ -143,6 +146,8 @@ import java.util.concurrent.Semaphore
episodes.addAll(etmp)
ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index }
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
}
}
@ -151,16 +156,22 @@ import java.util.concurrent.Semaphore
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 {
CustomTheme(requireContext()) {
EpisodeLazyColumn(activity as MainActivity, episodes = episodes, refreshCB = {FeedUpdateManager.runOnceOrAsk(requireContext(), feed)},
leftSwipeCB = { if (leftActionState.value == null) swipeActions.showDialog() else leftActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())},
rightSwipeCB = { if (rightActionState.value == null) swipeActions.showDialog() else rightActionState.value?.performAction(it, this, swipeActions.filter ?: EpisodeFilter())}, )
Column {
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
CustomTheme(requireContext()) {
EpisodeLazyColumn(activity as MainActivity, vms = vms,
refreshCB = { FeedUpdateManager.runOnceOrAsk(requireContext(), feed) },
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()
ueMap = mapOf()
episodes.clear()
vms.clear()
tts?.stop()
tts?.shutdown()
ttsWorking = false
@ -384,14 +396,12 @@ import java.util.concurrent.Semaphore
private fun onPlayEvent(event: FlowEvent.PlayEvent) {
// Logd(TAG, "onPlayEvent ${event.episode.title}")
// if (feed != null) {
// val pos: Int = ieMap[event.episode.id] ?: -1
// if (pos >= 0) {
//// if (!filterOutEpisode(event.episode)) episodes[pos].isPlayingState.value = event.isPlaying()
//// if (filterOutEpisode(event.episode)) adapter.updateItems(episodes)
//// else adapter.notifyItemChangedCompat(pos)
// }
// }
if (feed != null) {
val pos: Int = ieMap[event.episode.id] ?: -1
if (pos >= 0) {
if (!filterOutEpisode(event.episode)) vms[pos].isPlayingState = event.isPlaying()
}
}
}
private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) {
@ -403,8 +413,8 @@ import java.util.concurrent.Semaphore
val pos: Int = ueMap[url] ?: -1
if (pos >= 0) {
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
// adapter.notifyItemChangedCompat(pos)
// episodes[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
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)
}
// private fun filterOutEpisode(episode: Episode): Boolean {
// if (enableFilter && !feed?.preferences?.filterString.isNullOrEmpty() && !feed!!.episodeFilter.matches(episode)) {
// episodes.remove(episode)
// ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index }
// ueMap = episodes.mapIndexedNotNull { index, episode_ -> episode_.media?.downloadUrl?.let { it to index } }.toMap()
// return true
// }
// return false
// }
private fun filterOutEpisode(episode: Episode): Boolean {
if (enableFilter && !feed?.preferences?.filterString.isNullOrEmpty() && !feed!!.episodeFilter.matches(episode)) {
episodes.remove(episode)
ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index }
ueMap = episodes.mapIndexedNotNull { index, episode_ -> episode_.media?.downloadUrl?.let { it to index } }.toMap()
return true
}
return false
}
// private fun redoFilter(list: List<Episode>? = null) {
// if (enableFilter && !feed?.preferences?.filterString.isNullOrEmpty()) {
@ -574,6 +584,10 @@ import java.util.concurrent.Semaphore
episodes.addAll(etmp)
ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index }
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) {
var hasNonMediaItems = false
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.getFeedList
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.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.FlowEvent
import ac.mdiq.podcini.util.Logd
@ -23,7 +22,6 @@ import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.content.SharedPreferences
import android.graphics.LightingColorFilter
import android.net.Uri
import android.os.Bundle
import android.text.Spannable
@ -34,15 +32,24 @@ import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.annotation.OptIn
import androidx.annotation.UiThread
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.lifecycle.lifecycleScope
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.snackbar.Snackbar
import kotlinx.coroutines.*
@ -72,6 +79,13 @@ class OnlineFeedFragment : Fragment() {
private var feedUrl: String = ""
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
get() {
if (feeds == null) return 0
@ -83,6 +97,7 @@ class OnlineFeedFragment : Fragment() {
@Volatile
private var feeds: List<Feed>? = null
private var feed by mutableStateOf<Feed?>(null)
private var selectedDownloadUrl: String? = null
// private var downloader: Downloader? = 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 {
Logd(TAG, "fragment onCreateView")
_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
if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW)
(activity as MainActivity).setupToolbarToggle(binding.toolbar, displayUpArrow)
feedUrl = requireArguments().getString(ARG_FEEDURL) ?: ""
Logd(TAG, "feedUrl: $feedUrl")
feedBuilder = FeedBuilder(requireContext()) { message, details -> showErrorDialog(message, details) }
binding.mainView.setContent {
CustomTheme(requireContext()) {
MainView()
}
}
if (feedUrl.isEmpty()) {
Log.e(TAG, "feedUrl is null.")
showNoPodcastFoundError()
} else {
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
if (feedUrl.contains("subscribeonandroid.com")) feedUrl = feedUrl.replaceFirst("((www.)?(subscribeonandroid.com/))".toRegex(), "")
if (savedInstanceState != null) {
@ -127,11 +142,6 @@ class OnlineFeedFragment : Fragment() {
return binding.root
}
private fun setLoadingLayout() {
binding.progressBar.visibility = View.VISIBLE
binding.feedDisplayContainer.visibility = View.GONE
}
override fun onStart() {
super.onStart()
isPaused = false
@ -164,9 +174,10 @@ class OnlineFeedFragment : Fragment() {
val urlString = PodcastSearcherRegistry.lookupUrl1(url)
try {
feeds = getFeedList()
feedBuilder.startFeedBuilding(urlString, username, password) { feed, map ->
feedBuilder.startFeedBuilding(urlString, username, password) { feed_, map ->
selectedDownloadUrl = feedBuilder.selectedDownloadUrl
showFeedInformation(feed, map)
feed = feed_
showFeedInformation(feed_, map)
}
} catch (e: FeedUrlNotFoundException) { tryToRetrieveFeedUrlBySearch(e)
} catch (e: Throwable) {
@ -196,9 +207,10 @@ class OnlineFeedFragment : Fragment() {
Logd(TAG, "Successfully retrieve feed url")
isFeedFoundBySearch = true
feeds = getFeedList()
feedBuilder.startFeedBuilding(url, username, password) { feed, map ->
feedBuilder.startFeedBuilding(url, username, password) { feed_, map ->
selectedDownloadUrl = feedBuilder.selectedDownloadUrl
showFeedInformation(feed, map)
feed = feed_
showFeedInformation(feed_, map)
}
} else {
showNoPodcastFoundError()
@ -268,77 +280,140 @@ class OnlineFeedFragment : Fragment() {
* This method is executed on the GUI thread.
*/
@UnstableApi private fun showFeedInformation(feed: Feed, alternateFeedUrls: Map<String, String>) {
binding.progressBar.visibility = View.GONE
binding.feedDisplayContainer.visibility = View.VISIBLE
showProgress = false
// binding.feedDisplayContainer.visibility = View.VISIBLE
showFeedDisplay = true
if (isFeedFoundBySearch) {
val resId = R.string.no_feed_url_podcast_found_by_search
Snackbar.make(binding.root, resId, Snackbar.LENGTH_LONG).show()
}
binding.backgroundImage.colorFilter = LightingColorFilter(-0x7d7d7e, 0x000000)
binding.episodeLabel.setOnClickListener { showEpisodes(feed.episodes)}
if (!feed.imageUrl.isNullOrBlank()) {
binding.coverImage.load(feed.imageUrl) {
placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher)
}
}
binding.titleLabel.text = feed.title
binding.authorLabel.text = feed.author
binding.txtvDescription.text = HtmlToPlainText.getPlainText(feed.description?:"")
binding.txtvTechInfo.text = "${feed.episodes.size} episodes\n" +
"${feed.mostRecentItem?.title ?: ""}\n\n" +
"${feed.language} ${feed.type ?: ""} ${feed.lastUpdate ?: ""}\n" +
"${feed.link}\n" +
"${feed.downloadUrl}"
binding.subscribeButton.setOnClickListener {
if (feedInFeedlist()) openFeed()
else {
lifecycleScope.launch {
binding.progressBar.visibility = View.VISIBLE
withContext(Dispatchers.IO) { feedBuilder.subscribe(feed) }
withContext(Dispatchers.Main) {
binding.progressBar.visibility = View.GONE
didPressSubscribe = true
handleUpdatedFeedStatus()
}
}
}
}
if (feedSource != "VistaGuide" && isEnableAutodownload)
binding.autoDownloadCheckBox.isChecked = prefs!!.getBoolean(PREF_LAST_AUTO_DOWNLOAD, true)
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(),
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<*>?) {}
}
}
// 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(),
// 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()
}
@UnstableApi private fun openFeed() {
(activity as MainActivity).loadFeedFragmentById(feedId, null)
@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)
}
Row(Modifier.constrainAs(buttons) {
start.linkTo(coverImage.end)
top.linkTo(taColumn.bottom)
end.linkTo(parent.end)
}) {
Spacer(modifier = Modifier.weight(0.2f))
if (enableSubscribe) Button(onClick = {
if (feedInFeedlist()) (activity as MainActivity).loadFeedFragmentById(feedId, null)
else {
enableSubscribe = false
enableEpisodes = false
CoroutineScope(Dispatchers.IO).launch {
feedBuilder.subscribe(feed!!)
withContext(Dispatchers.Main) {
enableSubscribe = true
didPressSubscribe = true
handleUpdatedFeedStatus()
}
}
}
}) {
Text(stringResource(subButTextRes))
}
Spacer(modifier = Modifier.weight(0.1f))
if (enableEpisodes && feed != null) Button(onClick = { showEpisodes(feed!!.episodes) }) {
Text(stringResource(R.string.episodes_label))
}
Spacer(modifier = Modifier.weight(0.2f))
}
if (false) Icon(painter = painterResource(R.drawable.ic_close_white), contentDescription = null, modifier = Modifier
.constrainAs(closeButton) {
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)
)
}
}
val scrollState = rememberScrollState()
var numEpisodes by remember { mutableIntStateOf(feed?.episodes?.size?:0) }
LaunchedEffect(Unit) {
while (true) {
delay(1000)
numEpisodes = feed?.episodes?.size?:0
}
}
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))
}
}
}
}
@UnstableApi private fun showEpisodes(episodes: MutableList<Episode>) {
@ -364,12 +439,12 @@ class OnlineFeedFragment : Fragment() {
// binding.subscribeButton.isEnabled = false
// }
dli.isDownloadingEpisode(selectedDownloadUrl!!) -> {
binding.subscribeButton.isEnabled = false
binding.subscribeButton.setText(R.string.subscribing_label)
enableSubscribe = false
subButTextRes = R.string.subscribing_label
}
feedInFeedlist() -> {
binding.subscribeButton.isEnabled = true
binding.subscribeButton.setText(R.string.open)
enableSubscribe = true
subButTextRes = R.string.open
if (didPressSubscribe) {
didPressSubscribe = false
val feed1 = getFeed(feedId, true)?: return
@ -379,7 +454,7 @@ class OnlineFeedFragment : Fragment() {
feed1.preferences!!.prefStreamOverDownload = true
feed1.preferences!!.autoDownload = false
} else if (isEnableAutodownload) {
val autoDownload = binding.autoDownloadCheckBox.isChecked
val autoDownload = autoDownloadChecked
feed1.preferences!!.autoDownload = autoDownload
val editor = prefs!!.edit()
editor.putBoolean(PREF_LAST_AUTO_DOWNLOAD, autoDownload)
@ -390,13 +465,12 @@ class OnlineFeedFragment : Fragment() {
feed1.preferences!!.password = password
}
persistFeedPreferences(feed1)
openFeed()
// openFeed()
}
}
else -> {
binding.subscribeButton.isEnabled = true
binding.subscribeButton.setText(R.string.subscribe_label)
if (feedSource != "VistaGuide" && isEnableAutodownload) binding.autoDownloadCheckBox.visibility = View.VISIBLE
enableSubscribe = true
subButTextRes = R.string.subscribing_label
}
}
}

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.EpisodeUtil
import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.actions.SwipeAction
import ac.mdiq.podcini.ui.actions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme
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.compose.*
import ac.mdiq.podcini.ui.dialog.ConfirmationDialog
import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog
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.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.leinardi.android.speeddial.SpeedDialActionItem
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@ -113,7 +109,8 @@ import kotlin.math.max
private lateinit var queues: List<PlayQueue>
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)
@ -183,7 +180,7 @@ import kotlin.math.max
swipeActions = SwipeActions(this, TAG)
swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name))
swipeActionsBin = SwipeActions(this, TAG)
swipeActionsBin = SwipeActions(this, TAG+".Bin")
swipeActionsBin.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name))
binding.lazyColumn.setContent {
@ -199,7 +196,7 @@ import kotlin.math.max
if (rightActionStateBin.value == null) swipeActionsBin.showDialog()
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 {
Column {
@ -212,7 +209,7 @@ import kotlin.math.max
if (rightActionState.value == null) swipeActions.showDialog()
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
when (event.action) {
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 -> {
queueItems.clear()
queueItems.addAll(event.episodes)
vms.clear()
for (e in event.episodes) vms.add(EpisodeVM(e))
}
FlowEvent.QueueEvent.Action.REMOVED, FlowEvent.QueueEvent.Action.IRREVERSIBLE_REMOVED -> {
if (event.episodes.isNotEmpty()) {
@ -329,8 +331,10 @@ import kotlin.math.max
val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, e.id)
if (pos >= 0) {
Logd(TAG, "removing episode $pos ${queueItems[pos].title} $e")
queueItems[pos].stopMonitoring.value = true
// queueItems[pos].stopMonitoring.value = true
queueItems.removeAt(pos)
vms[pos].stopMonitoring()
vms.removeAt(pos)
} else {
Log.e(TAG, "Trying to remove item non-existent from queue ${e.id} ${e.title}")
continue
@ -344,6 +348,7 @@ import kotlin.math.max
}
FlowEvent.QueueEvent.Action.CLEARED -> {
queueItems.clear()
vms.clear()
}
FlowEvent.QueueEvent.Action.MOVED, FlowEvent.QueueEvent.Action.DELETED_MEDIA -> return
}
@ -357,7 +362,7 @@ import kotlin.math.max
private fun onPlayEvent(event: FlowEvent.PlayEvent) {
val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, event.episode.id)
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) {
@ -366,7 +371,10 @@ import kotlin.math.max
for (url in event.urls) {
// if (!event.isCompleted(url)) continue
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")
_binding = null
queueItems.clear()
vms.clear()
toolbar.setOnMenuItemClickListener(null)
toolbar.setOnLongClickListener(null)
super.onDestroyView()
@ -662,12 +671,14 @@ import kotlin.math.max
while (curQueue.name.isEmpty()) runBlocking { delay(100) }
if (queueItems.isNotEmpty()) emptyViewHandler.hide()
queueItems.clear()
vms.clear()
if (showBin) queueItems.addAll(realm.query(Episode::class, "id IN $0", curQueue.idsBinList)
.find().sortedByDescending { curQueue.idsBinList.indexOf(it.id) })
else {
curQueue.episodes.clear()
queueItems.addAll(curQueue.episodes)
}
for (e in queueItems) vms.add(EpisodeVM(e))
Logd(TAG, "loadCurQueue() curQueue.episodes: ${curQueue.episodes.size}")
// 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.Feed
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.compose.CustomTheme
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.RemoveFeedDialog
import ac.mdiq.podcini.ui.dialog.TagSettingsDialog
import ac.mdiq.podcini.ui.utils.EmptyViewHandler
import ac.mdiq.podcini.ui.view.SquareImageView
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
@ -68,7 +69,8 @@ class SearchFragment : Fragment() {
private lateinit var chip: Chip
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 isOtherViewInFoucus = false
@ -86,7 +88,7 @@ class SearchFragment : Fragment() {
binding.lazyColumn.setContent {
CustomTheme(requireContext()) {
EpisodeLazyColumn(activity as MainActivity, episodes = results)
EpisodeLazyColumn(activity as MainActivity, vms = vms)
}
}
@ -137,6 +139,7 @@ class SearchFragment : Fragment() {
Logd(TAG, "onDestroyView")
_binding = null
results.clear()
vms.clear()
super.onDestroyView()
}
@ -229,7 +232,10 @@ class SearchFragment : Fragment() {
private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) {
for (url in event.urls) {
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()
results.clear()
results.addAll(first_)
vms.clear()
for (e in first_) { vms.add(EpisodeVM(e)) }
}
if (requireArguments().getLong(ARG_FEED, 0) == 0L) {
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.PodcastSearcher
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.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.OnlineFeedItem
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.formatNumber
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
@ -20,8 +20,10 @@ import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.widget.SearchView
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
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.rememberLazyListState
import androidx.compose.material3.Button
@ -31,15 +33,12 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import coil.compose.AsyncImage
import com.google.android.material.appbar.MaterialToolbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -168,8 +167,18 @@ class SearchResultsFragment : Fragment() {
private fun search(query: String) {
showOnlyProgressBar()
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 {
val result = searchProvider?.search(query) ?: listOf()
for (r in result) {
r.feedId = feedId(r)
}
searchResults.clear()
searchResults.addAll(result)
withContext(Dispatchers.Main) {

View File

@ -847,17 +847,21 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Column(Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
.combinedClickable(onClick = {
Logd(TAG, "clicked: ${feed.title}")
if (selectMode) toggleSelected()
else (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id))
if (!feed.isBuilding) {
if (selectMode) toggleSelected()
else (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id))
}
}, onLongClick = {
selectMode = !selectMode
isSelected = selectMode
if (selectMode) {
selected.add(feed)
longPressIndex = index
} else {
selectedSize = 0
longPressIndex = -1
if (!feed.isBuilding) {
selectMode = !selectMode
isSelected = selectMode
if (selectMode) {
selected.add(feed)
longPressIndex = index
} else {
selectedSize = 0
longPressIndex = -1
}
}
Logd(TAG, "long clicked: ${feed.title}")
})) {
@ -914,25 +918,31 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
modifier = Modifier.width(80.dp).height(80.dp)
.clickable(onClick = {
Logd(TAG, "icon clicked!")
if (selectMode) toggleSelected()
else (activity as MainActivity).loadChildFragment(FeedInfoFragment.newInstance(feed))
if (!feed.isBuilding) {
if (selectMode) toggleSelected()
else (activity as MainActivity).loadChildFragment(FeedInfoFragment.newInstance(feed))
}
})
)
val textColor = MaterialTheme.colorScheme.onSurface
Column(Modifier.weight(1f).padding(start = 10.dp).combinedClickable(onClick = {
Logd(TAG, "clicked: ${feed.title}")
if (selectMode) toggleSelected()
else (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id))
if (!feed.isBuilding) {
if (selectMode) toggleSelected()
else (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id))
}
}, onLongClick = {
selectMode = !selectMode
isSelected = selectMode
if (selectMode) {
selected.add(feed)
longPressIndex = index
} else {
selected.clear()
selectedSize = 0
longPressIndex = -1
if (!feed.isBuilding) {
selectMode = !selectMode
isSelected = selectMode
if (selectMode) {
selected.add(feed)
longPressIndex = index
} else {
selected.clear()
selectedSize = 0
longPressIndex = -1
}
}
Logd(TAG, "long clicked: ${feed.title}")
})) {
@ -944,9 +954,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
color = textColor, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.weight(1f))
var feedSortInfo by remember { mutableStateOf(feed.sortInfo) }
LaunchedEffect(feedSorted) {
feedSortInfo = feed.sortInfo
}
LaunchedEffect(feedSorted) { feedSortInfo = feed.sortInfo }
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.net.utils.NetworkUtils
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.utils.ShownotesCleaner
import ac.mdiq.podcini.util.*

View File

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

View File

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

View File

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

View File

@ -25,232 +25,9 @@
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/transparentBackground"
<androidx.compose.ui.platform.ComposeView
android:id="@+id/mainView"
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>

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
* clear history really clears it
@ -27,7 +44,7 @@
* migrated mostly the following view to Jetpack Compose:
* 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
* 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
* please voice any irregularities you may see
* 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