From 72f28ce9b757c2f45c65a1ba76d43c668041ba14 Mon Sep 17 00:00:00 2001 From: Xilin Jia <6257601+XilinJia@users.noreply.github.com> Date: Sun, 6 Oct 2024 19:00:44 +0100 Subject: [PATCH] 6.9.0 commit --- README.md | 2 + app/build.gradle | 4 +- .../ac/mdiq/podcini/net/feed/FeedBuilder.kt | 110 ++-- .../net/feed/discovery/PodcastSearchResult.kt | 6 + .../podcini/net/feed/parser/FeedHandler.kt | 1 - .../ac/mdiq/podcini/storage/database/Feeds.kt | 4 + .../ac/mdiq/podcini/storage/model/Episode.kt | 32 - .../ac/mdiq/podcini/storage/model/Feed.kt | 6 + .../podcini/ui/actions/EpisodeActionButton.kt | 568 ++++++++++++++++++ .../{handler => }/EpisodeMenuHandler.kt | 2 +- .../EpisodeMultiSelectHandler.kt | 2 +- .../ui/actions/{handler => }/MenuItemUtils.kt | 2 +- .../actions/{swipeactions => }/SwipeAction.kt | 2 +- .../{swipeactions => }/SwipeActions.kt | 5 +- .../CancelDownloadActionButton.kt | 37 -- .../actionbutton/DeleteActionButton.kt | 27 - .../actionbutton/DownloadActionButton.kt | 54 -- .../actionbutton/EpisodeActionButton.kt | 135 ----- .../actionbutton/MarkAsPlayedActionButton.kt | 27 - .../actions/actionbutton/PauseActionButton.kt | 27 - .../actions/actionbutton/PlayActionButton.kt | 72 --- .../actionbutton/PlayLocalActionButton.kt | 43 -- .../actionbutton/StreamActionButton.kt | 69 --- .../actions/actionbutton/TTSActionButton.kt | 163 ----- .../actionbutton/VisitWebsiteActionButton.kt | 25 - .../mdiq/podcini/ui/activity/MainActivity.kt | 3 +- .../ui/activity/ShareReceiverActivity.kt | 110 ++-- .../ac/mdiq/podcini/ui/compose/Composables.kt | 26 + .../ui/compose/{Episodes.kt => EpisodesVM.kt} | 313 ++++++---- .../ac/mdiq/podcini/ui/compose/OnlineFeed.kt | 51 +- .../podcini/ui/dialog/SwipeActionsDialog.kt | 10 +- .../ui/fragment/AudioPlayerFragment.kt | 3 +- .../ui/fragment/BaseEpisodesFragment.kt | 49 +- .../ui/fragment/DownloadLogFragment.kt | 2 +- .../podcini/ui/fragment/DownloadsFragment.kt | 66 +- .../ui/fragment/EpisodeInfoFragment.kt | 5 +- .../ui/fragment/FeedEpisodesFragment.kt | 76 ++- .../podcini/ui/fragment/OnlineFeedFragment.kt | 260 +++++--- .../podcini/ui/fragment/QueuesFragment.kt | 41 +- .../podcini/ui/fragment/SearchFragment.kt | 18 +- .../ui/fragment/SearchResultsFragment.kt | 21 +- .../ui/fragment/SubscriptionsFragment.kt | 60 +- .../mdiq/podcini/ui/view/ShownotesWebView.kt | 2 +- .../layout/base_episodes_list_fragment.xml | 8 +- .../main/res/layout/downloads_fragment.xml | 8 +- .../res/layout/feed_item_list_fragment.xml | 8 +- .../res/layout/online_feedview_fragment.xml | 229 +------ changelog.md | 19 +- .../android/en-US/changelogs/3020265.txt | 16 + 49 files changed, 1390 insertions(+), 1439 deletions(-) create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt rename app/src/main/kotlin/ac/mdiq/podcini/ui/actions/{handler => }/EpisodeMenuHandler.kt (99%) rename app/src/main/kotlin/ac/mdiq/podcini/ui/actions/{handler => }/EpisodeMultiSelectHandler.kt (99%) rename app/src/main/kotlin/ac/mdiq/podcini/ui/actions/{handler => }/MenuItemUtils.kt (95%) rename app/src/main/kotlin/ac/mdiq/podcini/ui/actions/{swipeactions => }/SwipeAction.kt (95%) rename app/src/main/kotlin/ac/mdiq/podcini/ui/actions/{swipeactions => }/SwipeActions.kt (98%) delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/CancelDownloadActionButton.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/DeleteActionButton.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/DownloadActionButton.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/MarkAsPlayedActionButton.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PauseActionButton.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/TTSActionButton.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/VisitWebsiteActionButton.kt rename app/src/main/kotlin/ac/mdiq/podcini/ui/compose/{Episodes.kt => EpisodesVM.kt} (61%) create mode 100644 fastlane/metadata/android/en-US/changelogs/3020265.txt diff --git a/README.md b/README.md index 6bdc0dd4..9e358baf 100644 --- a/README.md +++ b/README.md @@ -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]() 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 diff --git a/app/build.gradle b/app/build.gradle index a4f73d53..d8780cee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt index 83857bdc..2bf3ced7 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt @@ -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 = realmListOf() + val eList: MutableList = 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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/discovery/PodcastSearchResult.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/discovery/PodcastSearchResult.kt index f9c40301..446ebdc3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/discovery/PodcastSearchResult.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/discovery/PodcastSearchResult.kt @@ -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") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt index fc9b6956..7bde1530 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt index e609b6ca..74c0eb29 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt @@ -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)) } /** diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt index cf6b0f28..76c74711 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt @@ -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), diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt index d3112fbd..2cc89548 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt @@ -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. */ diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt new file mode 100644 index 00000000..28d1626c --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt @@ -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() + 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() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMenuHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMenuHandler.kt similarity index 99% rename from app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMenuHandler.kt rename to app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMenuHandler.kt index 53b8c9b4..f4e02960 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMenuHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMenuHandler.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMultiSelectHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMultiSelectHandler.kt similarity index 99% rename from app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMultiSelectHandler.kt rename to app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMultiSelectHandler.kt index 3fea6ed2..f2ae8aa1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMultiSelectHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMultiSelectHandler.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/MenuItemUtils.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/MenuItemUtils.kt similarity index 95% rename from app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/MenuItemUtils.kt rename to app/src/main/kotlin/ac/mdiq/podcini/ui/actions/MenuItemUtils.kt index 7291d0ac..983455e3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/MenuItemUtils.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/MenuItemUtils.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeAction.kt similarity index 95% rename from app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeAction.kt rename to app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeAction.kt index 77f9c898..4e2d2681 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeAction.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeAction.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt similarity index 98% rename from app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt rename to app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt index 7eb385f8..f09aa483 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/CancelDownloadActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/CancelDownloadActionButton.kt deleted file mode 100644 index 42022321..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/CancelDownloadActionButton.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/DeleteActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/DeleteActionButton.kt deleted file mode 100644 index d103295d..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/DeleteActionButton.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/DownloadActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/DownloadActionButton.kt deleted file mode 100644 index b45342f4..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/DownloadActionButton.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt deleted file mode 100644 index e006597d..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/EpisodeActionButton.kt +++ /dev/null @@ -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)) - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/MarkAsPlayedActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/MarkAsPlayedActionButton.kt deleted file mode 100644 index 904dcbbc..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/MarkAsPlayedActionButton.kt +++ /dev/null @@ -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() - } - -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PauseActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PauseActionButton.kt deleted file mode 100644 index 0508bfdf..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PauseActionButton.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt deleted file mode 100644 index a6f2cb49..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayActionButton.kt +++ /dev/null @@ -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))) - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt deleted file mode 100644 index a1aa3cff..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/PlayLocalActionButton.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt deleted file mode 100644 index cee3b311..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/StreamActionButton.kt +++ /dev/null @@ -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) - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/TTSActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/TTSActionButton.kt deleted file mode 100644 index 1dd3e5b6..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/TTSActionButton.kt +++ /dev/null @@ -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() - 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() - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/VisitWebsiteActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/VisitWebsiteActionButton.kt deleted file mode 100644 index c71d3012..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/actionbutton/VisitWebsiteActionButton.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index 021cddd9..afc80114 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/ShareReceiverActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/ShareReceiverActivity.kt index 72e18f54..a1ae3563 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/ShareReceiverActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/ShareReceiverActivity.kt @@ -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 { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt index 183f1b8c..ce963395 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt similarity index 61% rename from app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt rename to app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt index 260d9dc6..76ee51e4 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt @@ -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, leftAction: MutableState, 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(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, refreshCB: (()->Unit)? = null, - leftSwipeCB: ((Episode) -> Unit)? = null, rightSwipeCB: ((Episode) -> Unit)? = null, actionButton_: ((Episode)->EpisodeActionButton)? = null) { +fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList, 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() } + confirmAddYoutubeEpisode(youtubeUrls, showConfirmYoutubeDialog.value, onDismissRequest = { + showConfirmYoutubeDialog.value = false + }) @Composable fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList, 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 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 -> 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 -> 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 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=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, 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") + } + } + } + } + } +} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/OnlineFeed.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/OnlineFeed.kt index ef659686..44362e5a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/OnlineFeed.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/OnlineFeed.kt @@ -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 { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt index 52c8ce69..a8dc88da 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index f1b82eca..ff43a887 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt index 7016b101..b6d32c7c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt @@ -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() + val episodes = mutableListOf() + private val vms = mutableStateListOf() @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() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadLogFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadLogFragment.kt index f1f5423e..02735738 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadLogFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadLogFragment.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt index 92027080..50cc12c1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt @@ -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 = HashSet() - private val episodes = mutableStateListOf() + private val episodes = mutableListOf() + private val vms = mutableStateListOf() private var infoBarText = mutableStateOf("") private var leftActionState = mutableStateOf(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): List { Logd(TAG, "getEpisdesWithUrl() called ") if (urls.isEmpty()) return listOf() - val episodes: MutableList = mutableListOf() + val episodes_: MutableList = 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 } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt index 8c8b417a..e0e36e60 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt index f837ec45..843c13ef 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt @@ -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(null) - private val episodes = mutableStateListOf() + + private val episodes = mutableListOf() + private val vms = mutableStateListOf() + private var ieMap: Map = mapOf() private var ueMap: Map = 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? = 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) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt index aad357df..22f8723c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt @@ -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? = null + private var feed by mutableStateOf(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) { - 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 = ArrayList() - val alternateUrlsTitleList: MutableList = 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 = object : ArrayAdapter(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 = ArrayList() +// val alternateUrlsTitleList: MutableList = 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 = object : ArrayAdapter(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) { @@ -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 } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt index 95aa31ff..80fb58ad 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt @@ -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 private var displayUpArrow = false - private val queueItems = mutableStateListOf() + private val queueItems = mutableListOf() + private val vms = mutableStateListOf() 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) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt index c20d00a5..9b69f6b8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt @@ -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() + private val results = mutableListOf() + private val vms = mutableStateListOf() 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!!) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt index 3c74ac0c..b22f3568 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt @@ -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) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt index f138bd93..1286edc1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt @@ -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) } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/ShownotesWebView.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/ShownotesWebView.kt index 6e53dba6..c6095b13 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/ShownotesWebView.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/ShownotesWebView.kt @@ -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.* diff --git a/app/src/main/res/layout/base_episodes_list_fragment.xml b/app/src/main/res/layout/base_episodes_list_fragment.xml index 77a5d2e7..c3070aaa 100644 --- a/app/src/main/res/layout/base_episodes_list_fragment.xml +++ b/app/src/main/res/layout/base_episodes_list_fragment.xml @@ -21,10 +21,10 @@ app:navigationContentDescription="@string/toolbar_back_button_content_description" app:navigationIcon="?homeAsUpIndicator" /> - + + + + diff --git a/app/src/main/res/layout/downloads_fragment.xml b/app/src/main/res/layout/downloads_fragment.xml index 3ffecb2b..2baf5dc7 100644 --- a/app/src/main/res/layout/downloads_fragment.xml +++ b/app/src/main/res/layout/downloads_fragment.xml @@ -20,10 +20,10 @@ app:navigationContentDescription="@string/toolbar_back_button_content_description" app:navigationIcon="?homeAsUpIndicator" /> - + + + + diff --git a/app/src/main/res/layout/feed_item_list_fragment.xml b/app/src/main/res/layout/feed_item_list_fragment.xml index d5e779ec..241d8f6f 100644 --- a/app/src/main/res/layout/feed_item_list_fragment.xml +++ b/app/src/main/res/layout/feed_item_list_fragment.xml @@ -25,10 +25,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content"/> - + + + + diff --git a/app/src/main/res/layout/online_feedview_fragment.xml b/app/src/main/res/layout/online_feedview_fragment.xml index 8d1010af..dc052e07 100644 --- a/app/src/main/res/layout/online_feedview_fragment.xml +++ b/app/src/main/res/layout/online_feedview_fragment.xml @@ -25,232 +25,9 @@ - + android:layout_height="wrap_content"/> - - - - - - - - - - - - - - - - - - - - -