From 89143c60d345d22bad581072d73825ffad9a2abe Mon Sep 17 00:00:00 2001 From: Xilin Jia <6257601+XilinJia@users.noreply.github.com> Date: Sun, 29 Sep 2024 22:29:20 +0100 Subject: [PATCH] 6.8.3 commit --- app/build.gradle | 4 +- .../podcini/net/download/DownloadStatus.kt | 14 +- .../service/DownloadServiceInterface.kt | 4 +- .../playback/service/PlaybackService.kt | 4 +- .../ac/mdiq/podcini/storage/model/Episode.kt | 32 +- .../podcini/storage/model/EpisodeMedia.kt | 5 +- .../CancelDownloadActionButton.kt | 4 +- .../actionbutton/DeleteActionButton.kt | 1 + .../actionbutton/DownloadActionButton.kt | 1 + .../actionbutton/EpisodeActionButton.kt | 34 +- .../actionbutton/MarkAsPlayedActionButton.kt | 1 + .../actions/actionbutton/PauseActionButton.kt | 1 + .../actions/actionbutton/PlayActionButton.kt | 3 +- .../actionbutton/PlayLocalActionButton.kt | 3 +- .../actionbutton/StreamActionButton.kt | 1 + .../actions/actionbutton/TTSActionButton.kt | 1 + .../actionbutton/VisitWebsiteActionButton.kt | 1 + .../ui/actions/swipeactions/SwipeActions.kt | 151 +- .../mdiq/podcini/ui/activity/MainActivity.kt | 22 +- .../podcini/ui/adapter/EpisodesAdapter.kt | 1002 ----------- .../ac/mdiq/podcini/ui/compose/Episodes.kt | 317 ++-- .../podcini/ui/dialog/SkipPreferenceDialog.kt | 2 +- .../ui/fragment/AudioPlayerFragment.kt | 1513 ++++++++--------- .../ui/fragment/BaseEpisodesFragment.kt | 51 +- .../podcini/ui/fragment/ChaptersFragment.kt | 8 - .../ui/fragment/DownloadLogFragment.kt | 2 +- .../ui/fragment/EpisodeInfoFragment.kt | 929 ++++++++++ .../ui/fragment/FeedEpisodesFragment.kt | 33 +- .../podcini/ui/fragment/OnlineFeedFragment.kt | 1 - .../podcini/ui/fragment/QueuesFragment.kt | 287 +--- .../podcini/ui/fragment/SearchFragment.kt | 146 +- .../ui/fragment/SubscriptionsFragment.kt | 38 +- .../mdiq/podcini/ui/view/EpisodeViewHolder.kt | 327 ---- .../kotlin/ac/mdiq/podcini/util/FlowEvent.kt | 14 + .../main/res/layout/audioplayer_fragment.xml | 77 +- .../main/res/layout/episode_info_fragment.xml | 224 +-- .../main/res/layout/feeditemlist_header.xml | 227 --- .../res/layout/fragment_subscriptions.xml | 14 +- .../res/layout/player_details_fragment.xml | 133 -- .../main/res/layout/player_ui_fragment.xml | 232 --- app/src/main/res/layout/queue_fragment.xml | 94 +- app/src/main/res/layout/search_fragment.xml | 29 +- changelog.md | 17 + .../android/en-US/changelogs/3020258.txt | 5 + .../android/en-US/changelogs/3020259.txt | 9 + 45 files changed, 2180 insertions(+), 3838 deletions(-) delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt create mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt delete mode 100644 app/src/main/res/layout/feeditemlist_header.xml delete mode 100644 app/src/main/res/layout/player_details_fragment.xml delete mode 100644 app/src/main/res/layout/player_ui_fragment.xml create mode 100644 fastlane/metadata/android/en-US/changelogs/3020258.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/3020259.txt diff --git a/app/build.gradle b/app/build.gradle index 2c78693a..0130575e 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 3020257 - versionName "6.8.0" + versionCode 3020259 + versionName "6.8.2" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/DownloadStatus.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/DownloadStatus.kt index c0067cf9..f384ab02 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/DownloadStatus.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/download/DownloadStatus.kt @@ -1,9 +1,13 @@ package ac.mdiq.podcini.net.download -class DownloadStatus(@JvmField val state: Int, @JvmField val progress: Int) { - companion object { - const val STATE_QUEUED: Int = 0 - const val STATE_COMPLETED: Int = 1 // Both successful and not successful - const val STATE_RUNNING: Int = 2 +class DownloadStatus( + @JvmField val state: Int, + @JvmField val progress: Int) { + + enum class State { + UNKNOWN, + QUEUED, + RUNNING, + COMPLETED // Both successful and not successful } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterface.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterface.kt index b30db448..669e5626 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterface.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterface.kt @@ -27,11 +27,11 @@ abstract class DownloadServiceInterface { abstract fun cancelAll(context: Context) fun isDownloadingEpisode(url: String): Boolean { - return (currentDownloads.containsKey(url) && currentDownloads[url]!!.state != DownloadStatus.STATE_COMPLETED) + return (currentDownloads.containsKey(url) && currentDownloads[url]!!.state != DownloadStatus.State.COMPLETED.ordinal) } fun isEpisodeQueued(url: String): Boolean { - return (currentDownloads.containsKey(url) && currentDownloads[url]!!.state == DownloadStatus.STATE_QUEUED) + return (currentDownloads.containsKey(url) && currentDownloads[url]!!.state == DownloadStatus.State.QUEUED.ordinal) } fun getProgress(url: String): Int { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt index 04525353..b240559e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt @@ -57,7 +57,7 @@ import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter -import ac.mdiq.podcini.ui.fragment.AudioPlayerFragment.PlayerDetailsFragment.Companion.media3Controller +import ac.mdiq.podcini.ui.fragment.AudioPlayerFragment.Companion.media3Controller import ac.mdiq.podcini.ui.utils.NotificationUtils import ac.mdiq.podcini.ui.widget.WidgetUpdater import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState @@ -1708,7 +1708,7 @@ class PlaybackService : MediaLibraryService() { when (status) { PlayerStatus.PLAYING, PlayerStatus.PAUSED, PlayerStatus.PREPARED -> { - Logd(TAG, "seekTo() called $t") + Logd(TAG, "seekTo t: $t") if (seekLatch != null && seekLatch!!.count > 0) { try { seekLatch!!.await(3, TimeUnit.SECONDS) } catch (e: InterruptedException) { Log.e(TAG, Log.getStackTraceString(e)) } } 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 21455464..0151dc30 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 @@ -1,9 +1,13 @@ package ac.mdiq.podcini.storage.model +import ac.mdiq.podcini.net.download.DownloadStatus +import ac.mdiq.podcini.playback.base.InTheatre.curQueue +import ac.mdiq.podcini.playback.base.InTheatre.isCurrentlyPlaying import ac.mdiq.podcini.storage.database.Feeds.getFeed -import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.vista.extractor.Vista import ac.mdiq.vista.extractor.stream.StreamInfo +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import io.realm.kotlin.ext.realmListOf import io.realm.kotlin.ext.realmSetOf import io.realm.kotlin.types.RealmList @@ -116,7 +120,7 @@ class Episode : RealmObject { val imageLocation: String? get() = when { imageUrl != null -> imageUrl - media != null && unmanaged(media!!).hasEmbeddedPicture() -> EpisodeMedia.FILENAME_PREFIX_EMBEDDED_COVER + media!!.getLocalMediaUrl() + media != null && media?.hasEmbeddedPicture() == true -> EpisodeMedia.FILENAME_PREFIX_EMBEDDED_COVER + media!!.getLocalMediaUrl() feed != null -> { feed!!.imageUrl } @@ -133,6 +137,18 @@ class Episode : RealmObject { return field } + @Ignore + val inQueueState = mutableStateOf(curQueue.contains(this)) + + @Ignore + val isPlayingState = mutableStateOf(isCurrentlyPlaying(media)) + + @Ignore + val downloadState = mutableIntStateOf(if (media?.downloaded == true) DownloadStatus.State.COMPLETED.ordinal else DownloadStatus.State.UNKNOWN.ordinal) + + @Ignore + val stopMonitoring = mutableStateOf(false) + constructor() { this.playState = PlayState.UNPLAYED.code } @@ -151,6 +167,13 @@ 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 @@ -256,12 +279,11 @@ class Episode : RealmObject { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Episode) return false - return id == other.id && playState == other.playState + return id == other.id } override fun hashCode(): Int { - var result = (id xor (id ushr 32)).toInt() - result = 31 * result + playState.hashCode() + val result = (id xor (id ushr 32)).toInt() return result } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt index 782db8ee..0cb90739 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt @@ -5,6 +5,7 @@ import ac.mdiq.podcini.storage.database.RealmDB.unmanaged import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.utils.MediaMetadataRetrieverCompat import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.showStackTrace import android.content.Context import android.os.Parcel import android.os.Parcelable @@ -208,7 +209,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable { fun hasEmbeddedPicture(): Boolean { // TODO: checkEmbeddedPicture needs to update current copy - if (hasEmbeddedPicture == null) checkEmbeddedPicture() + if (hasEmbeddedPicture == null) unmanaged(this).checkEmbeddedPicture() return hasEmbeddedPicture ?: false } @@ -303,7 +304,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable { override fun getImageLocation(): String? { return when { episode != null -> episode!!.imageLocation - unmanaged(this).hasEmbeddedPicture() -> FILENAME_PREFIX_EMBEDDED_COVER + getLocalMediaUrl() + hasEmbeddedPicture() -> FILENAME_PREFIX_EMBEDDED_COVER + getLocalMediaUrl() else -> null } } 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 index 727c2369..42022321 100644 --- 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 @@ -30,6 +30,8 @@ class CancelDownloadActionButton(item: Episode) : EpisodeActionButton(item) { val item_ = upsertBlk(item) { it.disableAutoDownload() } - EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item_)) } + 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 index d2b7031f..d103295d 100644 --- 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 @@ -22,5 +22,6 @@ class DeleteActionButton(item: Episode) : EpisodeActionButton(item) { } @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 index b65ad2c9..b45342f4 100644 --- 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 @@ -43,6 +43,7 @@ class DownloadActionButton(item: Episode) : EpisodeActionButton(item) { builder.show() } + actionState.value = getLabel() } private fun shouldNotDownload(media: EpisodeMedia?): Boolean { 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 index c16cc671..a9ba5744 100644 --- 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 @@ -35,28 +35,30 @@ abstract class EpisodeActionButton internal constructor(@JvmField var item: Epis 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()) - } +// 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) { 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 index 3ff23d0f..904dcbbc 100644 --- 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 @@ -21,6 +21,7 @@ class MarkAsPlayedActionButton(item: Episode) : EpisodeActionButton(item) { @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 index b1a355f7..0508bfdf 100644 --- 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 @@ -22,5 +22,6 @@ class PauseActionButton(item: Episode) : EpisodeActionButton(item) { 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 index d09ace11..a6f2cb49 100644 --- 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 @@ -38,7 +38,6 @@ class PlayActionButton(item: Episode) : EpisodeActionButton(item) { notifyMissingEpisodeMediaFile(context, media) return } - if (playbackService?.isServiceReady() == true && InTheatre.isCurMedia(media)) { playbackService?.mPlayer?.resume() playbackService?.taskManager?.restartSleepTimer() @@ -47,8 +46,8 @@ class PlayActionButton(item: Episode) : EpisodeActionButton(item) { PlaybackServiceStarter(context, media).callEvenIfRunning(true).start() EventFlow.postEvent(FlowEvent.PlayEvent(item)) } - playVideoIfNeeded(context, media) + actionState.value = getLabel() } /** 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 index 04c3c40e..a1aa3cff 100644 --- 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 @@ -29,7 +29,6 @@ class PlayLocalActionButton(item: Episode) : EpisodeActionButton(item) { 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() @@ -38,7 +37,7 @@ class PlayLocalActionButton(item: Episode) : EpisodeActionButton(item) { 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 index 7697e174..cee3b311 100644 --- 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 @@ -38,6 +38,7 @@ class StreamActionButton(item: Episode) : EpisodeActionButton(item) { return } stream(context, media) + actionState.value = getLabel() } class StreamingConfirmationDialog(private val context: Context, private val playable: Playable) { 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 index f03e0a7c..1dd3e5b6 100644 --- 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 @@ -157,6 +157,7 @@ class TTSActionButton(item: Episode) : EpisodeActionButton(item) { 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 index faaf63b4..c71d3012 100644 --- 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 @@ -20,5 +20,6 @@ class VisitWebsiteActionButton(item: Episode) : EpisodeActionButton(item) { 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/actions/swipeactions/SwipeActions.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt index efa9337a..6bfafb79 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/SwipeActions.kt @@ -22,53 +22,41 @@ import ac.mdiq.podcini.ui.actions.actionbutton.DownloadActionButton import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction.Companion.NO_ACTION import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.dialog.SwipeActionsDialog -import ac.mdiq.podcini.ui.fragment.* +import ac.mdiq.podcini.ui.fragment.AllEpisodesFragment +import ac.mdiq.podcini.ui.fragment.DownloadsFragment +import ac.mdiq.podcini.ui.fragment.HistoryFragment +import ac.mdiq.podcini.ui.fragment.QueuesFragment import ac.mdiq.podcini.ui.utils.LocalDeleteModal.deleteEpisodesWarnLocal -import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr -import ac.mdiq.podcini.ui.view.EpisodeViewHolder import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.Logd import android.content.Context import android.content.SharedPreferences -import android.graphics.Canvas import android.os.Handler import androidx.annotation.OptIn -import androidx.core.graphics.ColorUtils import androidx.fragment.app.Fragment -import androidx.lifecycle.* +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.media3.common.util.UnstableApi import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView import com.annimon.stream.Stream import com.google.android.material.snackbar.Snackbar -import it.xabaras.android.recyclerview.swipedecorator.RecyclerViewSwipeDecorator import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.util.* import kotlin.math.ceil -import kotlin.math.max -import kotlin.math.min -import kotlin.math.sin -open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private val tag: String) - : ItemTouchHelper.SimpleCallback(dragDirs, ItemTouchHelper.RIGHT or ItemTouchHelper.LEFT), DefaultLifecycleObserver { +open class SwipeActions(private val fragment: Fragment, private val tag: String) : DefaultLifecycleObserver { @set:JvmName("setFilterProperty") var filter: EpisodeFilter? = null - var actions: Actions? = null - var swipeOutEnabled: Boolean = true - var swipedOutTo: Int = 0 - private val itemTouchHelper = ItemTouchHelper(this) init { actions = getPrefs(tag) } - constructor(fragment: Fragment, tag: String) : this(0, fragment, tag) - override fun onStart(owner: LifecycleOwner) { actions = getPrefs(tag) } @@ -82,32 +70,6 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v this.filter = filter } - fun attachTo(recyclerView: RecyclerView?): SwipeActions { - itemTouchHelper.attachToRecyclerView(recyclerView) - return this - } - - fun detach() { - itemTouchHelper.attachToRecyclerView(null) - } - - private val isSwipeActionEnabled: Boolean - get() = isSwipeActionEnabled(tag) - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - return false - } - - @UnstableApi override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { - if (actions != null && !actions!!.hasActions(swipeDir)) { - showDialog() - return - } - val item = (viewHolder as EpisodeViewHolder).episode - if (actions != null && item != null && filter != null) - (if (swipeDir == ItemTouchHelper.RIGHT) actions!!.right else actions!!.left)?.performAction(item, fragment, filter!!) - } - fun showDialog() { SwipeActionsDialog(fragment.requireContext(), tag).show(object : SwipeActionsDialog.Callback { override fun onCall() { @@ -117,93 +79,6 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v }) } - @UnstableApi - override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, - dx: Float, dy: Float, actionState: Int, isCurrentlyActive: Boolean) { - var dx = dx - val right: SwipeAction - val left: SwipeAction - if (actions != null && actions!!.hasActions()) { - right = actions!!.right!! - left = actions!!.left!! - } else { - left = ShowFirstSwipeDialogAction() - right = left - } - - //check if it will be removed - val item = (viewHolder as EpisodeViewHolder).episode - var wontLeave = false - if (item != null && filter != null) { - val rightWillRemove = right.willRemove(filter!!, item) - val leftWillRemove = left.willRemove(filter!!, item) - wontLeave = (dx > 0 && !rightWillRemove) || (dx < 0 && !leftWillRemove) - } - //Limit swipe if it's not removed - val maxMovement = recyclerView.width * 2 / 5 - val sign = (if (dx > 0) 1 else -1).toFloat() - val limitMovement = min(maxMovement.toDouble(), (sign * dx).toDouble()).toFloat() - val displacementPercentage = limitMovement / maxMovement - - if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && wontLeave) { - swipeOutEnabled = false - val swipeThresholdReached = displacementPercentage == 1f - - // Move slower when getting near the maxMovement - dx = sign * maxMovement * sin((Math.PI / 2) * displacementPercentage).toFloat() - - if (isCurrentlyActive) { - val dir = if (dx > 0) ItemTouchHelper.RIGHT else ItemTouchHelper.LEFT - swipedOutTo = if (swipeThresholdReached) dir else 0 - } - } else swipeOutEnabled = true - - //add color and icon - val context = fragment.requireContext() - val themeColor = getColorFromAttr(context, android.R.attr.colorBackground) - val actionColor = getColorFromAttr(context, if (dx > 0) right.getActionColor() else left.getActionColor()) - val builder = RecyclerViewSwipeDecorator.Builder(c, recyclerView, viewHolder, dx, dy, actionState, isCurrentlyActive) - .addSwipeRightActionIcon(right.getActionIcon()) - .addSwipeLeftActionIcon(left.getActionIcon()) - .addSwipeRightBackgroundColor(getColorFromAttr(context, R.attr.background_elevated)) - .addSwipeLeftBackgroundColor(getColorFromAttr(context, R.attr.background_elevated)) - .setActionIconTint(ColorUtils.blendARGB(themeColor, actionColor, max(0.5, displacementPercentage.toDouble()).toFloat())) - builder.create().decorate() - - super.onChildDraw(c, recyclerView, viewHolder, dx, dy, actionState, isCurrentlyActive) - } - - override fun getSwipeEscapeVelocity(defaultValue: Float): Float { - return if (swipeOutEnabled) defaultValue * 1.5f else Float.MAX_VALUE - } - - override fun getSwipeVelocityThreshold(defaultValue: Float): Float { - return if (swipeOutEnabled) defaultValue * 0.6f else 0f - } - - override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float { - return if (swipeOutEnabled) 0.6f else 1.0f - } - - @UnstableApi - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - super.clearView(recyclerView, viewHolder) - - if (swipedOutTo != 0) { - onSwiped(viewHolder, swipedOutTo) - swipedOutTo = 0 - } - } - - override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { - return if (!isSwipeActionEnabled) makeMovementFlags(getDragDirs(recyclerView, viewHolder), 0) - else super.getMovementFlags(recyclerView, viewHolder) - } - - fun startDrag(holder: EpisodeViewHolder?) { - if (holder != null) itemTouchHelper.startDrag(holder) - } - class Actions(prefs: String?) { @JvmField var right: SwipeAction? = null @@ -259,11 +134,11 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v @OptIn(UnstableApi::class) @JvmStatic fun getPrefsWithDefaults(tag: String): Actions { val defaultActions = when (tag) { - QueuesFragment.TAG -> NO_ACTION + "," + NO_ACTION - DownloadsFragment.TAG -> NO_ACTION + "," + NO_ACTION - HistoryFragment.TAG -> NO_ACTION + "," + NO_ACTION - AllEpisodesFragment.TAG -> NO_ACTION + "," + NO_ACTION - else -> NO_ACTION + "," + NO_ACTION + QueuesFragment.TAG -> "$NO_ACTION,$NO_ACTION" + DownloadsFragment.TAG -> "$NO_ACTION,$NO_ACTION" + HistoryFragment.TAG -> "$NO_ACTION,$NO_ACTION" + AllEpisodesFragment.TAG -> "$NO_ACTION,$NO_ACTION" + else -> "$NO_ACTION,$NO_ACTION" } return getPrefs(tag, defaultActions) } @@ -358,7 +233,7 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v class NoActionSwipeAction : SwipeAction { override fun getId(): String { - return SwipeAction.NO_ACTION + return NO_ACTION } override fun getActionIcon(): Int { 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 36b92b57..021cddd9 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 @@ -27,7 +27,7 @@ import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter import ac.mdiq.podcini.ui.dialog.RatingDialog import ac.mdiq.podcini.ui.fragment.* -import ac.mdiq.podcini.ui.fragment.AudioPlayerFragment.PlayerDetailsFragment.Companion.media3Controller +import ac.mdiq.podcini.ui.fragment.AudioPlayerFragment.Companion.media3Controller import ac.mdiq.podcini.ui.statistics.StatisticsFragment import ac.mdiq.podcini.ui.utils.LockableBottomSheetBehavior import ac.mdiq.podcini.ui.utils.ThemeUtils.getDrawableFromAttr @@ -58,6 +58,7 @@ import android.view.ViewGroup.MarginLayoutParams import android.widget.EditText import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.compose.ui.platform.ComposeView import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat @@ -182,7 +183,7 @@ class MainActivity : CastEnabledActivity() { buildTags() monitorFeeds() // InTheatre.apply { } - AudioPlayerFragment.PlayerDetailsFragment.getSharedPrefs(this@MainActivity) + AudioPlayerFragment.getSharedPrefs(this@MainActivity) PlayerWidget.getSharedPrefs(this@MainActivity) StatisticsFragment.getSharedPrefs(this@MainActivity) OnlineFeedFragment.getSharedPrefs(this@MainActivity) @@ -283,21 +284,21 @@ class MainActivity : CastEnabledActivity() { // Logd(TAG, "workInfo.state: ${workInfo.state}") var status: Int status = when (workInfo.state) { - WorkInfo.State.RUNNING -> DownloadStatus.STATE_RUNNING - WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> DownloadStatus.STATE_QUEUED - WorkInfo.State.SUCCEEDED -> DownloadStatus.STATE_COMPLETED + WorkInfo.State.RUNNING -> DownloadStatus.State.RUNNING.ordinal + WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> DownloadStatus.State.QUEUED.ordinal + WorkInfo.State.SUCCEEDED -> DownloadStatus.State.COMPLETED.ordinal WorkInfo.State.FAILED -> { Log.e(TAG, "download failed $downloadUrl") - DownloadStatus.STATE_COMPLETED + DownloadStatus.State.COMPLETED.ordinal } WorkInfo.State.CANCELLED -> { Logd(TAG, "download cancelled $downloadUrl") - DownloadStatus.STATE_COMPLETED + DownloadStatus.State.COMPLETED.ordinal } } var progress = workInfo.progress.getInt(DownloadServiceInterface.WORK_DATA_PROGRESS, -1) - if (progress == -1 && status != DownloadStatus.STATE_COMPLETED) { - status = DownloadStatus.STATE_QUEUED + if (progress == -1 && status != DownloadStatus.State.COMPLETED.ordinal) { + status = DownloadStatus.State.QUEUED.ordinal progress = 0 } updatedEpisodes[downloadUrl] = DownloadStatus(status, progress) @@ -398,7 +399,8 @@ class MainActivity : CastEnabledActivity() { params.setMargins(navigationBarInsets.left, 0, navigationBarInsets.right, navigationBarInsets.bottom + (if (visible) externalPlayerHeight else 0)) mainView.layoutParams = params - val playerView = findViewById(R.id.playerFragment1) +// val playerView = findViewById(R.id.playerFragment1) + val playerView = findViewById(R.id.composeView1) val playerParams = playerView?.layoutParams as? MarginLayoutParams playerParams?.setMargins(navigationBarInsets.left, 0, navigationBarInsets.right, 0) playerView?.layoutParams = playerParams diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt deleted file mode 100644 index 1be036a3..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt +++ /dev/null @@ -1,1002 +0,0 @@ -package ac.mdiq.podcini.ui.adapter - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.EpisodeHomeFragmentBinding -import ac.mdiq.podcini.databinding.EpisodeInfoFragmentBinding -import ac.mdiq.podcini.net.download.service.DownloadServiceInterface -import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient -import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource -import ac.mdiq.podcini.net.utils.NetworkUtils.isEpisodeHeadDownloadAllowed -import ac.mdiq.podcini.playback.base.InTheatre -import ac.mdiq.podcini.playback.base.InTheatre.curMedia -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo -import ac.mdiq.podcini.preferences.UsageStatistics -import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope -import ac.mdiq.podcini.storage.database.RealmDB.unmanaged -import ac.mdiq.podcini.storage.database.RealmDB.upsert -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.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.activity.MainActivity -import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment -import ac.mdiq.podcini.ui.fragment.FeedInfoFragment -import ac.mdiq.podcini.ui.utils.ShownotesCleaner -import ac.mdiq.podcini.ui.utils.ThemeUtils -import ac.mdiq.podcini.ui.view.EpisodeViewHolder -import ac.mdiq.podcini.ui.view.ShownotesWebView -import ac.mdiq.podcini.util.EventFlow -import ac.mdiq.podcini.util.FlowEvent -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev -import ac.mdiq.podcini.util.MiscFormatter.formatForAccessibility -import android.R.color -import android.app.Activity -import android.content.Context -import android.os.Build -import android.os.Bundle -import android.speech.tts.TextToSpeech -import android.text.Layout -import android.text.TextUtils -import android.text.format.Formatter.formatShortFileSize -import android.util.Log -import android.view.* -import android.webkit.WebView -import android.webkit.WebViewClient -import android.widget.Button -import android.widget.ImageView -import android.widget.TextView -import android.widget.Toast -import androidx.annotation.OptIn -import androidx.appcompat.widget.Toolbar -import androidx.core.app.ShareCompat -import androidx.core.text.HtmlCompat -import androidx.core.view.MenuProvider -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.media3.common.util.UnstableApi -import coil.imageLoader -import coil.request.ErrorResult -import coil.request.ImageRequest -import com.google.android.material.appbar.MaterialToolbar -import com.google.android.material.snackbar.Snackbar -import com.skydoves.balloon.ArrowOrientation -import com.skydoves.balloon.ArrowOrientationRules -import com.skydoves.balloon.Balloon -import com.skydoves.balloon.BalloonAnimation -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import net.dankito.readability4j.extended.Readability4JExtended -import okhttp3.Request.Builder -import java.io.File -import java.lang.ref.WeakReference -import java.util.* -import kotlin.math.max - -/** - * List adapter for the list of new episodes. - */ -open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallback: ((Int, Episode) -> Unit)? = null) - : SelectableAdapter(mainActivity) { - private val TAG: String = this::class.simpleName ?: "Anonymous" - - val mainActivityRef: WeakReference = WeakReference(mainActivity) - protected val activity: Activity? - get() = mainActivityRef.get() - - private var episodes: MutableList = ArrayList() - private var feed: Feed? = null - var longPressedItem: Episode? = null - private var longPressedPosition: Int = 0 // used to init actionMode - private var dummyViews = 0 - - val selectedItems: List - get() { - val items: MutableList = ArrayList() - for (i in 0 until itemCount) { - if (i < episodes.size && isSelected(i)) { - val item = getItem(i) - if (item != null) items.add(item) - } - } - return items - } - - init { - setHasStableIds(true) - } - - @UnstableApi - fun refreshPosCallback(pos: Int, episode: Episode) { - Logd(TAG, "refreshPosCallback: $pos ${episode.title}") - if (pos >= 0 && pos < episodes.size && episodes[pos].id == episode.id) { - episodes[pos] = episode -// notifyItemChanged(pos, "foo") - refreshFragPosCallback?.invoke(pos, episode) - } - } - - fun clearData() { - episodes = mutableListOf() - feed = null - notifyDataSetChanged() - } - - fun updateItems(items: MutableList, feed_: Feed? = null) { - episodes = items - feed = feed_ - notifyDataSetChanged() - updateTitle() - } - - override fun getItemViewType(position: Int): Int { - return R.id.view_type_episode_item - } - - @UnstableApi override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder { -// TODO: the Invalid resource ID 0x00000000 on Android 14 occurs after this and before onBindViewHolder, -// somehow, only on the first time EpisodeItemListAdapter is called - return EpisodeViewHolder(mainActivityRef.get()!!, parent) - } - - @UnstableApi - override fun onBindViewHolder(holder: EpisodeViewHolder, pos: Int) { - Logd(TAG, "onBindViewHolder $pos ${episodes[pos].title}") - if (pos >= episodes.size || pos < 0) { - Logd(TAG, "onBindViewHolder got invalid pos: $pos of ${episodes.size}") - return - } - - holder.refreshAdapterPosCallback = ::refreshPosCallback - holder.setPosIndex(pos) - - // Reset state of recycled views - holder.coverHolder.visibility = View.VISIBLE - holder.dragHandle.visibility = View.GONE - - beforeBindViewHolder(holder, pos) - - val item: Episode = episodes[pos] - item.feed = feed ?: episodes[pos].feed - holder.bind(item) - - holder.infoCard.setOnLongClickListener { - longPressedItem = holder.episode - longPressedPosition = holder.bindingAdapterPosition - startSelectMode(longPressedPosition) - true - } - holder.infoCard.setOnClickListener { - val activity: MainActivity? = mainActivityRef.get() - if (!inActionMode() && holder.episode != null) activity?.loadChildFragment(EpisodeInfoFragment.newInstance(holder.episode!!)) - else toggleSelection(holder.bindingAdapterPosition) - } - holder.coverHolder.setOnClickListener { - val activity: MainActivity? = mainActivityRef.get() - if (!inActionMode() && holder.episode?.feed != null) activity?.loadChildFragment(FeedInfoFragment.newInstance(holder.episode!!.feed!!)) - else toggleSelection(holder.bindingAdapterPosition) - } - holder.itemView.setOnTouchListener(View.OnTouchListener { _: View?, e: MotionEvent -> - if (e.isFromSource(InputDevice.SOURCE_MOUSE) && e.buttonState == MotionEvent.BUTTON_SECONDARY) { - longPressedItem = holder.episode - longPressedPosition = holder.bindingAdapterPosition - return@OnTouchListener false - } - false - }) - if (inActionMode()) { - holder.secondaryActionButton.setOnClickListener(null) - if (isSelected(pos)) - holder.itemView.setBackgroundColor(-0x78000000 + (0xffffff and ThemeUtils.getColorFromAttr(mainActivityRef.get()!!, androidx.appcompat.R.attr.colorAccent))) - else holder.itemView.setBackgroundResource(color.transparent) - } - - afterBindViewHolder(holder, pos) - holder.hideSeparatorIfNecessary() - } - - @UnstableApi - override fun onBindViewHolder(holder: EpisodeViewHolder, pos: Int, payloads: MutableList) { -// Logd(TAG, "onBindViewHolder payloads $pos ${holder.episode?.title}") - if (payloads.isEmpty()) onBindViewHolder(holder, pos) - else { - holder.refreshAdapterPosCallback = ::refreshPosCallback - val payload = payloads[0] - when { - payload is String && payload == "foo" -> onBindViewHolder(holder, pos) - payload is Bundle && !payload.getString("PositionUpdate").isNullOrEmpty() -> holder.updatePlaybackPositionNew(episodes[pos]) - } - } - } - - protected open fun beforeBindViewHolder(holder: EpisodeViewHolder, pos: Int) {} - - protected open fun afterBindViewHolder(holder: EpisodeViewHolder, pos: Int) {} - - @UnstableApi override fun onViewRecycled(holder: EpisodeViewHolder) { - super.onViewRecycled(holder) - holder.refreshAdapterPosCallback = null - holder.unbind() - } - - /** - * [.notifyItemChanged] is final, so we can not override. - * Calling [.notifyItemChanged] may bind the item to a new ViewHolder and execute a transition. - * This causes flickering and breaks the download animation that stores the old progress in the View. - * Instead, we tell the adapter to use partial binding by calling [.notifyItemChanged]. - * We actually ignore the payload and always do a full bind but calling the partial bind method ensures - * that ViewHolders are always re-used. - * @param position Position of the item that has changed - */ - fun notifyItemChangedCompat(position: Int) { - notifyItemChanged(position, "foo") - } - - override fun getItemId(position: Int): Long { - return getItem(position)?.id ?: 0L - } - - override fun getItemCount(): Int { - return dummyViews + episodes.size - } - - protected fun getItem(index: Int): Episode? { - val item = if (index in episodes.indices) episodes[index] else null - return item - } - - /** - * Displays information about an Episode (FeedItem) and actions. - */ - @UnstableApi class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { - private var _binding: EpisodeInfoFragmentBinding? = null - private val binding get() = _binding!! - - private var homeFragment: EpisodeHomeFragment? = null - - private var itemLoaded = false - private var episode: Episode? = null // managed - - private var webviewData: String = "" - - private lateinit var shownotesCleaner: ShownotesCleaner - private lateinit var toolbar: MaterialToolbar - private lateinit var webvDescription: ShownotesWebView - private lateinit var imgvCover: ImageView - - private lateinit var butAction1: ImageView - private lateinit var butAction2: ImageView - - private var actionButton1: EpisodeActionButton? = null - private var actionButton2: EpisodeActionButton? = null - - @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - super.onCreateView(inflater, container, savedInstanceState) - - _binding = EpisodeInfoFragmentBinding.inflate(inflater, container, false) - Logd(TAG, "fragment onCreateView") - - toolbar = binding.toolbar - toolbar.title = "" - toolbar.inflateMenu(R.menu.feeditem_options) - toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } - toolbar.setOnMenuItemClickListener(this) - - binding.txtvPodcast.setOnClickListener { openPodcast() } - binding.txtvTitle.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL) - binding.txtvTitle.ellipsize = TextUtils.TruncateAt.END - webvDescription = binding.webvDescription - webvDescription.setTimecodeSelectedListener { time: Int? -> - val cMedia = curMedia - if (episode?.media?.getIdentifier() == cMedia?.getIdentifier()) seekTo(time ?: 0) - else (activity as MainActivity).showSnackbarAbovePlayer(R.string.play_this_to_seek_position, Snackbar.LENGTH_LONG) - } - registerForContextMenu(webvDescription) - - imgvCover = binding.imgvCover - imgvCover.setOnClickListener { openPodcast() } - butAction1 = binding.butAction1 - butAction2 = binding.butAction2 - - binding.homeButton.setOnClickListener { - if (!episode?.link.isNullOrEmpty()) { - homeFragment = EpisodeHomeFragment.newInstance(episode!!) - (activity as MainActivity).loadChildFragment(homeFragment!!) - } else Toast.makeText(context, "Episode link is not valid ${episode?.link}", Toast.LENGTH_LONG).show() - } - - butAction1.setOnClickListener(View.OnClickListener { - when { - actionButton1 is StreamActionButton && !UserPreferences.isStreamOverDownload - && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_STREAM) -> { - showOnDemandConfigBalloon(true) - return@OnClickListener - } - actionButton1 == null -> return@OnClickListener // Not loaded yet - else -> actionButton1?.onClick(requireContext()) - } - }) - butAction2.setOnClickListener(View.OnClickListener { - when { - actionButton2 is DownloadActionButton && UserPreferences.isStreamOverDownload - && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_DOWNLOAD) -> { - showOnDemandConfigBalloon(false) - return@OnClickListener - } - actionButton2 == null -> return@OnClickListener // Not loaded yet - else -> actionButton2?.onClick(requireContext()) - } - }) - shownotesCleaner = ShownotesCleaner(requireContext()) - onFragmentLoaded() - load() - return binding.root - } - - override fun onStart() { - super.onStart() - procFlowEvents() - } - - override fun onStop() { - super.onStop() - cancelFlowEvents() - } - - @OptIn(UnstableApi::class) - private fun showOnDemandConfigBalloon(offerStreaming: Boolean) { - val isLocaleRtl = (TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL) - val balloon: Balloon = Balloon.Builder(requireContext()) - .setArrowOrientation(ArrowOrientation.TOP) - .setArrowOrientationRules(ArrowOrientationRules.ALIGN_FIXED) - .setArrowPosition(0.25f + (if (isLocaleRtl xor offerStreaming) 0f else 0.5f)) - .setWidthRatio(1.0f) - .setMarginLeft(8) - .setMarginRight(8) - .setBackgroundColor(ThemeUtils.getColorFromAttr(requireContext(), com.google.android.material.R.attr.colorSecondary)) - .setBalloonAnimation(BalloonAnimation.OVERSHOOT) - .setLayout(R.layout.popup_bubble_view) - .setDismissWhenTouchOutside(true) - .setLifecycleOwner(this) - .build() - val ballonView = balloon.getContentView() - val positiveButton: Button = ballonView.findViewById(R.id.balloon_button_positive) - val negativeButton: Button = ballonView.findViewById(R.id.balloon_button_negative) - val message: TextView = ballonView.findViewById(R.id.balloon_message) - message.setText(if (offerStreaming) R.string.on_demand_config_stream_text - else R.string.on_demand_config_download_text) - positiveButton.setOnClickListener { - UserPreferences.isStreamOverDownload = offerStreaming - // Update all visible lists to reflect new streaming action button - // TODO: need another event type? - EventFlow.postEvent(FlowEvent.EpisodePlayedEvent()) - (activity as MainActivity).showSnackbarAbovePlayer(R.string.on_demand_config_setting_changed, Snackbar.LENGTH_SHORT) - balloon.dismiss() - } - negativeButton.setOnClickListener { - UsageStatistics.doNotAskAgain(UsageStatistics.ACTION_STREAM) // Type does not matter. Both are silenced. - balloon.dismiss() - } - balloon.showAlignBottom(butAction1, 0, (-12 * resources.displayMetrics.density).toInt()) - } - - @UnstableApi override fun onMenuItemClick(menuItem: MenuItem): Boolean { - when (menuItem.itemId) { - R.id.share_notes -> { - if (episode == null) return false - val notes = episode!!.description - if (!notes.isNullOrEmpty()) { - val shareText = if (Build.VERSION.SDK_INT >= 24) HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() - else HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() - val context = requireContext() - val intent = ShareCompat.IntentBuilder(context) - .setType("text/plain") - .setText(shareText) - .setChooserTitle(R.string.share_notes_label) - .createChooserIntent() - context.startActivity(intent) - } - return true - } - else -> { - if (episode == null) return false - return EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, episode!!) - } - } - } - - @UnstableApi override fun onResume() { - super.onResume() - if (itemLoaded) { - binding.progbarLoading.visibility = View.GONE - updateAppearance() - } - } - - @OptIn(UnstableApi::class) override fun onDestroyView() { - Logd(TAG, "onDestroyView") - binding.root.removeView(webvDescription) - episode = null - webvDescription.clearHistory() - webvDescription.clearCache(true) - webvDescription.clearView() - webvDescription.destroy() - _binding = null - super.onDestroyView() - } - - @UnstableApi private fun onFragmentLoaded() { - if (!itemLoaded) - webvDescription.loadDataWithBaseURL("https://127.0.0.1", webviewData, "text/html", "utf-8", "about:blank") -// if (item?.link != null) binding.webView.loadUrl(item!!.link!!) - updateAppearance() - } - - private fun prepareMenu() { - if (episode!!.media != null) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast) - // these are already available via button1 and button2 - else EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast, R.id.mark_read_item, R.id.visit_website_item) - } - - @UnstableApi private fun updateAppearance() { - if (episode == null) { - Logd(TAG, "updateAppearance item is null") - return - } - prepareMenu() - - if (episode!!.feed != null) binding.txtvPodcast.text = episode!!.feed!!.title - binding.txtvTitle.text = episode!!.title - binding.itemLink.text = episode!!.link - - if (episode?.pubDate != null) { - val pubDateStr = formatAbbrev(context, Date(episode!!.pubDate)) - binding.txtvPublished.text = pubDateStr - binding.txtvPublished.setContentDescription(formatForAccessibility(Date(episode!!.pubDate))) - } - - val media = episode?.media - when { - media == null -> binding.txtvSize.text = "" - media.size > 0 -> binding.txtvSize.text = formatShortFileSize(activity, media.size) - isEpisodeHeadDownloadAllowed && !media.checkedOnSizeButUnknown() -> { - binding.txtvSize.text = "{faw_spinner}" -// Iconify.addIcons(size) - lifecycleScope.launch { - val sizeValue = getMediaSize(episode) - if (sizeValue <= 0) binding.txtvSize.text = "" - else binding.txtvSize.text = formatShortFileSize(activity, sizeValue) - } - } - else -> binding.txtvSize.text = "" - } - - val imgLocFB = ImageResourceUtils.getFallbackImageLocation(episode!!) - val imageLoader = imgvCover.context.imageLoader - val imageRequest = ImageRequest.Builder(requireContext()) - .data(episode!!.imageLocation) - .placeholder(R.color.light_gray) - .listener(object : ImageRequest.Listener { - override fun onError(request: ImageRequest, result: ErrorResult) { - val fallbackImageRequest = ImageRequest.Builder(requireContext()) - .data(imgLocFB) - .setHeader("User-Agent", "Mozilla/5.0") - .error(R.mipmap.ic_launcher) - .target(imgvCover) - .build() - imageLoader.enqueue(fallbackImageRequest) - } - }) - .target(imgvCover) - .build() - imageLoader.enqueue(imageRequest) - - updateButtons() - } - - @UnstableApi private fun updateButtons() { - binding.circularProgressBar.visibility = View.GONE - val dls = DownloadServiceInterface.get() - if (episode != null && episode!!.media != null && episode!!.media!!.downloadUrl != null) { - val url = episode!!.media!!.downloadUrl!! - if (dls != null && dls.isDownloadingEpisode(url)) { - binding.circularProgressBar.visibility = View.VISIBLE - binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), episode) - binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url)) - } - } - - val media: EpisodeMedia? = episode?.media - if (media == null) { - if (episode != null) { -// actionButton1 = VisitWebsiteActionButton(item!!) - butAction1.visibility = View.INVISIBLE - actionButton2 = VisitWebsiteActionButton(episode!!) - } - binding.noMediaLabel.visibility = View.VISIBLE - } else { - binding.noMediaLabel.visibility = View.GONE - if (media.getDuration() > 0) { - binding.txtvDuration.text = DurationConverter.getDurationStringLong(media.getDuration()) - binding.txtvDuration.setContentDescription(DurationConverter.getDurationStringLocalized(requireContext(), media.getDuration().toLong())) - } - if (episode != null) { - actionButton1 = when { -// media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode!!) - InTheatre.isCurrentlyPlaying(media) -> PauseActionButton(episode!!) - episode!!.feed != null && episode!!.feed!!.isLocalFeed -> PlayLocalActionButton(episode!!) - media.downloaded -> PlayActionButton(episode!!) - else -> StreamActionButton(episode!!) - } - actionButton2 = when { - episode!!.feed?.type == Feed.FeedType.YOUTUBE.name -> VisitWebsiteActionButton(episode!!) - dls != null && media.downloadUrl != null && dls.isDownloadingEpisode(media.downloadUrl!!) -> CancelDownloadActionButton(episode!!) - !media.downloaded -> DownloadActionButton(episode!!) - else -> DeleteActionButton(episode!!) - } -// if (actionButton2 != null && media.getMediaType() == MediaType.FLASH) actionButton2!!.visibility = View.GONE - } - } - - if (actionButton1 != null) { - butAction1.setImageResource(actionButton1!!.getDrawable()) - butAction1.visibility = actionButton1!!.visibility - } - if (actionButton2 != null) { - butAction2.setImageResource(actionButton2!!.getDrawable()) - butAction2.visibility = actionButton2!!.visibility - } - } - - override fun onContextItemSelected(item: MenuItem): Boolean { - return webvDescription.onContextItemSelected(item) - } - - @OptIn(UnstableApi::class) private fun openPodcast() { - if (episode?.feedId == null) return - - val fragment: Fragment = FeedEpisodesFragment.newInstance(episode!!.feedId!!) - (activity as MainActivity).loadChildFragment(fragment) - } - - private var eventSink: Job? = null - private var eventStickySink: Job? = null - private fun cancelFlowEvents() { - eventSink?.cancel() - eventSink = null - eventStickySink?.cancel() - eventStickySink = null - } - private fun procFlowEvents() { - if (eventSink == null) eventSink = lifecycleScope.launch { - EventFlow.events.collectLatest { event -> - Logd(TAG, "Received event: ${event.TAG}") - when (event) { - is FlowEvent.QueueEvent -> onQueueEvent(event) - is FlowEvent.FavoritesEvent -> onFavoriteEvent(event) - is FlowEvent.EpisodeEvent -> onEpisodeEvent(event) - is FlowEvent.PlayerSettingsEvent -> updateButtons() - is FlowEvent.EpisodePlayedEvent -> load() - else -> {} - } - } - } - if (eventStickySink == null) eventStickySink = lifecycleScope.launch { - EventFlow.stickyEvents.collectLatest { event -> - Logd(TAG, "Received event: ${event.TAG}") - when (event) { - is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event) - else -> {} - } - } - } - } - - private fun onFavoriteEvent(event: FlowEvent.FavoritesEvent) { - if (episode?.id == event.episode.id) { - episode = unmanaged(episode!!) - episode!!.isFavorite = event.episode.isFavorite -// episode = event.episode - prepareMenu() - } - } - - private fun onQueueEvent(event: FlowEvent.QueueEvent) { - var i = 0 - val size: Int = event.episodes.size - while (i < size) { - val item_ = event.episodes[i] - if (item_.id == episode?.id) { -// episode = unmanaged(item_) -// episode = item_ - prepareMenu() - break - } - i++ - } - } - - private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) { -// Logd(TAG, "onEventMainThread() called with ${event.TAG}") - if (this.episode == null) return - for (item in event.episodes) { - if (this.episode!!.id == item.id) { - load() - return - } - } - } - - private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { - if (episode == null || episode!!.media == null) return - if (!event.urls.contains(episode!!.media!!.downloadUrl)) return - if (itemLoaded && activity != null) updateButtons() - } - - private var loadItemsRunning = false - @UnstableApi private fun load() { - if (!itemLoaded) binding.progbarLoading.visibility = View.VISIBLE - Logd(TAG, "load() called") - if (!loadItemsRunning) { - loadItemsRunning = true - lifecycleScope.launch { - try { - withContext(Dispatchers.IO) { - if (episode != null) { - val duration = episode!!.media?.getDuration() ?: Int.MAX_VALUE - Logd(TAG, "description: ${episode?.description}") - val url = episode!!.media?.downloadUrl - if (url?.contains("youtube.com") == true && episode!!.description?.startsWith("Short:") == true) { - Logd(TAG, "getting extended description: ${episode!!.title}") - try { - val info = episode!!.streamInfo - if (info?.description?.content != null) { - episode = upsert(episode!!) { it.description = info.description?.content } - webviewData = shownotesCleaner.processShownotes(info.description!!.content, duration) - } else webviewData = shownotesCleaner.processShownotes(episode!!.description ?: "", duration) - } catch (e: Exception) { Logd(TAG, "StreamInfo error: ${e.message}") } - } else webviewData = shownotesCleaner.processShownotes(episode!!.description ?: "", duration) - } - } - withContext(Dispatchers.Main) { - binding.progbarLoading.visibility = View.GONE - onFragmentLoaded() - itemLoaded = true - } - } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) - } finally { loadItemsRunning = false } - } - } - } - - fun setItem(item_: Episode) { - episode = item_ - } - - /** - * Displays information about an Episode (FeedItem) and actions. - */ - class EpisodeHomeFragment : Fragment() { - private var _binding: EpisodeHomeFragmentBinding? = null - private val binding get() = _binding!! - - private var startIndex = 0 - private var ttsSpeed = 1.0f - - private lateinit var toolbar: MaterialToolbar - - private var readerText: String? = null - private var cleanedNotes: String? = null - private var readerhtml: String? = null - private var readMode = true - private var ttsPlaying = false - private var jsEnabled = false - - private var tts: TextToSpeech? = null - private var ttsReady = false - - @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - super.onCreateView(inflater, container, savedInstanceState) - _binding = EpisodeHomeFragmentBinding.inflate(inflater, container, false) - Logd(TAG, "fragment onCreateView") - toolbar = binding.toolbar - toolbar.title = "" - toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } - toolbar.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED) - - if (!episode?.link.isNullOrEmpty()) showContent() - else { - Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() - parentFragmentManager.popBackStack() - } - binding.webView.apply { - webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView?, url: String?) { - val isEmpty = view?.title.isNullOrEmpty() && view?.contentDescription.isNullOrEmpty() - if (isEmpty) Logd(TAG, "content is empty") - } - } - } - updateAppearance() - return binding.root - } - - @OptIn(UnstableApi::class) private fun switchMode() { - readMode = !readMode - showContent() - updateAppearance() - } - - @OptIn(UnstableApi::class) private fun showReaderContent() { - runOnIOScope { - if (!episode?.link.isNullOrEmpty()) { - if (cleanedNotes == null) { - if (episode?.transcript == null) { - val url = episode!!.link!! - val htmlSource = fetchHtmlSource(url) - val article = Readability4JExtended(episode?.link!!, htmlSource).parse() - readerText = article.textContent -// Log.d(TAG, "readability4J: ${article.textContent}") - readerhtml = article.contentWithDocumentsCharsetOrUtf8 - } else { - readerhtml = episode!!.transcript - readerText = HtmlCompat.fromHtml(readerhtml!!, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() - } - if (!readerhtml.isNullOrEmpty()) { - val shownotesCleaner = ShownotesCleaner(requireContext()) - cleanedNotes = shownotesCleaner.processShownotes(readerhtml!!, 0) - episode = upsertBlk(episode!!) { - it.setTranscriptIfLonger(readerhtml) - } -// persistEpisode(episode) - } - } - } - if (!cleanedNotes.isNullOrEmpty()) { - if (!ttsReady) initializeTTS(requireContext()) - withContext(Dispatchers.Main) { - binding.readerView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes ?: "No notes", - "text/html", "UTF-8", null) - binding.readerView.visibility = View.VISIBLE - binding.webView.visibility = View.GONE - } - } else { - withContext(Dispatchers.Main) { - Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() - } - } - } - } - - private fun initializeTTS(context: Context) { - Logd(TAG, "starting TTS") - if (tts == null) { - tts = TextToSpeech(context) { status: Int -> - if (status == TextToSpeech.SUCCESS) { - if (episode?.feed?.language != null) { - val result = tts?.setLanguage(Locale(episode!!.feed!!.language!!)) - if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { - Log.w(TAG, "TTS language not supported ${episode?.feed?.language}") - requireActivity().runOnUiThread { - Toast.makeText(context, getString(R.string.language_not_supported_by_tts) + " ${episode?.feed?.language}", Toast.LENGTH_LONG).show() - } - } - } - ttsReady = true -// semaphore.release() - Logd(TAG, "TTS init success") - } else { - Log.w(TAG, "TTS init failed") - requireActivity().runOnUiThread { Toast.makeText(context, R.string.tts_init_failed, Toast.LENGTH_LONG).show() } - } - } - } - } - - private fun showWebContent() { - if (!episode?.link.isNullOrEmpty()) { - binding.webView.settings.javaScriptEnabled = jsEnabled - Logd(TAG, "currentItem!!.link ${episode!!.link}") - binding.webView.loadUrl(episode!!.link!!) - binding.readerView.visibility = View.GONE - binding.webView.visibility = View.VISIBLE - } else Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() - } - - private fun showContent() { - if (readMode) showReaderContent() - else showWebContent() - } - - private val menuProvider = object: MenuProvider { - override fun onPrepareMenu(menu: Menu) { -// super.onPrepareMenu(menu) - Logd(TAG, "onPrepareMenu called") - val textSpeech = menu.findItem(R.id.text_speech) - textSpeech?.isVisible = readMode && tts != null - if (textSpeech?.isVisible == true) { - if (ttsPlaying) textSpeech.setIcon(R.drawable.ic_pause) else textSpeech.setIcon(R.drawable.ic_play_24dp) - } - menu.findItem(R.id.share_notes)?.setVisible(readMode) - menu.findItem(R.id.switchJS)?.setVisible(!readMode) - val btn = menu.findItem(R.id.switch_home) - if (readMode) btn?.setIcon(R.drawable.baseline_home_24) - else btn?.setIcon(R.drawable.outline_home_24) - } - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.episode_home, menu) - onPrepareMenu(menu) - } - - @OptIn(UnstableApi::class) override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - when (menuItem.itemId) { - R.id.switch_home -> { - switchMode() - return true - } - R.id.switchJS -> { - jsEnabled = !jsEnabled - showWebContent() - return true - } - R.id.text_speech -> { - Logd(TAG, "text_speech selected: $readerText") - if (tts != null) { - if (tts!!.isSpeaking) tts?.stop() - if (!ttsPlaying) { - ttsPlaying = true - if (!readerText.isNullOrEmpty()) { - ttsSpeed = episode?.feed?.preferences?.playSpeed ?: 1.0f - tts?.setSpeechRate(ttsSpeed) - while (startIndex < readerText!!.length) { - val endIndex = minOf(startIndex + MAX_CHUNK_LENGTH, readerText!!.length) - val chunk = readerText!!.substring(startIndex, endIndex) - tts?.speak(chunk, TextToSpeech.QUEUE_ADD, null, null) - startIndex += MAX_CHUNK_LENGTH - } - } - } else ttsPlaying = false - updateAppearance() - } else Toast.makeText(context, R.string.tts_not_available, Toast.LENGTH_LONG).show() - - return true - } - R.id.share_notes -> { - val notes = readerhtml - if (!notes.isNullOrEmpty()) { - val shareText = HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() - val context = requireContext() - val intent = ShareCompat.IntentBuilder(context) - .setType("text/plain") - .setText(shareText) - .setChooserTitle(R.string.share_notes_label) - .createChooserIntent() - context.startActivity(intent) - } - return true - } - else -> { - return episode != null - } - } - } - } - - @UnstableApi override fun onResume() { - super.onResume() - updateAppearance() - } - - private fun cleatWebview(webview: WebView) { - binding.root.removeView(webview) - webview.clearHistory() - webview.clearCache(true) - webview.clearView() - webview.destroy() - } - - @OptIn(UnstableApi::class) override fun onDestroyView() { - Logd(TAG, "onDestroyView") - cleatWebview(binding.webView) - cleatWebview(binding.readerView) - _binding = null - tts?.stop() - tts?.shutdown() - tts = null - super.onDestroyView() - } - - @UnstableApi private fun updateAppearance() { - if (episode == null) { - Logd(TAG, "updateAppearance currentItem is null") - return - } -// onPrepareOptionsMenu(toolbar.menu) - toolbar.invalidateMenu() -// menuProvider.onPrepareMenu(toolbar.menu) - } - - companion object { - private val TAG: String = EpisodeHomeFragment::class.simpleName ?: "Anonymous" - private const val MAX_CHUNK_LENGTH = 2000 - - var episode: Episode? = null // unmanged - - fun newInstance(item: Episode): EpisodeHomeFragment { - val fragment = EpisodeHomeFragment() - Logd(TAG, "item.itemIdentifier ${item.identifier}") - if (item.identifier != episode?.identifier) episode = item - return fragment - } - } - } - - companion object { - private val TAG: String = EpisodeInfoFragment::class.simpleName ?: "Anonymous" - - private suspend fun getMediaSize(episode: Episode?) : Long { - return withContext(Dispatchers.IO) { - if (!isEpisodeHeadDownloadAllowed) return@withContext -1 - val media = episode?.media ?: return@withContext -1 - - var size = Int.MIN_VALUE.toLong() - when { - media.downloaded -> { - val url = media.getLocalMediaUrl() - if (!url.isNullOrEmpty()) { - val mediaFile = File(url) - if (mediaFile.exists()) size = mediaFile.length() - } - } - !media.checkedOnSizeButUnknown() -> { - // only query the network if we haven't already checked - - val url = media.downloadUrl - if (url.isNullOrEmpty()) return@withContext -1 - - val client = getHttpClient() - val httpReq: Builder = Builder().url(url).header("Accept-Encoding", "identity").head() - try { - val response = client.newCall(httpReq.build()).execute() - if (response.isSuccessful) { - val contentLength = response.header("Content-Length")?:"0" - try { - size = contentLength.toInt().toLong() - } catch (e: NumberFormatException) { - Log.e(TAG, Log.getStackTraceString(e)) - } - } - } catch (e: Exception) { - Log.e(TAG, Log.getStackTraceString(e)) - return@withContext -1 // better luck next time - } - } - } - // they didn't tell us the size, but we don't want to keep querying on it - upsert(episode) { - if (size <= 0) it.media?.setCheckedOnSizeButUnknown() - else it.media?.size = size - } - size - } - } - - fun newInstance(item: Episode): EpisodeInfoFragment { - val fragment = EpisodeInfoFragment() - fragment.setItem(item) - return fragment - } - } - } - -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt index 5609ad42..592432dd 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt @@ -1,9 +1,9 @@ package ac.mdiq.podcini.ui.compose import ac.mdiq.podcini.R +import ac.mdiq.podcini.net.download.DownloadStatus import ac.mdiq.podcini.net.download.service.DownloadServiceInterface import ac.mdiq.podcini.playback.base.InTheatre -import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.storage.database.Episodes import ac.mdiq.podcini.storage.database.Episodes.setPlayState import ac.mdiq.podcini.storage.database.Queues @@ -17,7 +17,7 @@ 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.activity.MainActivity -import ac.mdiq.podcini.ui.adapter.EpisodesAdapter.EpisodeInfoFragment +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 @@ -67,102 +67,122 @@ fun InforBar(text: MutableState, leftAction: MutableState, val textColor = MaterialTheme.colors.onSurface Logd("InforBar", "textState: ${text.value}") Row { - Image(painter = painterResource(leftAction.value?.getActionIcon() ?:R.drawable.ic_questionmark), contentDescription = "left_action_icon", - Modifier.width(24.dp).height(24.dp).clickable(onClick = actionConfig)) - Image(painter = painterResource(R.drawable.baseline_arrow_left_alt_24), contentDescription = "left_arrow", Modifier.width(24.dp).height(24.dp)) + Icon(painter = painterResource(leftAction.value?.getActionIcon() ?:R.drawable.ic_questionmark), tint = textColor, contentDescription = "left_action_icon", + modifier = Modifier.width(24.dp).height(24.dp).clickable(onClick = actionConfig)) + Icon(painter = painterResource(R.drawable.baseline_arrow_left_alt_24), tint = textColor, contentDescription = "left_arrow", modifier = Modifier.width(24.dp).height(24.dp)) Spacer(modifier = Modifier.weight(1f)) Text(text.value, color = textColor, style = MaterialTheme.typography.body2) Spacer(modifier = Modifier.weight(1f)) - Image(painter = painterResource(R.drawable.baseline_arrow_right_alt_24), contentDescription = "right_arrow", Modifier.width(24.dp).height(24.dp)) - Image(painter = painterResource(rightAction.value?.getActionIcon() ?:R.drawable.ic_questionmark), contentDescription = "right_action_icon", - Modifier.width(24.dp).height(24.dp).clickable(onClick = actionConfig)) + Icon(painter = painterResource(R.drawable.baseline_arrow_right_alt_24), tint = textColor, contentDescription = "right_arrow", modifier = Modifier.width(24.dp).height(24.dp)) + Icon(painter = painterResource(rightAction.value?.getActionIcon() ?:R.drawable.ic_questionmark), tint = textColor, contentDescription = "right_action_icon", + modifier = Modifier.width(24.dp).height(24.dp).clickable(onClick = actionConfig)) } } @Composable -fun EpisodeSpeedDial(activity: MainActivity, selected: List, modifier: Modifier = Modifier) { - val TAG = "EpisodeSpeedDial" +fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList, modifier: Modifier = Modifier) { + val TAG = "EpisodeSpeedDial ${selected.size}" var isExpanded by remember { mutableStateOf(false) } val options = listOf<@Composable () -> Unit>( - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - Logd(TAG, "ic_delete: ${selected.size}") - LocalDeleteModal.deleteEpisodesWarnLocal(activity, selected) - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), "") - Text(stringResource(id = R.string.delete_episode_label)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - Logd(TAG, "ic_download: ${selected.size}") - for (episode in selected) { - if (episode.media != null && episode.feed != null && !episode.feed!!.isLocalFeed) DownloadServiceInterface.get()?.download(activity, episode) - } - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), "") - Text(stringResource(id = R.string.download_label)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - Logd(TAG, "ic_mark_played: ${selected.size}") - setPlayState(Episode.PlayState.UNSPECIFIED.code, false, *selected.toTypedArray()) - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_mark_played), "") - Text(stringResource(id = R.string.toggle_played_label)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - Logd(TAG, "ic_playlist_remove: ${selected.size}") - removeFromQueue(*selected.toTypedArray()) - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_remove), "") - Text(stringResource(id = R.string.remove_from_queue_label)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - Logd(TAG, "ic_playlist_play: ${selected.size}") - Queues.addToQueue(true, *selected.toTypedArray()) - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") - Text(stringResource(id = R.string.add_to_queue_label)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - Logd(TAG, "ic_playlist_play: ${selected.size}") - PutToQueueDialog(activity, selected).show() - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") - Text(stringResource(id = R.string.put_in_queue_label)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - Logd(TAG, "ic_star: ${selected.size}") - for (item in selected) { - Episodes.setFavorite(item, null) - } - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_star), "") - Text(stringResource(id = R.string.toggle_favorite_label)) - } }, + { + Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + Logd(TAG, "ic_delete: ${selected.size}") + LocalDeleteModal.deleteEpisodesWarnLocal(activity, selected) + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), "") + Text(stringResource(id = R.string.delete_episode_label)) + } + }, + { + Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + Logd(TAG, "ic_download: ${selected.size}") + for (episode in selected) { + if (episode.media != null && episode.feed != null && !episode.feed!!.isLocalFeed) DownloadServiceInterface.get() + ?.download(activity, episode) + } + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), "") + Text(stringResource(id = R.string.download_label)) + } + }, + { + Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + Logd(TAG, "ic_mark_played: ${selected.size}") + setPlayState(Episode.PlayState.UNSPECIFIED.code, false, *selected.toTypedArray()) + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_mark_played), "") + Text(stringResource(id = R.string.toggle_played_label)) + } + }, + { + Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + Logd(TAG, "ic_playlist_remove: ${selected.size}") + removeFromQueue(*selected.toTypedArray()) + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_remove), "") + Text(stringResource(id = R.string.remove_from_queue_label)) + } + }, + { + Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + Logd(TAG, "ic_playlist_play: ${selected.size}") + Queues.addToQueue(true, *selected.toTypedArray()) + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") + Text(stringResource(id = R.string.add_to_queue_label)) + } + }, + { + Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + Logd(TAG, "ic_playlist_play: ${selected.size}") + PutToQueueDialog(activity, selected).show() + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") + Text(stringResource(id = R.string.put_in_queue_label)) + } + }, + { + Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + Logd(TAG, "ic_star: ${selected.size}") + for (item in selected) { + Episodes.setFavorite(item, null) + } + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_star), "") + Text(stringResource(id = R.string.toggle_favorite_label)) + } + }, ) - Column(modifier = modifier, verticalArrangement = Arrangement.Bottom) { + val scrollState = rememberScrollState() + Column(modifier = modifier.verticalScroll(scrollState), verticalArrangement = Arrangement.Bottom) { if (isExpanded) options.forEachIndexed { _, button -> - FloatingActionButton(modifier = Modifier.padding(start = 4.dp, bottom = 6.dp).height(50.dp), backgroundColor = Color.LightGray, onClick = {}) { button() } } - FloatingActionButton(onClick = { isExpanded = !isExpanded }) { Icon(Icons.Filled.Edit, "Edit") } + FloatingActionButton(modifier = Modifier.padding(start = 4.dp, bottom = 6.dp).height(50.dp), + backgroundColor = Color.LightGray, + onClick = {}) { button() } + } + FloatingActionButton(backgroundColor = Color.Green, + onClick = { isExpanded = !isExpanded }) { Icon(Icons.Filled.Edit, "Edit") } } } @@ -171,10 +191,12 @@ fun EpisodeSpeedDial(activity: MainActivity, selected: List, modifier: fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList, leftSwipeCB: (Episode) -> Unit, rightSwipeCB: (Episode) -> Unit, actionButton_: ((Episode)->EpisodeActionButton)? = null) { val TAG = "EpisodeLazyColumn" var selectMode by remember { mutableStateOf(false) } - val selectedIds by remember { mutableStateOf(mutableSetOf()) } - val selected = remember { mutableListOf()} +// val selectedIds = remember { mutableSetOf() } + var selectedSize by remember { mutableStateOf(0) } + val selected = remember { mutableStateListOf() } val coroutineScope = rememberCoroutineScope() val lazyListState = rememberLazyListState() + var longPressIndex by remember { mutableIntStateOf(-1) } Box(modifier = Modifier.fillMaxWidth()) { LazyColumn(state = lazyListState, @@ -196,11 +218,12 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList { Logd(TAG, "episodeMonitor UpdatedObject ${changes.obj.title} ${changes.changedFields.joinToString()}") - Logd(TAG, "episodeMonitor playedState0: $playedState ${episode.isPlayed()}") playedState = changes.obj.isPlayed() farvoriteState = changes.obj.isFavorite - episodes[index] = changes.obj - Logd(TAG, "episodeMonitor playedState: $playedState") +// 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) } else -> {} } @@ -217,7 +240,10 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList {} } @@ -230,6 +256,10 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList 0) Formatter.formatShortFileSize(LocalContext.current, episode.media!!.size) else "" Text(dateSizeText, color = textColor, style = MaterialTheme.typography.body2) } @@ -339,19 +379,78 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList DownloadStatus.State.UNKNOWN.ordinal && episodes[index].downloadState.value < DownloadStatus.State.COMPLETED.ordinal + } + if (actionButton_ == null) { + LaunchedEffect(episodes[index].downloadState.value) { + 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]) + } + LaunchedEffect(episodes[index].isPlayingState.value) { + Logd(TAG, "$index isPlayingState: ${episode.isPlayingState.value}") + actionButton = EpisodeActionButton.forItem(episodes[index]) + } + } 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) - actionButton = EpisodeActionButton.forItem(episodes[index]) }) }, contentAlignment = Alignment.Center) { - Image(painter = painterResource(actionButton.getDrawable()), contentDescription = null, modifier = Modifier.width(28.dp).height(32.dp)) + Icon(painter = painterResource(actionButton.getDrawable()), 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) } if (showAltActionsDialog) actionButton.AltActionsDialog(activity, showAltActionsDialog, onDismiss = { showAltActionsDialog = false }) } } } } - if (selectMode) EpisodeSpeedDial(activity, selected, modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 16.dp, start = 16.dp)) + if (selectMode) { + Row(modifier = Modifier.align(Alignment.TopEnd).width(150.dp).height(45.dp).background(Color.LightGray), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Icon(painter = painterResource(R.drawable.baseline_arrow_upward_24), tint = Color.Black, contentDescription = null, modifier = Modifier.width(35.dp).height(35.dp).padding(end = 10.dp) + .clickable(onClick = { +// selectedIds.clear() + selected.clear() + for (i in 0..longPressIndex) { +// selectedIds.add(episodes[i].id) + selected.add(episodes[i]) + } + selectedSize = selected.size + Logd(TAG, "selectedIds: ${selected.size}") + })) + Icon(painter = painterResource(R.drawable.baseline_arrow_downward_24), tint = Color.Black, contentDescription = null, modifier = Modifier.width(35.dp).height(35.dp).padding(end = 10.dp) + .clickable(onClick = { +// selectedIds.clear() + selected.clear() + for (i in longPressIndex..episodes.size-1) { +// selectedIds.add(episodes[i].id) + selected.add(episodes[i]) + } + selectedSize = selected.size + Logd(TAG, "selectedIds: ${selected.size}") + })) + var selectAllRes by remember { mutableIntStateOf(R.drawable.ic_select_all) } + Icon(painter = painterResource(selectAllRes), tint = Color.Black, contentDescription = null, modifier = Modifier.width(35.dp).height(35.dp) + .clickable(onClick = { + if (selectedSize != episodes.size) { + for (e in episodes) { +// selectedIds.add(e.id) + selected.add(e) + } + selectAllRes = R.drawable.ic_select_none + } else { +// selectedIds.clear() + selected.clear() + selectAllRes = R.drawable.ic_select_all + } + selectedSize = selected.size + Logd(TAG, "selectedIds: ${selected.size}") + })) + } + EpisodeSpeedDial(activity, selected.toMutableStateList(), modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 16.dp, start = 16.dp)) + } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SkipPreferenceDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SkipPreferenceDialog.kt index 12163815..299a49df 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SkipPreferenceDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SkipPreferenceDialog.kt @@ -15,7 +15,7 @@ import java.util.* * Shows the dialog that allows setting the skip time. */ object SkipPreferenceDialog { - fun showSkipPreference(context: Context, direction: SkipDirection, textView: TextView?) { + fun showSkipPreference(context: Context, direction: SkipDirection, textView: TextView? = null) { var checked = 0 val skipSecs = if (direction == SkipDirection.SKIP_FORWARD) fastForwardSecs else rewindSecs 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 9fcedd93..d2ae471a 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 @@ -2,15 +2,12 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.AudioplayerFragmentBinding -import ac.mdiq.podcini.databinding.PlayerDetailsFragmentBinding -import ac.mdiq.podcini.databinding.PlayerUiFragmentBinding import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource import ac.mdiq.podcini.playback.PlaybackServiceStarter import ac.mdiq.podcini.playback.ServiceStatusHandler import ac.mdiq.podcini.playback.base.InTheatre.curEpisode import ac.mdiq.podcini.playback.base.InTheatre.curMedia import ac.mdiq.podcini.playback.base.MediaPlayerBase -import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.playback.base.VideoMode import ac.mdiq.podcini.playback.cast.CastEnabledActivity @@ -39,55 +36,50 @@ import ac.mdiq.podcini.ui.actions.handler.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 +import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.dialog.MediaPlayerErrorDialog import ac.mdiq.podcini.ui.dialog.SkipPreferenceDialog import ac.mdiq.podcini.ui.dialog.SleepTimerDialog import ac.mdiq.podcini.ui.dialog.VariableSpeedDialog import ac.mdiq.podcini.ui.utils.ShownotesCleaner -import ac.mdiq.podcini.ui.view.ChapterSeekBar -import ac.mdiq.podcini.ui.view.PlayButton import ac.mdiq.podcini.ui.view.ShownotesWebView import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.MiscFormatter -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.AnimatorSet -import android.animation.ObjectAnimator import android.app.Activity import android.content.* -import android.graphics.ColorFilter import android.os.Build import android.os.Bundle import android.util.Log import android.view.* -import android.view.View.OnLayoutChangeListener -import android.widget.ImageView -import android.widget.SeekBar -import android.widget.TextView import android.widget.Toast -import androidx.annotation.OptIn import androidx.appcompat.widget.Toolbar -import androidx.cardview.widget.CardView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Slider +import androidx.compose.material.Text +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.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.app.ShareCompat import androidx.core.content.ContextCompat -import androidx.core.graphics.BlendModeColorFilterCompat -import androidx.core.graphics.BlendModeCompat import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment -import androidx.interpolator.view.animation.FastOutSlowInInterpolator import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController -import coil.imageLoader -import coil.request.ErrorResult -import coil.request.ImageRequest +import coil.compose.AsyncImage import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.elevation.SurfaceColors import com.google.android.material.snackbar.Snackbar -import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest @@ -104,32 +96,55 @@ import kotlin.math.min * Shows the audio player. */ @UnstableApi -class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar.OnMenuItemClickListener { - +class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { var _binding: AudioplayerFragmentBinding? = null private val binding get() = _binding!! - - private var playerDetailsFragment: PlayerDetailsFragment? = null - + private lateinit var toolbar: MaterialToolbar + private var showPlayer1 by mutableStateOf(true) + var isCollapsed by mutableStateOf(true) - private var playerUI1: PlayerUIFragment? = null - private var playerUI2: PlayerUIFragment? = null - private var playerUI: PlayerUIFragment? = null - private var playerUIView1: View? = null - private var playerUIView2: View? = null - - private lateinit var cardViewSeek: CardView - - private lateinit var controllerFuture: ListenableFuture +// private lateinit var controllerFuture: ListenableFuture private var controller: ServiceStatusHandler? = null - private var seekedToChapterStart = false -// private var currentChapterIndex = -1 + private var prevMedia: Playable? = null private var currentMedia: Playable? = null private var isShowPlay: Boolean = false - var isCollapsed = true + + private var showTimeLeft = false + private var titleText by mutableStateOf("") + private var imgLoc by mutableStateOf(null) + private var txtvPlaybackSpeed by mutableStateOf("") + private var remainingTime by mutableIntStateOf(0) + private var isVideoScreen = false + private var playButRes by mutableIntStateOf(R.drawable.ic_play_48dp) + private var currentPosition by mutableIntStateOf(0) + private var duration by mutableIntStateOf(0) + private var txtvLengtTexth by mutableStateOf("") + private var sliderValue by mutableFloatStateOf(0f) + + private var shownotesCleaner: ShownotesCleaner? = null + + private var prevItem: Episode? = null + private var currentItem: Episode? = null + private var displayedChapterIndex = -1 + + private var cleanedNotes by mutableStateOf(null) + private var isLoading = false + private var homeText: String? = null + private var showHomeText = false + private var readerhtml: String? = null + private var txtvPodcastTitle by mutableStateOf("") + private var episodeDate by mutableStateOf("") + private var chapterControlVisible by mutableStateOf(false) + private var hasNextChapter by mutableStateOf(true) + // var imgLoc by mutableStateOf("") + private val currentChapter: Chapter? + get() { + if (currentMedia == null || currentMedia!!.getChapters().isEmpty() || displayedChapterIndex == -1) return null + return currentMedia!!.getChapters()[displayedChapterIndex] + } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) @@ -140,49 +155,37 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar toolbar = binding.toolbar toolbar.title = "" toolbar.setNavigationOnClickListener { -// val mtype = getMedia?.getMediaType() -// if (mtype == MediaType.AUDIO || (mtype == MediaType.VIDEO && videoPlayMode == VideoMode.AUDIO_ONLY)) { - val bottomSheet = (activity as MainActivity).bottomSheet -// if (bottomSheet.state == BottomSheetBehavior.STATE_EXPANDED) - bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED -// else bottomSheet.state = BottomSheetBehavior.STATE_EXPANDED -// } + val bottomSheet = (activity as MainActivity).bottomSheet + bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED } toolbar.setOnMenuItemClickListener(this) - controller = createHandler() controller!!.init() - - playerUI1 = PlayerUIFragment.newInstance(controller!!) - childFragmentManager.beginTransaction() - .replace(R.id.playerFragment1, playerUI1!!, "InternalPlayerFragment1") - .commit() - playerUIView1 = binding.root.findViewById(R.id.playerFragment1) - playerUIView1?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) - - playerUI2 = PlayerUIFragment.newInstance(controller!!) - childFragmentManager.beginTransaction() - .replace(R.id.playerFragment2, playerUI2!!, "InternalPlayerFragment2") - .commit() - playerUIView2 = binding.root.findViewById(R.id.playerFragment2) - playerUIView2?.setBackgroundColor(SurfaceColors.getColorForElevation(requireContext(), 8 * resources.displayMetrics.density)) onCollaped() - cardViewSeek = binding.cardViewSeek - initDetailedView() + binding.composeView1.setContent { + CustomTheme(requireContext()) { + if (showPlayer1) PlayerUI() + else Spacer(modifier = Modifier.size(0.dp)) + } + } + binding.composeDetailView.setContent { + CustomTheme(requireContext()) { + if (!isCollapsed) DetailUI() + else Spacer(modifier = Modifier.size(0.dp)) + } + } + binding.composeView2.setContent { + CustomTheme(requireContext()) { + if (!showPlayer1) PlayerUI() + else Spacer(modifier = Modifier.size(0.dp)) + } + } +// cardViewSeek = binding.cardViewSeek (activity as MainActivity).setPlayerVisible(false) return binding.root } - private fun initDetailedView() { - if (playerDetailsFragment == null) { - val fm = requireActivity().supportFragmentManager - val transaction = fm.beginTransaction() - playerDetailsFragment = PlayerDetailsFragment() - transaction.replace(R.id.itemDescription, playerDetailsFragment!!).commit() - } - } - override fun onDestroyView() { Logd(TAG, "Fragment destroyed") _binding = null @@ -192,25 +195,521 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar super.onDestroyView() } + @OptIn(ExperimentalFoundationApi::class) + @Composable + fun PlayerUI() { + Column(modifier = Modifier.fillMaxWidth().height(133.dp)) { + val textColor = MaterialTheme.colors.onSurface + Text(titleText, maxLines = 1, color = textColor, style = MaterialTheme.typography.body2) + var tempSliderValue by remember { mutableStateOf(sliderValue) } + Slider(value = tempSliderValue, valueRange = 0f..duration.toFloat(), modifier = Modifier.height(15.dp), + onValueChange = { + Logd(TAG, "Slider onValueChange: $it") + tempSliderValue = it + }, onValueChangeFinished = { + Logd(TAG, "Slider onValueChangeFinished: $tempSliderValue") + sliderValue = tempSliderValue + currentPosition = sliderValue.toInt() + if (playbackService?.isServiceReady() == true) seekTo(currentPosition) + }) + Row { + Text(DurationConverter.getDurationStringLong(currentPosition), color = textColor, style = MaterialTheme.typography.body2) + Spacer(Modifier.weight(1f)) + showTimeLeft = UserPreferences.shouldShowRemainingTime() + Text(txtvLengtTexth, color = textColor, style = MaterialTheme.typography.body2, modifier = Modifier.clickable { + if (controller == null) return@clickable + showTimeLeft = !showTimeLeft + UserPreferences.setShowRemainTimeSetting(showTimeLeft) + onPositionUpdate(FlowEvent.PlaybackPositionEvent(curMedia, curPositionFB, curDurationFB)) + }) + } + Row { + fun ensureService() { + if (curMedia == null) return + if (playbackService == null) PlaybackServiceStarter(requireContext(), curMedia!!).start() + } + AsyncImage(model = imgLoc, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), + modifier = Modifier.width(80.dp).height(80.dp).padding(start = 5.dp) + .clickable(onClick = { + Logd(TAG, "icon clicked!") + Logd(TAG, "playerUiFragment was clicked") + val media = curMedia + if (media != null) { + val mediaType = media.getMediaType() + if (mediaType == MediaType.AUDIO || videoPlayMode == VideoMode.AUDIO_ONLY.code || videoMode == VideoMode.AUDIO_ONLY + || (media is EpisodeMedia && media.episode?.feed?.preferences?.videoModePolicy == VideoMode.AUDIO_ONLY)) { + Logd(TAG, "popping as audio episode") + ensureService() + (activity as MainActivity).bottomSheet.setState(BottomSheetBehavior.STATE_EXPANDED) + } else { + Logd(TAG, "popping video activity") + val intent = getPlayerActivityIntent(requireContext(), mediaType) + startActivity(intent) + } + } + })) + Spacer(Modifier.weight(0.1f)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(painter = painterResource(R.drawable.ic_playback_speed), tint = textColor, + contentDescription = "speed", + modifier = Modifier.width(48.dp).height(48.dp).clickable(onClick = { + VariableSpeedDialog.newInstance(booleanArrayOf(true, true, true), null)?.show(childFragmentManager, null) + })) + Text(txtvPlaybackSpeed, color = textColor, style = MaterialTheme.typography.body2) + } + Spacer(Modifier.weight(0.1f)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(painter = painterResource(R.drawable.ic_fast_rewind), tint = textColor, + contentDescription = "rewind", + modifier = Modifier.width(48.dp).height(48.dp).combinedClickable(onClick = { + if (controller != null && playbackService?.isServiceReady() == true) { + playbackService?.mPlayer?.seekDelta(-UserPreferences.rewindSecs * 1000) + } + }, onLongClick = { + SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND) + })) + Text(NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong()), color = textColor, style = MaterialTheme.typography.body2) + } + Spacer(Modifier.weight(0.1f)) + Icon(painter = painterResource(playButRes), tint = textColor, + contentDescription = "play", + modifier = Modifier.width(64.dp).height(64.dp).combinedClickable(onClick = { + if (controller == null) return@combinedClickable + if (curMedia != null) { + val media = curMedia!! + setIsShowPlay(!isShowPlay) + if (media.getMediaType() == MediaType.VIDEO && MediaPlayerBase.status != PlayerStatus.PLAYING && + (media is EpisodeMedia && media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY)) { + playPause() + requireContext().startActivity(getPlayerActivityIntent(requireContext(), curMedia!!.getMediaType())) + } else playPause() + } + }, onLongClick = { + if (controller != null && MediaPlayerBase.status == PlayerStatus.PLAYING) { + val fallbackSpeed = UserPreferences.fallbackSpeed + if (fallbackSpeed > 0.1f) toggleFallbackSpeed(fallbackSpeed) + } + })) + Spacer(Modifier.weight(0.1f)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(painter = painterResource(R.drawable.ic_fast_forward), tint = textColor, + contentDescription = "forward", + modifier = Modifier.width(48.dp).height(48.dp).combinedClickable(onClick = { + if (controller != null && playbackService?.isServiceReady() == true) { + playbackService?.mPlayer?.seekDelta(UserPreferences.fastForwardSecs * 1000) + } + }, onLongClick = { + SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD) + })) + Text(NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong()), color = textColor, style = MaterialTheme.typography.body2) + } + Spacer(Modifier.weight(0.1f)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + fun speedForward(speed: Float) { + if (playbackService?.mPlayer == null || playbackService?.isFallbackSpeed == true) return + if (playbackService?.isSpeedForward == false) { + playbackService?.normalSpeed = playbackService?.mPlayer!!.getPlaybackSpeed() + playbackService?.mPlayer!!.setPlaybackParams(speed, isSkipSilence) + } else playbackService?.mPlayer?.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence) + playbackService!!.isSpeedForward = !playbackService!!.isSpeedForward + } + Icon(painter = painterResource(R.drawable.ic_skip_48dp), tint = textColor, + contentDescription = "rewind", + modifier = Modifier.width(48.dp).height(48.dp).combinedClickable(onClick = { + if (controller != null && MediaPlayerBase.status == PlayerStatus.PLAYING) { + val speedForward = UserPreferences.speedforwardSpeed + if (speedForward > 0.1f) speedForward(speedForward) + } + }, onLongClick = { + activity?.sendBroadcast(MediaButtonReceiver.createIntent(requireContext(), KeyEvent.KEYCODE_MEDIA_NEXT)) + })) + if (UserPreferences.speedforwardSpeed > 0.1f) Text(NumberFormat.getInstance().format(UserPreferences.speedforwardSpeed), color = textColor, style = MaterialTheme.typography.body2) + } + Spacer(Modifier.weight(0.1f)) + } + } + } + + @OptIn(ExperimentalFoundationApi::class) + @Composable + fun DetailUI() { + val scrollState = rememberScrollState() + Column(modifier = Modifier.fillMaxWidth().verticalScroll(scrollState)) { + val textColor = MaterialTheme.colors.onSurface + fun copyText(text: String): Boolean { + val clipboardManager: ClipboardManager? = ContextCompat.getSystemService(requireContext(), ClipboardManager::class.java) + clipboardManager?.setPrimaryClip(ClipData.newPlainText("Podcini", text)) + if (Build.VERSION.SDK_INT <= 32) { + (requireActivity() as MainActivity).showSnackbarAbovePlayer(resources.getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT) + } + return true + } + Text(txtvPodcastTitle, textAlign = TextAlign.Center, color = textColor, style = MaterialTheme.typography.h5, + modifier = Modifier.fillMaxWidth().padding(top = 2.dp, bottom = 5.dp).combinedClickable(onClick = { + if (currentMedia is EpisodeMedia) { + if (currentItem?.feedId != null) { + val openFeed: Intent = MainActivity.getIntentToOpenFeed(requireContext(), currentItem!!.feedId!!) + startActivity(openFeed) + } + } + }, onLongClick = { copyText(currentMedia?.getFeedTitle()?:"") })) + Text(episodeDate, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 2.dp, bottom = 2.dp), color = textColor, style = MaterialTheme.typography.body2) + Text(titleText, textAlign = TextAlign.Center, color = textColor, style = MaterialTheme.typography.h6, modifier = Modifier.fillMaxWidth().padding(top = 2.dp, bottom = 5.dp) + .combinedClickable(onClick = {}, onLongClick = { copyText(currentItem?.title?:"") })) + fun restoreFromPreference(): Boolean { + if ((activity as MainActivity).bottomSheet.state != BottomSheetBehavior.STATE_EXPANDED) return false + Logd(TAG, "Restoring from preferences") + val activity: Activity? = activity + if (activity != null) { + val id = prefs!!.getString(PREF_PLAYABLE_ID, "") + val scrollY = prefs!!.getInt(PREF_SCROLL_Y, -1) + if (scrollY != -1) { + if (id == curMedia?.getIdentifier()?.toString()) { + Logd(TAG, "Restored scroll Position: $scrollY") +// binding.itemDescriptionFragment.scrollTo(binding.itemDescriptionFragment.scrollX, scrollY) + return true + } + Logd(TAG, "reset scroll Position: 0") +// binding.itemDescriptionFragment.scrollTo(0, 0) + return true + } + } + return false + } + AndroidView(modifier = Modifier.fillMaxSize(), factory = { context -> + ShownotesWebView(context).apply { + setTimecodeSelectedListener { time: Int -> seekTo(time) } + setPageFinishedListener { + // Restoring the scroll position might not always work + postDelayed({ restoreFromPreference() }, 50) + } + } + }, update = { + it.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank") + }) + if (chapterControlVisible) { + Row { + Icon(painter = painterResource(R.drawable.ic_chapter_prev), tint = textColor, contentDescription = "prev_chapter", + modifier = Modifier.width(36.dp).height(36.dp).padding(end = 10.dp).clickable(onClick = { seekToPrevChapter() })) + Text(stringResource(id = R.string.chapters_label), modifier = Modifier.weight(1f) + .clickable(onClick = { ChaptersFragment().show(childFragmentManager, ChaptersFragment.TAG) })) + if (hasNextChapter) Icon(painter = painterResource(R.drawable.ic_chapter_prev), tint = textColor, contentDescription = "prev_chapter", + modifier = Modifier.width(36.dp).height(36.dp).padding(end = 10.dp).clickable(onClick = { seekToNextChapter() })) + } + } + AsyncImage(model = imgLoc, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), + modifier = Modifier.fillMaxWidth().padding(start = 32.dp, end = 32.dp, top = 10.dp).clickable(onClick = { + })) + } + } + + fun setIsShowPlay(showPlay: Boolean) { + if (this.isShowPlay != showPlay) { + this.isShowPlay = showPlay + playButRes = when { + isVideoScreen -> if (showPlay) R.drawable.ic_play_video_white else R.drawable.ic_pause_video_white + showPlay -> R.drawable.ic_play_48dp + else -> R.drawable.ic_pause + } + } + } + private fun updatePlaybackSpeedButton(event: FlowEvent.SpeedChangedEvent) { + val speedStr: String = DecimalFormat("0.00").format(event.newSpeed.toDouble()) + txtvPlaybackSpeed = speedStr +// binding.butPlaybackSpeed.setSpeed(event.newSpeed) TODO + } + @UnstableApi + fun onPositionUpdate(event: FlowEvent.PlaybackPositionEvent) { + if (curMedia?.getIdentifier() != event.media?.getIdentifier() || controller == null || curPositionFB == Playable.INVALID_TIME || curDurationFB == Playable.INVALID_TIME) return + val converter = TimeSpeedConverter(curSpeedFB) + currentPosition = converter.convert(event.position) + duration = converter.convert(event.duration) + val remainingTime: Int = converter.convert(max((event.duration - event.position).toDouble(), 0.0).toInt()) + if (currentPosition == Playable.INVALID_TIME || duration == Playable.INVALID_TIME) { + Log.w(TAG, "Could not react to position observer update because of invalid time") + return + } + showTimeLeft = UserPreferences.shouldShowRemainingTime() + txtvLengtTexth = if (showTimeLeft) { + (if (remainingTime > 0) "-" else "") + DurationConverter.getDurationStringLong(remainingTime) + } else DurationConverter.getDurationStringLong(duration) + + val progress: Float = (event.position.toFloat()) / event.duration + sliderValue = event.position.toFloat() + } + private fun onPlaybackServiceChanged(event: FlowEvent.PlaybackServiceEvent) { + when (event.action) { + FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN -> (activity as MainActivity).setPlayerVisible(false) + FlowEvent.PlaybackServiceEvent.Action.SERVICE_STARTED -> if (curMedia != null) (activity as MainActivity).setPlayerVisible(true) +// PlaybackServiceEvent.Action.SERVICE_RESTARTED -> (activity as MainActivity).setPlayerVisible(true) + } + } + @UnstableApi + fun updateUi(media: Playable) { + Logd(TAG, "updateUi called $media") +// if (media == null) return + titleText = media.getEpisodeTitle() +// (activity as MainActivity).setPlayerVisible(true) + onPositionUpdate(FlowEvent.PlaybackPositionEvent(media, media.getPosition(), media.getDuration())) + if (prevMedia?.getIdentifier() != media.getIdentifier()) { + imgLoc = ImageResourceUtils.getEpisodeListImageLocation(media) +// val imgLocFB = ImageResourceUtils.getFallbackImageLocation(media) +// val imageLoader = imgvCover.context.imageLoader +// val imageRequest = ImageRequest.Builder(requireContext()) +// .data(imgLoc) +// .setHeader("User-Agent", "Mozilla/5.0") +// .placeholder(R.color.light_gray) +// .listener(object : ImageRequest.Listener { +// override fun onError(request: ImageRequest, result: ErrorResult) { +// val fallbackImageRequest = ImageRequest.Builder(requireContext()) +// .data(imgLocFB) +// .setHeader("User-Agent", "Mozilla/5.0") +// .error(R.mipmap.ic_launcher) +// .target(imgvCover) +// .build() +// imageLoader.enqueue(fallbackImageRequest) +// } +// }) +// .target(imgvCover) +// .build() +// imageLoader.enqueue(imageRequest) + } + if (isPlayingVideoLocally && (curMedia as? EpisodeMedia)?.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) { + (activity as MainActivity).bottomSheet.setLocked(true) + (activity as MainActivity).bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) + } else { + (activity as MainActivity).bottomSheet.setLocked(false) + } + prevMedia = media + } + + internal fun updateInfo() { +// if (isLoading) return + lifecycleScope.launch { + Logd(TAG, "in updateInfo") + isLoading = true + withContext(Dispatchers.IO) { + if (currentItem == null) { + currentMedia = curMedia + if (currentMedia != null && currentMedia is EpisodeMedia) { + val episodeMedia = currentMedia as EpisodeMedia + currentItem = episodeMedia.episodeOrFetch() + showHomeText = false + homeText = null + } + } + if (currentItem != null) { + currentMedia = currentItem!!.media + if (prevItem?.identifier != currentItem!!.identifier) cleanedNotes = null + if (cleanedNotes == null) { + Logd(TAG, "calling load description ${currentItem!!.description==null} ${currentItem!!.title}") + cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", currentMedia?.getDuration()?:0) + } + prevItem = currentItem + } + } + withContext(Dispatchers.Main) { + Logd(TAG, "subscribe: ${currentMedia?.getEpisodeTitle()}") + displayMediaInfo(currentMedia!!) +// shownoteView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank") + Logd(TAG, "Webview loaded") + } + }.invokeOnCompletion { throwable -> + isLoading = false + if (throwable != null) Log.e(TAG, Log.getStackTraceString(throwable)) + } + } + + private fun buildHomeReaderText() { + showHomeText = !showHomeText + runOnIOScope { + if (showHomeText) { + homeText = currentItem!!.transcript + if (homeText == null && currentItem?.link != null) { + val url = currentItem!!.link!! + val htmlSource = fetchHtmlSource(url) + val readability4J = Readability4J(currentItem!!.link!!, htmlSource) + val article = readability4J.parse() + readerhtml = article.contentWithDocumentsCharsetOrUtf8 + if (!readerhtml.isNullOrEmpty()) { + currentItem = upsertBlk(currentItem!!) { + it.setTranscriptIfLonger(readerhtml) + } + homeText = currentItem!!.transcript +// persistEpisode(currentItem) + } + } + if (!homeText.isNullOrEmpty()) { +// val shownotesCleaner = ShownotesCleaner(requireContext()) + cleanedNotes = shownotesCleaner?.processShownotes(homeText!!, 0) + withContext(Dispatchers.Main) { +// shownoteView.loadDataWithBaseURL("https://127.0.0.1", +// cleanedNotes ?: "No notes", +// "text/html", +// "UTF-8", +// null) + } + } else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() } + } else { +// val shownotesCleaner = ShownotesCleaner(requireContext()) + cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", currentMedia?.getDuration() ?: 0) + if (!cleanedNotes.isNullOrEmpty()) { + withContext(Dispatchers.Main) { +// shownoteView.loadDataWithBaseURL("https://127.0.0.1", +// cleanedNotes ?: "No notes", +// "text/html", +// "UTF-8", +// null) + } + } else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() } + } + } + } + + private fun displayMediaInfo(media: Playable) { + Logd(TAG, "displayMediaInfo ${currentItem?.title} ${media.getEpisodeTitle()}") + val pubDateStr = MiscFormatter.formatAbbrev(context, media.getPubDate()) + txtvPodcastTitle = StringUtils.stripToEmpty(media.getFeedTitle()) + episodeDate = StringUtils.stripToEmpty(pubDateStr) + titleText = currentItem?.title ?:"" + displayedChapterIndex = -1 + refreshChapterData(ChapterUtils.getCurrentChapterIndex(media, media.getPosition())) //calls displayCoverImage + updateChapterControlVisibility() + } + + private fun updateChapterControlVisibility() { + when { + currentMedia?.getChapters() != null -> chapterControlVisible = currentMedia!!.getChapters().isNotEmpty() + currentMedia is EpisodeMedia -> { + val item_ = (currentMedia as EpisodeMedia).episodeOrFetch() + // If an item has chapters but they are not loaded yet, still display the button. + chapterControlVisible = !item_?.chapters.isNullOrEmpty() + } + } +// val newVisibility = if (chapterControlVisible) View.VISIBLE else View.GONE +// if (binding.chapterButton.visibility != newVisibility) { +// binding.chapterButton.visibility = newVisibility +// ObjectAnimator.ofFloat(binding.chapterButton, "alpha", +// (if (chapterControlVisible) 0 else 1).toFloat(), (if (chapterControlVisible) 1 else 0).toFloat()).start() +// } + } + + private fun refreshChapterData(chapterIndex: Int) { + Logd(TAG, "in refreshChapterData $chapterIndex") + if (currentMedia != null && chapterIndex > -1) { + if (currentMedia!!.getPosition() > currentMedia!!.getDuration() || chapterIndex >= currentMedia!!.getChapters().size - 1) { + displayedChapterIndex = currentMedia!!.getChapters().size - 1 + hasNextChapter = false + } else { + displayedChapterIndex = chapterIndex + hasNextChapter = true + } + } + displayCoverImage() + } + + private fun displayCoverImage() { + if (currentMedia == null) return + if (displayedChapterIndex == -1 || currentMedia!!.getChapters().isEmpty() || currentMedia!!.getChapters()[displayedChapterIndex].imageUrl.isNullOrEmpty()) { + imgLoc = currentMedia!!.getImageLocation() +// val imageLoader = binding.imgvCover.context.imageLoader +// val imageRequest = ImageRequest.Builder(requireContext()) +// .data(playable!!.getImageLocation()) +// .setHeader("User-Agent", "Mozilla/5.0") +// .placeholder(R.color.light_gray) +// .listener(object : ImageRequest.Listener { +// override fun onError(request: ImageRequest, result: ErrorResult) { +// val fallbackImageRequest = ImageRequest.Builder(requireContext()) +// .data(ImageResourceUtils.getFallbackImageLocation(playable!!)) +// .setHeader("User-Agent", "Mozilla/5.0") +// .error(R.mipmap.ic_launcher) +// .target(binding.imgvCover) +// .build() +// imageLoader.enqueue(fallbackImageRequest) +// } +// }) +// .target(binding.imgvCover) +// .build() +// imageLoader.enqueue(imageRequest) + } else { + imgLoc = EmbeddedChapterImage.getModelFor(currentMedia!!, displayedChapterIndex)?.toString() +// val imageLoader = binding.imgvCover.context.imageLoader +// val imageRequest = ImageRequest.Builder(requireContext()) +// .data(imgLoc) +// .setHeader("User-Agent", "Mozilla/5.0") +// .placeholder(R.color.light_gray) +// .listener(object : ImageRequest.Listener { +// override fun onError(request: ImageRequest, result: ErrorResult) { +// val fallbackImageRequest = ImageRequest.Builder(requireContext()) +// .data(ImageResourceUtils.getFallbackImageLocation(playable!!)) +// .setHeader("User-Agent", "Mozilla/5.0") +// .error(R.mipmap.ic_launcher) +// .target(binding.imgvCover) +// .build() +// imageLoader.enqueue(fallbackImageRequest) +// } +// }) +// .target(binding.imgvCover) +// .build() +// imageLoader.enqueue(imageRequest) + } + Logd(TAG, "displayCoverImage: imgLoc: $imgLoc") + } + + @UnstableApi private fun seekToPrevChapter() { + val curr: Chapter? = currentChapter + if (curr == null || displayedChapterIndex == -1) return + + when { + displayedChapterIndex < 1 -> seekTo(0) + (curPositionFB - 10000 * curSpeedFB) < curr.start -> { + refreshChapterData(displayedChapterIndex - 1) + if (currentMedia != null) seekTo(currentMedia!!.getChapters()[displayedChapterIndex].start.toInt()) + } + else -> seekTo(curr.start.toInt()) + } + } + + @UnstableApi private fun seekToNextChapter() { + if (currentMedia == null || currentMedia!!.getChapters().isEmpty() || displayedChapterIndex == -1 || displayedChapterIndex + 1 >= currentMedia!!.getChapters().size) return + refreshChapterData(displayedChapterIndex + 1) + seekTo(currentMedia!!.getChapters()[displayedChapterIndex].start.toInt()) + } + + @UnstableApi private fun savePreference() { + Logd(TAG, "Saving preferences") + val editor = prefs?.edit() ?: return + if (curMedia != null) { +// Logd(TAG, "Saving scroll position: " + binding.itemDescriptionFragment.scrollY) +// editor.putInt(PREF_SCROLL_Y, binding.itemDescriptionFragment.scrollY) + editor.putString(PREF_PLAYABLE_ID, curMedia!!.getIdentifier().toString()) + } else { + Logd(TAG, "savePreferences was called while media or webview was null") + editor.putInt(PREF_SCROLL_Y, -1) + editor.putString(PREF_PLAYABLE_ID, "") + } + editor.apply() + } + fun onExpanded() { Logd(TAG, "onExpanded()") - initDetailedView() // the function can also be called from MainActivity when a select menu pops up and closes if (isCollapsed) { isCollapsed = false - playerUI = playerUI2 - if (currentMedia != null) playerUI?.updateUi(currentMedia!!) - playerUI?.butPlay?.setIsShowPlay(isShowPlay) - playerDetailsFragment?.updateInfo() + if (shownotesCleaner == null) shownotesCleaner = ShownotesCleaner(requireContext()) + showPlayer1 = false + if (currentMedia != null) updateUi(currentMedia!!) + setIsShowPlay(isShowPlay) + updateInfo() } } fun onCollaped() { Logd(TAG, "onCollaped()") isCollapsed = true - playerUI = playerUI1 - if (currentMedia != null) playerUI?.updateUi(currentMedia!!) - playerUI?.butPlay?.setIsShowPlay(isShowPlay) + showPlayer1 = true + if (currentMedia != null) updateUi(currentMedia!!) + setIsShowPlay(isShowPlay) } private fun setChapterDividers() { @@ -242,7 +741,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar if (!loadItemsRunning) { loadItemsRunning = true if (!actMain.isPlayerVisible()) actMain.setPlayerVisible(true) - if (!isCollapsed && (currentMedia == null || curMedia?.getIdentifier() != currentMedia?.getIdentifier())) playerDetailsFragment?.updateInfo() + if (!isCollapsed && (currentMedia == null || curMedia?.getIdentifier() != currentMedia?.getIdentifier())) updateInfo() if (currentMedia == null || curMedia?.getIdentifier() != currentMedia?.getIdentifier() || (includingChapters && !curMedia!!.chaptersLoaded())) { Logd(TAG, "loadMediaInfo loading details ${curMedia?.getIdentifier()} chapter: $includingChapters") @@ -254,36 +753,41 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } currentMedia = curMedia val item = (currentMedia as? EpisodeMedia)?.episodeOrFetch() - if (item != null) playerDetailsFragment?.setItem(item) + if (item != null) setItem(item) updateUi() - if (currentMedia != null) playerUI?.updateUi(currentMedia!!) + if (currentMedia != null) updateUi(currentMedia!!) // TODO: disable for now // if (!includingChapters) loadMediaInfo(true) }.invokeOnCompletion { throwable -> - if (throwable != null) { - Log.e(TAG, Log.getStackTraceString(throwable)) - } + if (throwable != null) Log.e(TAG, Log.getStackTraceString(throwable)) loadItemsRunning = false } } } } + private fun setItem(item_: Episode) { + Logd(TAG, "setItem ${item_.title}") + if (currentItem?.identifier != item_.identifier) { + currentItem = item_ + showHomeText = false + homeText = null + } + } + private fun createHandler(): ServiceStatusHandler { return object : ServiceStatusHandler(requireActivity()) { override fun updatePlayButton(showPlay: Boolean) { isShowPlay = showPlay - playerUI?.butPlay?.setIsShowPlay(showPlay) -// playerFragment2?.butPlay?.setIsShowPlay(showPlay) + setIsShowPlay(showPlay) } override fun loadMediaInfo() { this@AudioPlayerFragment.loadMediaInfo(false) - if (!isCollapsed) playerDetailsFragment?.updateInfo() + if (!isCollapsed) updateInfo() } override fun onPlaybackEnd() { isShowPlay = true - playerUI?.butPlay?.setIsShowPlay(true) -// playerFragment2?.butPlay?.setIsShowPlay(true) + setIsShowPlay(true) (activity as MainActivity).setPlayerVisible(false) } } @@ -326,7 +830,11 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar super.onStop() // MediaController.releaseFuture(controllerFuture) cancelFlowEvents() -// progressIndicator.visibility = View.GONE // Controller released; we will not receive buffering updates + } + + @UnstableApi override fun onPause() { + super.onPause() + savePreference() } // @Subscribe(threadMode = ThreadMode.MAIN) @@ -353,7 +861,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar val currentitem = event.episode if (currentMedia?.getIdentifier() == null || currentitem.media?.getIdentifier() != currentMedia?.getIdentifier()) { currentMedia = currentitem.media - playerDetailsFragment?.setItem(currentitem) + setItem(currentitem) } (activity as MainActivity).setPlayerVisible(true) } @@ -363,11 +871,15 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar val media = event.media ?: return if (currentMedia?.getIdentifier() == null || media.getIdentifier() != currentMedia?.getIdentifier()) { currentMedia = media - playerUI?.updateUi(currentMedia!!) - playerDetailsFragment?.setItem(curEpisode!!) + updateUi(currentMedia!!) + setItem(curEpisode!!) + } + onPositionUpdate(event) + if (!isCollapsed) { + if (currentMedia?.getIdentifier() != event.media.getIdentifier()) return + val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(currentMedia, event.position) + if (newChapterIndex >= 0 && newChapterIndex != displayedChapterIndex) refreshChapterData(newChapterIndex) } - playerUI?.onPositionUpdate(event) - if (!isCollapsed) playerDetailsFragment?.onPlaybackPositionEvent(event) } private var eventSink: Job? = null @@ -386,7 +898,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar is FlowEvent.PlaybackServiceEvent -> { if (event.action == FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN) (activity as MainActivity).bottomSheet.state = BottomSheetBehavior.STATE_EXPANDED - playerUI?.onPlaybackServiceChanged(event) + onPlaybackServiceChanged(event) } is FlowEvent.PlayEvent -> onPlayEvent(event) is FlowEvent.FavoritesEvent -> onFavoriteEvent(event) @@ -394,7 +906,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar // is FlowEvent.SleepTimerUpdatedEvent -> if (event.isCancelled || event.wasJustEnabled()) loadMediaInfo(false) is FlowEvent.SleepTimerUpdatedEvent -> if (event.isCancelled || event.wasJustEnabled()) setupOptionsMenu() is FlowEvent.PlaybackPositionEvent -> onPlaybackPositionEvent(event) - is FlowEvent.SpeedChangedEvent -> playerUI?.updatePlaybackSpeedButton(event) + is FlowEvent.SpeedChangedEvent -> updatePlaybackSpeedButton(event) else -> {} } } @@ -405,59 +917,59 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar if (curEpisode?.id == event.episode.id) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, event.episode) } - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - if (controller == null) return - when { - fromUser -> { - val prog: Float = progress / (seekBar.max.toFloat()) - val converter = TimeSpeedConverter(curSpeedFB) - val position: Int = converter.convert((prog * curDurationFB).toInt()) - val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(curMedia, position) - if (newChapterIndex > -1) { -// if (!sbPosition.isPressed && currentChapterIndex != newChapterIndex) { -// currentChapterIndex = newChapterIndex -// val media = getMedia -// position = media?.getChapters()?.get(currentChapterIndex)?.start?.toInt() ?: 0 -// seekedToChapterStart = true -// seekTo(position) -// updateUi(controller!!.getMedia) -// sbPosition.highlightCurrentChapter() -// } - binding.txtvSeek.text = curMedia?.getChapters()?.get(newChapterIndex)?.title ?: ("\n${DurationConverter.getDurationStringLong(position)}") - } else binding.txtvSeek.text = DurationConverter.getDurationStringLong(position) - } - curDurationFB != playbackService?.curDuration -> updateUi() - } - } +// override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { +// if (controller == null) return +// when { +// fromUser -> { +// val prog: Float = progress / (seekBar.max.toFloat()) +// val converter = TimeSpeedConverter(curSpeedFB) +// val position: Int = converter.convert((prog * curDurationFB).toInt()) +// val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(curMedia, position) +//// if (newChapterIndex > -1) { +//// if (!sbPosition.isPressed && currentChapterIndex != newChapterIndex) { +//// currentChapterIndex = newChapterIndex +//// val media = getMedia +//// position = media?.getChapters()?.get(currentChapterIndex)?.start?.toInt() ?: 0 +//// seekedToChapterStart = true +//// seekTo(position) +//// updateUi(controller!!.getMedia) +//// sbPosition.highlightCurrentChapter() +//// } +//// binding.txtvSeek.text = curMedia?.getChapters()?.get(newChapterIndex)?.title ?: ("\n${DurationConverter.getDurationStringLong(position)}") +//// } else binding.txtvSeek.text = DurationConverter.getDurationStringLong(position) +// } +// curDurationFB != playbackService?.curDuration -> updateUi() +// } +// } - override fun onStartTrackingTouch(seekBar: SeekBar) { - // interrupt position Observer, restart later - cardViewSeek.scaleX = .8f - cardViewSeek.scaleY = .8f - cardViewSeek.animate() - ?.setInterpolator(FastOutSlowInInterpolator()) - ?.alpha(1f)?.scaleX(1f)?.scaleY(1f) - ?.setDuration(200) - ?.start() - } +// override fun onStartTrackingTouch(seekBar: SeekBar) { +// // interrupt position Observer, restart later +// cardViewSeek.scaleX = .8f +// cardViewSeek.scaleY = .8f +// cardViewSeek.animate() +// ?.setInterpolator(FastOutSlowInInterpolator()) +// ?.alpha(1f)?.scaleX(1f)?.scaleY(1f) +// ?.setDuration(200) +// ?.start() +// } - override fun onStopTrackingTouch(seekBar: SeekBar) { - if (controller != null) { - if (seekedToChapterStart) { - seekedToChapterStart = false - } else { - val prog: Float = seekBar.progress / (seekBar.max.toFloat()) - seekTo((prog * curDurationFB).toInt()) - } - } - cardViewSeek.scaleX = 1f - cardViewSeek.scaleY = 1f - cardViewSeek.animate() - ?.setInterpolator(FastOutSlowInInterpolator()) - ?.alpha(0f)?.scaleX(.8f)?.scaleY(.8f) - ?.setDuration(200) - ?.start() - } +// override fun onStopTrackingTouch(seekBar: SeekBar) { +// if (controller != null) { +// if (seekedToChapterStart) { +// seekedToChapterStart = false +// } else { +// val prog: Float = seekBar.progress / (seekBar.max.toFloat()) +// seekTo((prog * curDurationFB).toInt()) +// } +// } +// cardViewSeek.scaleX = 1f +// cardViewSeek.scaleY = 1f +// cardViewSeek.animate() +// ?.setInterpolator(FastOutSlowInInterpolator()) +// ?.alpha(0f)?.scaleX(.8f)?.scaleY(.8f) +// ?.setDuration(200) +// ?.start() +// } private fun setupOptionsMenu() { if (toolbar.menu.size() == 0) toolbar.inflateMenu(R.menu.mediaplayer) @@ -486,9 +998,9 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar val itemId = menuItem.itemId when (itemId) { R.id.show_home_reader_view -> { - if (playerDetailsFragment?.showHomeText == true) menuItem.setIcon(R.drawable.ic_home) + if (showHomeText) menuItem.setIcon(R.drawable.ic_home) else menuItem.setIcon(R.drawable.outline_home_24) - playerDetailsFragment?.buildHomeReaderText() + buildHomeReaderText() } R.id.show_video -> { playPause() @@ -502,7 +1014,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } } R.id.share_notes -> { - val notes = if (playerDetailsFragment?.showHomeText == true) playerDetailsFragment!!.readerhtml else feedItem?.description + val notes = if (showHomeText) readerhtml else feedItem?.description if (!notes.isNullOrEmpty()) { val shareText = HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() val context = requireContext() @@ -520,692 +1032,31 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } fun scrollToTop() { - playerDetailsFragment?.scrollToTop() +// binding.itemDescriptionFragment.scrollTo(0, 0) + savePreference() } fun fadePlayerToToolbar(slideOffset: Float) { val playerFadeProgress = (max(0.0, min(0.2, (slideOffset - 0.2f).toDouble())) / 0.2f).toFloat() - val player = playerUIView1 - player?.alpha = 1 - playerFadeProgress - player?.visibility = if (playerFadeProgress > 0.99f) View.INVISIBLE else View.VISIBLE + val player = binding.composeView1 + player.alpha = 1 - playerFadeProgress + player.visibility = if (playerFadeProgress > 0.99f) View.GONE else View.VISIBLE val toolbarFadeProgress = (max(0.0, min(0.2, (slideOffset - 0.6f).toDouble())) / 0.2f).toFloat() toolbar.setAlpha(toolbarFadeProgress) - toolbar.visibility = if (toolbarFadeProgress < 0.01f) View.INVISIBLE else View.VISIBLE - } - - class PlayerUIFragment : Fragment(), SeekBar.OnSeekBarChangeListener { - val TAG = this::class.simpleName ?: "Anonymous" - - private var _binding: PlayerUiFragmentBinding? = null - private val binding get() = _binding!! - private lateinit var imgvCover: ImageView - var butPlay: PlayButton? = null - private var isControlButtonsSet = false - private lateinit var txtvLength: TextView - private lateinit var sbPosition: ChapterSeekBar - private var prevMedia: Playable? = null - private var showTimeLeft = false - - @UnstableApi - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = PlayerUiFragmentBinding.inflate(inflater) - Logd(TAG, "fragment onCreateView") - - imgvCover = binding.imgvCover - butPlay = binding.butPlay - sbPosition = binding.sbPosition - txtvLength = binding.txtvLength - - setupLengthTextView() - setupControlButtons() - binding.butPlaybackSpeed.setOnClickListener { - VariableSpeedDialog.newInstance(booleanArrayOf(true, true, true), null)?.show(childFragmentManager, null) - } - sbPosition.setOnSeekBarChangeListener(this) - binding.playerUiFragment.setOnClickListener { - Logd(TAG, "playerUiFragment was clicked") - val media = curMedia - if (media != null) { - val mediaType = media.getMediaType() - if (mediaType == MediaType.AUDIO || videoPlayMode == VideoMode.AUDIO_ONLY.code || videoMode == VideoMode.AUDIO_ONLY - || (media is EpisodeMedia && media.episode?.feed?.preferences?.videoModePolicy == VideoMode.AUDIO_ONLY)) { - Logd(TAG, "popping as audio episode") - ensureService() - (activity as MainActivity).bottomSheet.setState(BottomSheetBehavior.STATE_EXPANDED) - } else { - Logd(TAG, "popping video activity") -// playPause() -// controller!!.ensureService() - val intent = getPlayerActivityIntent(requireContext(), mediaType) - startActivity(intent) - } - } - } - return binding.root - } - @OptIn(UnstableApi::class) override fun onDestroyView() { - Logd(TAG, "onDestroyView") - _binding = null - super.onDestroyView() - } - @UnstableApi - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - butPlay?.setOnClickListener { - if (controller == null) return@setOnClickListener - if (curMedia != null) { - val media = curMedia!! - if (media.getMediaType() == MediaType.VIDEO && MediaPlayerBase.status != PlayerStatus.PLAYING && - (media is EpisodeMedia && media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY)) { - playPause() - requireContext().startActivity(getPlayerActivityIntent(requireContext(), curMedia!!.getMediaType())) - } else playPause() - if (!isControlButtonsSet) { - sbPosition.visibility = View.VISIBLE - isControlButtonsSet = true - } - } - } - } - @OptIn(UnstableApi::class) private fun setupControlButtons() { - binding.butRev.setOnClickListener { - if (controller != null && playbackService?.isServiceReady() == true) { - playbackService?.mPlayer?.seekDelta(-UserPreferences.rewindSecs * 1000) - sbPosition.visibility = View.VISIBLE - } - } - binding.butRev.setOnLongClickListener { - SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND, binding.txtvRev) - true - } - butPlay?.setOnLongClickListener { - if (controller != null && MediaPlayerBase.status == PlayerStatus.PLAYING) { - val fallbackSpeed = UserPreferences.fallbackSpeed - if (fallbackSpeed > 0.1f) toggleFallbackSpeed(fallbackSpeed) - } - true - } - binding.butFF.setOnClickListener { - if (controller != null && playbackService?.isServiceReady() == true) { - playbackService?.mPlayer?.seekDelta(UserPreferences.fastForwardSecs * 1000) - sbPosition.visibility = View.VISIBLE - } - } - binding.butFF.setOnLongClickListener { - SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, binding.txtvFF) - true - } - binding.butSkip.setOnClickListener { - if (controller != null && MediaPlayerBase.status == PlayerStatus.PLAYING) { - val speedForward = UserPreferences.speedforwardSpeed - if (speedForward > 0.1f) speedForward(speedForward) - } - } - binding.butSkip.setOnLongClickListener { - activity?.sendBroadcast(MediaButtonReceiver.createIntent(requireContext(), KeyEvent.KEYCODE_MEDIA_NEXT)) - true - } - } - private fun ensureService() { - if (curMedia == null) return - if (playbackService == null) PlaybackServiceStarter(requireContext(), curMedia!!).start() - } - private fun speedForward(speed: Float) { -// playbackService?.speedForward(speed) - if (playbackService?.mPlayer == null || playbackService?.isFallbackSpeed == true) return - if (playbackService?.isSpeedForward == false) { - playbackService?.normalSpeed = playbackService?.mPlayer!!.getPlaybackSpeed() - playbackService?.mPlayer!!.setPlaybackParams(speed, isSkipSilence) - } else playbackService?.mPlayer?.setPlaybackParams(playbackService!!.normalSpeed, isSkipSilence) - playbackService!!.isSpeedForward = !playbackService!!.isSpeedForward - } - @OptIn(UnstableApi::class) private fun setupLengthTextView() { - showTimeLeft = UserPreferences.shouldShowRemainingTime() - txtvLength.setOnClickListener(View.OnClickListener { - if (controller == null) return@OnClickListener - showTimeLeft = !showTimeLeft - UserPreferences.setShowRemainTimeSetting(showTimeLeft) - onPositionUpdate(FlowEvent.PlaybackPositionEvent(curMedia, curPositionFB, curDurationFB)) - }) - } - fun updatePlaybackSpeedButton(event: FlowEvent.SpeedChangedEvent) { - val speedStr: String = DecimalFormat("0.00").format(event.newSpeed.toDouble()) - binding.txtvPlaybackSpeed.text = speedStr - binding.butPlaybackSpeed.setSpeed(event.newSpeed) - } - @UnstableApi - fun onPositionUpdate(event: FlowEvent.PlaybackPositionEvent) { - if (curMedia?.getIdentifier() != event.media?.getIdentifier() || controller == null || curPositionFB == Playable.INVALID_TIME || curDurationFB == Playable.INVALID_TIME) return - val converter = TimeSpeedConverter(curSpeedFB) - val currentPosition: Int = converter.convert(event.position) - val duration: Int = converter.convert(event.duration) - val remainingTime: Int = converter.convert(max((event.duration - event.position).toDouble(), 0.0).toInt()) - if (currentPosition == Playable.INVALID_TIME || duration == Playable.INVALID_TIME) { - Log.w(TAG, "Could not react to position observer update because of invalid time") - return - } - binding.txtvPosition.text = DurationConverter.getDurationStringLong(currentPosition) - binding.txtvPosition.setContentDescription(getString(R.string.position, - DurationConverter.getDurationStringLocalized(requireContext(), currentPosition.toLong()))) - val showTimeLeft = UserPreferences.shouldShowRemainingTime() - if (showTimeLeft) { - txtvLength.setContentDescription(getString(R.string.remaining_time, - DurationConverter.getDurationStringLocalized(requireContext(), remainingTime.toLong()))) - txtvLength.text = (if (remainingTime > 0) "-" else "") + DurationConverter.getDurationStringLong(remainingTime) - } else { - txtvLength.setContentDescription(getString(R.string.chapter_duration, - DurationConverter.getDurationStringLocalized(requireContext(), duration.toLong()))) - txtvLength.text = DurationConverter.getDurationStringLong(duration) - } - if (sbPosition.visibility == View.INVISIBLE && playbackService?.isServiceReady() == true) sbPosition.visibility = View.VISIBLE - - if (!sbPosition.isPressed) { - val progress: Float = (event.position.toFloat()) / event.duration -// Log.d(TAG, "updating sbPosition: $progress") - sbPosition.progress = (progress * sbPosition.max).toInt() - } - } - fun onPlaybackServiceChanged(event: FlowEvent.PlaybackServiceEvent) { - when (event.action) { - FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN -> (activity as MainActivity).setPlayerVisible(false) - FlowEvent.PlaybackServiceEvent.Action.SERVICE_STARTED -> if (curMedia != null) (activity as MainActivity).setPlayerVisible(true) -// PlaybackServiceEvent.Action.SERVICE_RESTARTED -> (activity as MainActivity).setPlayerVisible(true) - } - } - @OptIn(UnstableApi::class) override fun onStart() { - Logd(TAG, "onStart() called") - super.onStart() - binding.txtvRev.text = NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong()) - binding.txtvFF.text = NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong()) - if (UserPreferences.speedforwardSpeed > 0.1f) binding.txtvSkip.text = NumberFormat.getInstance().format(UserPreferences.speedforwardSpeed) - else binding.txtvSkip.visibility = View.GONE - val media = curMedia ?: return - updatePlaybackSpeedButton(FlowEvent.SpeedChangedEvent(getCurrentPlaybackSpeed(media))) - } - @UnstableApi - override fun onPause() { - Logd(TAG, "onPause() called") - super.onPause() -// controller?.pause() - } - @OptIn(UnstableApi::class) override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {} - override fun onStartTrackingTouch(seekBar: SeekBar) {} - @OptIn(UnstableApi::class) override fun onStopTrackingTouch(seekBar: SeekBar) { - if (playbackService?.isServiceReady() == true) { - val prog: Float = seekBar.progress / (seekBar.max.toFloat()) - seekTo((prog * curDurationFB).toInt()) - } - } - @UnstableApi - fun updateUi(media: Playable) { - Logd(TAG, "updateUi called $media") -// if (media == null) return - binding.titleView.text = media.getEpisodeTitle() -// (activity as MainActivity).setPlayerVisible(true) - onPositionUpdate(FlowEvent.PlaybackPositionEvent(media, media.getPosition(), media.getDuration())) - if (prevMedia?.getIdentifier() != media.getIdentifier()) { - val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(media) - val imgLocFB = ImageResourceUtils.getFallbackImageLocation(media) - val imageLoader = imgvCover.context.imageLoader - val imageRequest = ImageRequest.Builder(requireContext()) - .data(imgLoc) - .setHeader("User-Agent", "Mozilla/5.0") - .placeholder(R.color.light_gray) - .listener(object : ImageRequest.Listener { - override fun onError(request: ImageRequest, result: ErrorResult) { - val fallbackImageRequest = ImageRequest.Builder(requireContext()) - .data(imgLocFB) - .setHeader("User-Agent", "Mozilla/5.0") - .error(R.mipmap.ic_launcher) - .target(imgvCover) - .build() - imageLoader.enqueue(fallbackImageRequest) - } - }) - .target(imgvCover) - .build() - imageLoader.enqueue(imageRequest) - } - if (isPlayingVideoLocally && (curMedia as? EpisodeMedia)?.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) { - (activity as MainActivity).bottomSheet.setLocked(true) - (activity as MainActivity).bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) - } else { - butPlay?.visibility = View.VISIBLE - (activity as MainActivity).bottomSheet.setLocked(false) - } - prevMedia = media - } - - companion object { - var controller: ServiceStatusHandler? = null - fun newInstance(controller_: ServiceStatusHandler) : PlayerUIFragment { - controller = controller_ - return PlayerUIFragment() - } - } + toolbar.visibility = if (toolbarFadeProgress < 0.01f) View.GONE else View.VISIBLE } - /** - * Displays the description of a Playable object in a Webview. - */ - @UnstableApi - class PlayerDetailsFragment : Fragment() { - private lateinit var shownoteView: ShownotesWebView - private var shownotesCleaner: ShownotesCleaner? = null - - private var _binding: PlayerDetailsFragmentBinding? = null - private val binding get() = _binding!! - - private var prevItem: Episode? = null - private var playable: Playable? = null - private var currentItem: Episode? = null - private var displayedChapterIndex = -1 - - private var cleanedNotes: String? = null - - private var isLoading = false - private var homeText: String? = null - internal var showHomeText = false - internal var readerhtml: String? = null - - private val currentChapter: Chapter? - get() { - if (playable == null || playable!!.getChapters().isEmpty() || displayedChapterIndex == -1) return null - return playable!!.getChapters()[displayedChapterIndex] - } - - @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - Logd(TAG, "fragment onCreateView") - _binding = PlayerDetailsFragmentBinding.inflate(inflater, container, false) - - val colorFilter: ColorFilter? = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(binding.txtvPodcastTitle.currentTextColor, BlendModeCompat.SRC_IN) - binding.butNextChapter.colorFilter = colorFilter - binding.butPrevChapter.colorFilter = colorFilter - binding.chapterButton.setOnClickListener { ChaptersFragment().show(childFragmentManager, ChaptersFragment.TAG) } - binding.butPrevChapter.setOnClickListener { seekToPrevChapter() } - binding.butNextChapter.setOnClickListener { seekToNextChapter() } - - Logd(TAG, "fragment onCreateView") - shownoteView = binding.webview - shownoteView.setTimecodeSelectedListener { time: Int -> seekTo(time) } - shownoteView.setPageFinishedListener { - // Restoring the scroll position might not always work - shownoteView.postDelayed({ this@PlayerDetailsFragment.restoreFromPreference() }, 50) - } - - binding.root.addOnLayoutChangeListener(object : OnLayoutChangeListener { - override fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) { - if (binding.root.measuredHeight != shownoteView.minimumHeight) shownoteView.setMinimumHeight(binding.root.measuredHeight) - binding.root.removeOnLayoutChangeListener(this) - } - }) - registerForContextMenu(shownoteView) - shownotesCleaner = ShownotesCleaner(requireContext()) - return binding.root - } - -// override fun onStart() { -// Logd(TAG, "onStart()") -// super.onStart() -// } - -// override fun onStop() { -// Logd(TAG, "onStop()") -// super.onStop() -// } - - override fun onDestroyView() { - Logd(TAG, "onDestroyView") - _binding = null - prevItem = null - currentItem = null - Logd(TAG, "Fragment destroyed") - shownoteView.removeAllViews() - shownoteView.destroy() - super.onDestroyView() - } - - override fun onContextItemSelected(item: MenuItem): Boolean { - return shownoteView.onContextItemSelected(item) - } - - internal fun updateInfo() { -// if (isLoading) return - lifecycleScope.launch { - Logd(TAG, "in updateInfo") - isLoading = true - withContext(Dispatchers.IO) { - if (currentItem == null) { - playable = curMedia - if (playable != null && playable is EpisodeMedia) { - val episodeMedia = playable as EpisodeMedia - currentItem = episodeMedia.episodeOrFetch() - showHomeText = false - homeText = null - } - } - if (currentItem != null) { - playable = currentItem!!.media - if (prevItem?.identifier != currentItem!!.identifier) cleanedNotes = null - if (cleanedNotes == null) { - Logd(TAG, "calling load description ${currentItem!!.description==null} ${currentItem!!.title}") - cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", playable?.getDuration()?:0) - } - prevItem = currentItem - } - } - withContext(Dispatchers.Main) { - Logd(TAG, "subscribe: ${playable?.getEpisodeTitle()}") - displayMediaInfo(playable!!) - shownoteView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank") - Logd(TAG, "Webview loaded") - } - }.invokeOnCompletion { throwable -> - isLoading = false - if (throwable != null) Log.e(TAG, Log.getStackTraceString(throwable)) - } - } - - fun buildHomeReaderText() { - showHomeText = !showHomeText - runOnIOScope { - if (showHomeText) { - homeText = currentItem!!.transcript - if (homeText == null && currentItem?.link != null) { - val url = currentItem!!.link!! - val htmlSource = fetchHtmlSource(url) - val readability4J = Readability4J(currentItem!!.link!!, htmlSource) - val article = readability4J.parse() - readerhtml = article.contentWithDocumentsCharsetOrUtf8 - if (!readerhtml.isNullOrEmpty()) { - currentItem = upsertBlk(currentItem!!) { - it.setTranscriptIfLonger(readerhtml) - } - homeText = currentItem!!.transcript -// persistEpisode(currentItem) - } - } - if (!homeText.isNullOrEmpty()) { -// val shownotesCleaner = ShownotesCleaner(requireContext()) - cleanedNotes = shownotesCleaner?.processShownotes(homeText!!, 0) - withContext(Dispatchers.Main) { - shownoteView.loadDataWithBaseURL("https://127.0.0.1", - cleanedNotes ?: "No notes", - "text/html", - "UTF-8", - null) - } - } else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() } - } else { -// val shownotesCleaner = ShownotesCleaner(requireContext()) - cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", playable?.getDuration() ?: 0) - if (!cleanedNotes.isNullOrEmpty()) { - withContext(Dispatchers.Main) { - shownoteView.loadDataWithBaseURL("https://127.0.0.1", - cleanedNotes ?: "No notes", - "text/html", - "UTF-8", - null) - } - } else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() } - } - } - } - - @UnstableApi private fun displayMediaInfo(media: Playable) { - Logd(TAG, "displayMediaInfo ${currentItem?.title} ${media.getEpisodeTitle()}") - val pubDateStr = MiscFormatter.formatAbbrev(context, media.getPubDate()) - binding.txtvPodcastTitle.text = StringUtils.stripToEmpty(media.getFeedTitle()) - if (media is EpisodeMedia) { - if (currentItem?.feedId != null) { - val openFeed: Intent = MainActivity.getIntentToOpenFeed(requireContext(), currentItem!!.feedId!!) - binding.txtvPodcastTitle.setOnClickListener { startActivity(openFeed) } - } - } else binding.txtvPodcastTitle.setOnClickListener(null) - - binding.txtvPodcastTitle.setOnLongClickListener { copyText(media.getFeedTitle()) } - binding.episodeDate.text = StringUtils.stripToEmpty(pubDateStr) - binding.txtvEpisodeTitle.text = currentItem?.title - binding.txtvEpisodeTitle.setOnLongClickListener { copyText(currentItem?.title?:"") } - binding.txtvEpisodeTitle.setOnClickListener { - val lines = binding.txtvEpisodeTitle.lineCount - val animUnit = 1500 - if (lines > binding.txtvEpisodeTitle.maxLines) { - val titleHeight = (binding.txtvEpisodeTitle.height - binding.txtvEpisodeTitle.paddingTop - binding.txtvEpisodeTitle.paddingBottom) - val verticalMarquee: ObjectAnimator = ObjectAnimator.ofInt(binding.txtvEpisodeTitle, "scrollY", 0, - (lines - binding.txtvEpisodeTitle.maxLines) * (titleHeight / binding.txtvEpisodeTitle.maxLines)).setDuration((lines * animUnit).toLong()) - val fadeOut: ObjectAnimator = ObjectAnimator.ofFloat(binding.txtvEpisodeTitle, "alpha", 0f) - fadeOut.setStartDelay(animUnit.toLong()) - fadeOut.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - binding.txtvEpisodeTitle.scrollTo(0, 0) - } - }) - val fadeBackIn: ObjectAnimator = ObjectAnimator.ofFloat(binding.txtvEpisodeTitle, "alpha", 1f) - val set = AnimatorSet() - set.playSequentially(verticalMarquee, fadeOut, fadeBackIn) - set.start() - } - } - displayedChapterIndex = -1 - refreshChapterData(ChapterUtils.getCurrentChapterIndex(media, media.getPosition())) //calls displayCoverImage - updateChapterControlVisibility() - } - - private fun updateChapterControlVisibility() { - var chapterControlVisible = false - when { - playable?.getChapters() != null -> chapterControlVisible = playable!!.getChapters().isNotEmpty() - playable is EpisodeMedia -> { - val item_ = (playable as EpisodeMedia).episodeOrFetch() - // If an item has chapters but they are not loaded yet, still display the button. - chapterControlVisible = !item_?.chapters.isNullOrEmpty() - } - } - val newVisibility = if (chapterControlVisible) View.VISIBLE else View.GONE - if (binding.chapterButton.visibility != newVisibility) { - binding.chapterButton.visibility = newVisibility - ObjectAnimator.ofFloat(binding.chapterButton, "alpha", - (if (chapterControlVisible) 0 else 1).toFloat(), (if (chapterControlVisible) 1 else 0).toFloat()).start() - } - } - - private fun refreshChapterData(chapterIndex: Int) { - Logd(TAG, "in refreshChapterData $chapterIndex") - if (playable != null && chapterIndex > -1) { - if (playable!!.getPosition() > playable!!.getDuration() || chapterIndex >= playable!!.getChapters().size - 1) { - displayedChapterIndex = playable!!.getChapters().size - 1 - binding.butNextChapter.visibility = View.INVISIBLE - } else { - displayedChapterIndex = chapterIndex - binding.butNextChapter.visibility = View.VISIBLE - } - } - displayCoverImage() - } - - private fun displayCoverImage() { - if (playable == null) return - if (displayedChapterIndex == -1 || playable!!.getChapters().isEmpty() || playable!!.getChapters()[displayedChapterIndex].imageUrl.isNullOrEmpty()) { - val imageLoader = binding.imgvCover.context.imageLoader - val imageRequest = ImageRequest.Builder(requireContext()) - .data(playable!!.getImageLocation()) - .setHeader("User-Agent", "Mozilla/5.0") - .placeholder(R.color.light_gray) - .listener(object : ImageRequest.Listener { - override fun onError(request: ImageRequest, result: ErrorResult) { - val fallbackImageRequest = ImageRequest.Builder(requireContext()) - .data(ImageResourceUtils.getFallbackImageLocation(playable!!)) - .setHeader("User-Agent", "Mozilla/5.0") - .error(R.mipmap.ic_launcher) - .target(binding.imgvCover) - .build() - imageLoader.enqueue(fallbackImageRequest) - } - }) - .target(binding.imgvCover) - .build() - imageLoader.enqueue(imageRequest) - - } else { - val imgLoc = EmbeddedChapterImage.getModelFor(playable!!, displayedChapterIndex) - val imageLoader = binding.imgvCover.context.imageLoader - val imageRequest = ImageRequest.Builder(requireContext()) - .data(imgLoc) - .setHeader("User-Agent", "Mozilla/5.0") - .placeholder(R.color.light_gray) - .listener(object : ImageRequest.Listener { - override fun onError(request: ImageRequest, result: ErrorResult) { - val fallbackImageRequest = ImageRequest.Builder(requireContext()) - .data(ImageResourceUtils.getFallbackImageLocation(playable!!)) - .setHeader("User-Agent", "Mozilla/5.0") - .error(R.mipmap.ic_launcher) - .target(binding.imgvCover) - .build() - imageLoader.enqueue(fallbackImageRequest) - } - }) - .target(binding.imgvCover) - .build() - imageLoader.enqueue(imageRequest) - } - } - - @UnstableApi private fun seekToPrevChapter() { - val curr: Chapter? = currentChapter - if (curr == null || displayedChapterIndex == -1) return - - when { - displayedChapterIndex < 1 -> seekTo(0) - (curPositionFB - 10000 * curSpeedFB) < curr.start -> { - refreshChapterData(displayedChapterIndex - 1) - if (playable != null) seekTo(playable!!.getChapters()[displayedChapterIndex].start.toInt()) - } - else -> seekTo(curr.start.toInt()) - } - } - - @UnstableApi private fun seekToNextChapter() { - if (playable == null || playable!!.getChapters().isEmpty() || displayedChapterIndex == -1 || displayedChapterIndex + 1 >= playable!!.getChapters().size) return - refreshChapterData(displayedChapterIndex + 1) - seekTo(playable!!.getChapters()[displayedChapterIndex].start.toInt()) - } - - - @UnstableApi override fun onPause() { - super.onPause() - savePreference() - } - - @UnstableApi private fun savePreference() { - Logd(TAG, "Saving preferences") - val editor = prefs?.edit() ?: return - if (curMedia != null) { - Logd(TAG, "Saving scroll position: " + binding.itemDescriptionFragment.scrollY) - editor.putInt(PREF_SCROLL_Y, binding.itemDescriptionFragment.scrollY) - editor.putString(PREF_PLAYABLE_ID, curMedia!!.getIdentifier().toString()) - } else { - Logd(TAG, "savePreferences was called while media or webview was null") - editor.putInt(PREF_SCROLL_Y, -1) - editor.putString(PREF_PLAYABLE_ID, "") - } - editor.apply() - } - - @UnstableApi private fun restoreFromPreference(): Boolean { - if ((activity as MainActivity).bottomSheet.state != BottomSheetBehavior.STATE_EXPANDED) return false - - Logd(TAG, "Restoring from preferences") - val activity: Activity? = activity - if (activity != null) { - val id = prefs!!.getString(PREF_PLAYABLE_ID, "") - val scrollY = prefs!!.getInt(PREF_SCROLL_Y, -1) - if (scrollY != -1) { - if (id == curMedia?.getIdentifier()?.toString()) { - Logd(TAG, "Restored scroll Position: $scrollY") - binding.itemDescriptionFragment.scrollTo(binding.itemDescriptionFragment.scrollX, scrollY) - return true - } - Logd(TAG, "reset scroll Position: 0") - binding.itemDescriptionFragment.scrollTo(0, 0) - return true - } - } - return false - } - - fun scrollToTop() { - binding.itemDescriptionFragment.scrollTo(0, 0) - savePreference() - } - - fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { - if (playable?.getIdentifier() != event.media?.getIdentifier()) return - val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(playable, event.position) - if (newChapterIndex >= 0 && newChapterIndex != displayedChapterIndex) refreshChapterData(newChapterIndex) - } - - fun setItem(item_: Episode) { - Logd(TAG, "setItem ${item_.title}") - if (currentItem?.identifier != item_.identifier) { - currentItem = item_ - showHomeText = false - homeText = null - } - } - -// override fun onConfigurationChanged(newConfig: Configuration) { -// super.onConfigurationChanged(newConfig) -// configureForOrientation(newConfig) -// } -// -// private fun configureForOrientation(newConfig: Configuration) { -// val isPortrait = newConfig.orientation == Configuration.ORIENTATION_PORTRAIT -// -//// binding.coverFragment.orientation = if (isPortrait) LinearLayout.VERTICAL else LinearLayout.HORIZONTAL -// -// if (isPortrait) { -// binding.coverHolder.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) -//// binding.coverFragmentTextContainer.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) -// } else { -// binding.coverHolder.layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f) -//// binding.coverFragmentTextContainer.layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f) -// } -// -// (binding.episodeDetails.parent as ViewGroup).removeView(binding.episodeDetails) -// if (isPortrait) { -// binding.coverFragment.addView(binding.episodeDetails) -// } else { -// binding.coverFragmentTextContainer.addView(binding.episodeDetails) -// } -// } - - @UnstableApi private fun copyText(text: String): Boolean { - val clipboardManager: ClipboardManager? = ContextCompat.getSystemService(requireContext(), ClipboardManager::class.java) - clipboardManager?.setPrimaryClip(ClipData.newPlainText("Podcini", text)) - if (Build.VERSION.SDK_INT <= 32) { - (requireActivity() as MainActivity).showSnackbarAbovePlayer(resources.getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT) - } - return true - } - - companion object { - private val TAG: String = PlayerDetailsFragment::class.simpleName ?: "Anonymous" - - private const val PREF = "ItemDescriptionFragmentPrefs" - private const val PREF_SCROLL_Y = "prefScrollY" - private const val PREF_PLAYABLE_ID = "prefPlayableId" + companion object { + val TAG = AudioPlayerFragment::class.simpleName ?: "Anonymous" + var media3Controller: MediaController? = null - var media3Controller: MediaController? = null + private const val PREF = "ItemDescriptionFragmentPrefs" + private const val PREF_SCROLL_Y = "prefScrollY" + private const val PREF_PLAYABLE_ID = "prefPlayableId" - var prefs: SharedPreferences? = null - fun getSharedPrefs(context: Context) { - if (prefs == null) prefs = context.getSharedPreferences(PREF, Context.MODE_PRIVATE) - } + var prefs: SharedPreferences? = null + fun getSharedPrefs(context: Context) { + if (prefs == null) prefs = context.getSharedPreferences(PREF, Context.MODE_PRIVATE) } } - - companion object { - val TAG = AudioPlayerFragment::class.simpleName ?: "Anonymous" - } } 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 d44f88d6..7016b101 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 @@ -2,6 +2,8 @@ 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 @@ -102,45 +104,6 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene emptyView.setMessage(R.string.no_all_episodes_label) emptyView.hide() -// val multiSelectDial = MultiSelectSpeedDialBinding.bind(binding.root) -// speedDialView = multiSelectDial.fabSD -// speedDialView.overlayLayout = multiSelectDial.fabSDOverlay -// speedDialView.inflate(R.menu.episodes_apply_action_speeddial) -// speedDialView.setOnChangeListener(object : SpeedDialView.OnChangeListener { -// override fun onMainActionSelected(): Boolean { -// return false -// } -// override fun onToggleChanged(open: Boolean) { -// if (open && adapter.selectedCount == 0) { -// (activity as MainActivity).showSnackbarAbovePlayer(R.string.no_items_selected, Snackbar.LENGTH_SHORT) -// speedDialView.close() -// } -// } -// }) -// speedDialView.setOnActionSelectedListener { actionItem: SpeedDialActionItem -> -//// if (actionItem.id == R.id.put_in_queue_batch) { -//// EpisodeMultiSelectHandler((activity as MainActivity), actionItem.id).handleAction(adapter.selectedItems.filterIsInstance()) -//// true -//// } else { -// var confirmationString = 0 -// Logd(TAG, "adapter.selectedItems: ${adapter.selectedItems.size}") -// if (adapter.selectedItems.size >= 25 || adapter.shouldSelectLazyLoadedItems()) { -// when (actionItem.id) { -// R.id.toggle_played_batch -> confirmationString = R.string.multi_select_toggle_played_confirmation -// else -> confirmationString = R.string.multi_select_operation_confirmation -// } -// } -// if (confirmationString == 0) performMultiSelectAction(actionItem.id) -// else { -// object : ConfirmationDialog(activity as MainActivity, R.string.multi_select, confirmationString) { -// override fun onConfirmButtonPressed(dialog: DialogInterface) { -// performMultiSelectAction(actionItem.id) -// } -// }.createNewDialog().show() -// } -// true -//// } -// } return binding.root } @@ -194,10 +157,12 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { if (loadItemsRunning) return -// for (downloadUrl in event.urls) { -// val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(episodes, downloadUrl) -// if (pos >= 0) adapter.notifyItemChangedCompat(pos) -// } + 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 + + } } private var eventSink: Job? = null diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt index ea5a4120..9441f06b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt @@ -81,7 +81,6 @@ class ChaptersFragment : AppCompatDialogFragment() { progressBar.visibility = View.VISIBLE loadMediaInfo(true) } - return dialog } @@ -106,19 +105,15 @@ class ChaptersFragment : AppCompatDialogFragment() { } }) recyclerView.adapter = adapter - progressBar.visibility = View.VISIBLE - val wrapHeight = CoordinatorLayout.LayoutParams(CoordinatorLayout.LayoutParams.MATCH_PARENT, CoordinatorLayout.LayoutParams.WRAP_CONTENT) recyclerView.layoutParams = wrapHeight - controller = object : ServiceStatusHandler(requireActivity()) { override fun loadMediaInfo() { this@ChaptersFragment.loadMediaInfo(false) } } controller?.init() - return binding.root } @@ -167,7 +162,6 @@ class ChaptersFragment : AppCompatDialogFragment() { private fun getCurrentChapter(media: Playable?): Int { if (controller == null) return -1 - return getCurrentChapterIndex(media, curPositionFB) } @@ -192,7 +186,6 @@ class ChaptersFragment : AppCompatDialogFragment() { dismiss() Toast.makeText(context, R.string.no_chapters_label, Toast.LENGTH_LONG).show() } else progressBar.visibility = View.GONE - adapter.setMedia(media) (dialog as AlertDialog).getButton(DialogInterface.BUTTON_NEUTRAL).visibility = View.INVISIBLE if ((media is EpisodeMedia) && !media.episodeOrFetch()?.podcastIndexChapterUrl.isNullOrEmpty()) @@ -214,7 +207,6 @@ class ChaptersFragment : AppCompatDialogFragment() { } class ChaptersListAdapter(private val context: Context, private val callback: Callback?) : RecyclerView.Adapter() { - private var media: Playable? = null private var currentChapterIndex = -1 private var currentChapterPosition: Long = -1 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 c70ee078..f1f5423e 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 @@ -164,7 +164,7 @@ class DownloadLogFragment : BottomSheetDialogFragment(), OnItemClickListener, To } private class DownloadLogAdapter(private val context: Activity) : BaseAdapter() { - private var downloadLog: List = ArrayList() + private var downloadLog: List = listOf() fun setDownloadLog(downloadLog: List) { this.downloadLog = downloadLog 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 new file mode 100644 index 00000000..b45fa9fe --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt @@ -0,0 +1,929 @@ +package ac.mdiq.podcini.ui.fragment + +import ac.mdiq.podcini.R +import ac.mdiq.podcini.databinding.EpisodeHomeFragmentBinding +import ac.mdiq.podcini.databinding.EpisodeInfoFragmentBinding +import ac.mdiq.podcini.net.download.service.DownloadServiceInterface +import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient +import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource +import ac.mdiq.podcini.net.utils.NetworkUtils.isEpisodeHeadDownloadAllowed +import ac.mdiq.podcini.playback.base.InTheatre +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo +import ac.mdiq.podcini.preferences.UsageStatistics +import ac.mdiq.podcini.preferences.UserPreferences +import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope +import ac.mdiq.podcini.storage.database.RealmDB.unmanaged +import ac.mdiq.podcini.storage.database.RealmDB.upsert +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.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.activity.MainActivity +import ac.mdiq.podcini.ui.compose.CustomTheme +import ac.mdiq.podcini.ui.utils.ShownotesCleaner +import ac.mdiq.podcini.ui.utils.ThemeUtils +import ac.mdiq.podcini.ui.view.ShownotesWebView +import ac.mdiq.podcini.util.EventFlow +import ac.mdiq.podcini.util.FlowEvent +import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.speech.tts.TextToSpeech +import android.text.TextUtils +import android.text.format.Formatter.formatShortFileSize +import android.util.Log +import android.view.* +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.appcompat.widget.Toolbar +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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 +import androidx.core.app.ShareCompat +import androidx.core.text.HtmlCompat +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi +import coil.compose.AsyncImage +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.snackbar.Snackbar +import com.skydoves.balloon.ArrowOrientation +import com.skydoves.balloon.ArrowOrientationRules +import com.skydoves.balloon.Balloon +import com.skydoves.balloon.BalloonAnimation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.dankito.readability4j.extended.Readability4JExtended +import okhttp3.Request.Builder +import java.io.File +import java.util.* + +/** + * Displays information about an Episode (FeedItem) and actions. + */ +@UnstableApi +class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { + private var _binding: EpisodeInfoFragmentBinding? = null + private val binding get() = _binding!! + + private var homeFragment: EpisodeHomeFragment? = null + + private var itemLoaded = false + private var episode: Episode? = null // managed + + private var webviewData by mutableStateOf("") + + private lateinit var shownotesCleaner: ShownotesCleaner + private lateinit var toolbar: MaterialToolbar +// private lateinit var webvDescription: ShownotesWebView +// private lateinit var imgvCover: ImageView + +// private lateinit var butAction1: ImageView +// private lateinit var butAction2: ImageView + + private var actionButton1 by mutableStateOf(null) + private var actionButton2 by mutableStateOf(null) + + @UnstableApi + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + super.onCreateView(inflater, container, savedInstanceState) + + _binding = EpisodeInfoFragmentBinding.inflate(inflater, container, false) + Logd(TAG, "fragment onCreateView") + + toolbar = binding.toolbar + toolbar.title = "" + toolbar.inflateMenu(R.menu.feeditem_options) + toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } + toolbar.setOnMenuItemClickListener(this) + + binding.composeView.setContent{ + CustomTheme(requireContext()) { + InfoView() + } + } + +// binding.txtvPodcast.setOnClickListener { openPodcast() } +// binding.txtvTitle.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL) +// binding.txtvTitle.ellipsize = TextUtils.TruncateAt.END +// webvDescription = binding.webvDescription +// webvDescription.setTimecodeSelectedListener { time: Int? -> +// val cMedia = curMedia +// if (episode?.media?.getIdentifier() == cMedia?.getIdentifier()) seekTo(time ?: 0) +// else (activity as MainActivity).showSnackbarAbovePlayer(R.string.play_this_to_seek_position, Snackbar.LENGTH_LONG) +// } +// registerForContextMenu(webvDescription) + +// imgvCover = binding.imgvCover +// imgvCover.setOnClickListener { openPodcast() } +// butAction1 = binding.butAction1 +// butAction2 = binding.butAction2 + +// binding.homeButton.setOnClickListener { +// if (!episode?.link.isNullOrEmpty()) { +// homeFragment = EpisodeHomeFragment.newInstance(episode!!) +// (activity as MainActivity).loadChildFragment(homeFragment!!) +// } else Toast.makeText(context, "Episode link is not valid ${episode?.link}", Toast.LENGTH_LONG).show() +// } + +// butAction1.setOnClickListener(View.OnClickListener { +// when { +// actionButton1 is StreamActionButton && !UserPreferences.isStreamOverDownload +// && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_STREAM) -> { +// showOnDemandConfigBalloon(true) +// return@OnClickListener +// } +// actionButton1 == null -> return@OnClickListener // Not loaded yet +// else -> actionButton1?.onClick(requireContext()) +// } +// }) +// butAction2.setOnClickListener(View.OnClickListener { +// when { +// actionButton2 is DownloadActionButton && UserPreferences.isStreamOverDownload +// && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_DOWNLOAD) -> { +// showOnDemandConfigBalloon(false) +// return@OnClickListener +// } +// actionButton2 == null -> return@OnClickListener // Not loaded yet +// else -> actionButton2?.onClick(requireContext()) +// } +// }) + shownotesCleaner = ShownotesCleaner(requireContext()) + onFragmentLoaded() + load() + return binding.root + } + + @Composable + fun InfoView() { + Column { + val textColor = MaterialTheme.colors.onSurface + Row(modifier = Modifier.padding(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically) { + val imgLoc = if (episode != null) ImageResourceUtils.getEpisodeListImageLocation(episode!!) else null + AsyncImage(model = imgLoc, contentDescription = "imgvCover", Modifier.width(56.dp).height(56.dp).clickable(onClick = { openPodcast() })) + Column(modifier = Modifier.padding(start = 10.dp)) { + Text(txtvPodcast, color = textColor, style = MaterialTheme.typography.body1, modifier = Modifier.clickable { openPodcast() }) + Text(txtvTitle, color = textColor, style = MaterialTheme.typography.body1.copy(fontWeight = FontWeight.Bold), maxLines = 5, overflow = TextOverflow.Ellipsis) + Text(txtvPublished + " · " + txtvDuration + " · " + txtvSize, color = textColor, style = MaterialTheme.typography.body2) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.weight(1f)) + if (hasMedia) Icon(painter = painterResource(actionButton1?.getDrawable()?: R.drawable.ic_questionmark), tint = textColor, contentDescription = "butAction1", + modifier = Modifier.width(24.dp).height(24.dp).clickable(onClick = { + when { + actionButton1 is StreamActionButton && !UserPreferences.isStreamOverDownload + && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_STREAM) -> { + showOnDemandConfigBalloon(true) + return@clickable + } + actionButton1 == null -> return@clickable // Not loaded yet + else -> actionButton1?.onClick(requireContext()) + } + })) + Spacer(modifier = Modifier.weight(0.2f)) + Icon(painter = painterResource(R.drawable.baseline_home_work_24), tint = textColor, contentDescription = "homeButton", + modifier = Modifier.width(24.dp).height(24.dp).clickable(onClick = { + if (!episode?.link.isNullOrEmpty()) { + homeFragment = EpisodeHomeFragment.newInstance(episode!!) + (activity as MainActivity).loadChildFragment(homeFragment!!) + } else Toast.makeText(context, "Episode link is not valid ${episode?.link}", Toast.LENGTH_LONG).show() + })) + Spacer(modifier = Modifier.weight(0.2f)) + Box(modifier = Modifier.width(40.dp).height(40.dp).align(Alignment.CenterVertically), contentAlignment = Alignment.Center) { + Icon(painter = painterResource(actionButton2?.getDrawable()?: R.drawable.ic_questionmark), tint = textColor, contentDescription = "butAction2", modifier = Modifier.width(24.dp).height(24.dp).clickable { + when { + actionButton2 is DownloadActionButton && UserPreferences.isStreamOverDownload + && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_DOWNLOAD) -> { + showOnDemandConfigBalloon(false) + return@clickable + } + actionButton2 == null -> return@clickable // Not loaded yet + else -> actionButton2?.onClick(requireContext()) + } + }) +// if (cond) CircularProgressIndicator(progress = 0.01f * dlPercent, strokeWidth = 4.dp, color = textColor) + } + Spacer(modifier = Modifier.weight(1f)) + } + if (!hasMedia) Text("noMediaLabel", color = textColor, style = MaterialTheme.typography.body2) + val scrollState = rememberScrollState() + Column(modifier = Modifier.fillMaxWidth().verticalScroll(scrollState)) { + AndroidView(modifier = Modifier.fillMaxSize(), factory = { context -> + ShownotesWebView(context).apply { + setTimecodeSelectedListener { time: Int -> seekTo(time) } + setPageFinishedListener { + // Restoring the scroll position might not always work + postDelayed({ }, 50) + } + } + }, update = { + it.loadDataWithBaseURL("https://127.0.0.1", webviewData, "text/html", "utf-8", "about:blank") + }) + Text(itemLink, color = textColor, style = MaterialTheme.typography.caption) + } + } + } + + override fun onStart() { + super.onStart() + procFlowEvents() + } + + override fun onStop() { + super.onStop() + cancelFlowEvents() + } + + @OptIn(UnstableApi::class) + private fun showOnDemandConfigBalloon(offerStreaming: Boolean) { + val isLocaleRtl = (TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL) + val balloon: Balloon = Balloon.Builder(requireContext()) + .setArrowOrientation(ArrowOrientation.TOP) + .setArrowOrientationRules(ArrowOrientationRules.ALIGN_FIXED) + .setArrowPosition(0.25f + (if (isLocaleRtl xor offerStreaming) 0f else 0.5f)) + .setWidthRatio(1.0f) + .setMarginLeft(8) + .setMarginRight(8) + .setBackgroundColor(ThemeUtils.getColorFromAttr(requireContext(), com.google.android.material.R.attr.colorSecondary)) + .setBalloonAnimation(BalloonAnimation.OVERSHOOT) + .setLayout(R.layout.popup_bubble_view) + .setDismissWhenTouchOutside(true) + .setLifecycleOwner(this) + .build() + val ballonView = balloon.getContentView() + val positiveButton: Button = ballonView.findViewById(R.id.balloon_button_positive) + val negativeButton: Button = ballonView.findViewById(R.id.balloon_button_negative) + val message: TextView = ballonView.findViewById(R.id.balloon_message) + message.setText(if (offerStreaming) R.string.on_demand_config_stream_text + else R.string.on_demand_config_download_text) + positiveButton.setOnClickListener { + UserPreferences.isStreamOverDownload = offerStreaming + // Update all visible lists to reflect new streaming action button + // TODO: need another event type? + EventFlow.postEvent(FlowEvent.EpisodePlayedEvent()) + (activity as MainActivity).showSnackbarAbovePlayer(R.string.on_demand_config_setting_changed, Snackbar.LENGTH_SHORT) + balloon.dismiss() + } + negativeButton.setOnClickListener { + UsageStatistics.doNotAskAgain(UsageStatistics.ACTION_STREAM) // Type does not matter. Both are silenced. + balloon.dismiss() + } +// balloon.showAlignBottom(butAction1, 0, (-12 * resources.displayMetrics.density).toInt()) + } + + @UnstableApi + override fun onMenuItemClick(menuItem: MenuItem): Boolean { + when (menuItem.itemId) { + R.id.share_notes -> { + if (episode == null) return false + val notes = episode!!.description + if (!notes.isNullOrEmpty()) { + val shareText = if (Build.VERSION.SDK_INT >= 24) HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + else HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + val context = requireContext() + val intent = ShareCompat.IntentBuilder(context) + .setType("text/plain") + .setText(shareText) + .setChooserTitle(R.string.share_notes_label) + .createChooserIntent() + context.startActivity(intent) + } + return true + } + else -> { + if (episode == null) return false + return EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, episode!!) + } + } + } + + @UnstableApi + override fun onResume() { + super.onResume() + if (itemLoaded) { +// binding.progbarLoading.visibility = View.GONE + updateAppearance() + } + } + + @OptIn(UnstableApi::class) override fun onDestroyView() { + Logd(TAG, "onDestroyView") +// binding.root.removeView(webvDescription) + episode = null +// webvDescription.clearHistory() +// webvDescription.clearCache(true) +// webvDescription.clearView() +// webvDescription.destroy() + _binding = null + super.onDestroyView() + } + + @UnstableApi + private fun onFragmentLoaded() { +// if (!itemLoaded) +// webvDescription.loadDataWithBaseURL("https://127.0.0.1", webviewData, "text/html", "utf-8", "about:blank") +// if (item?.link != null) binding.webView.loadUrl(item!!.link!!) + updateAppearance() + } + + private fun prepareMenu() { + if (episode!!.media != null) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast) + // these are already available via button1 and button2 + else EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast, R.id.mark_read_item, R.id.visit_website_item) + } + + private var txtvPodcast by mutableStateOf("") + private var txtvTitle by mutableStateOf("") + private var txtvPublished by mutableStateOf("") + private var txtvSize by mutableStateOf("") + private var txtvDuration by mutableStateOf("") + private var itemLink by mutableStateOf("") + @UnstableApi + private fun updateAppearance() { + if (episode == null) { + Logd(TAG, "updateAppearance item is null") + return + } + prepareMenu() + + if (episode!!.feed != null) txtvPodcast = episode!!.feed!!.title ?: "" + txtvTitle = episode!!.title ?:"" + itemLink = episode!!.link?: "" + + if (episode?.pubDate != null) { + val pubDateStr = formatAbbrev(context, Date(episode!!.pubDate)) + txtvPublished = pubDateStr +// binding.txtvPublished.setContentDescription(formatForAccessibility(Date(episode!!.pubDate))) + } + + val media = episode?.media + when { + media == null -> txtvSize = "" + media.size > 0 -> txtvSize = formatShortFileSize(activity, media.size) + isEpisodeHeadDownloadAllowed && !media.checkedOnSizeButUnknown() -> { + txtvSize = "{faw_spinner}" +// Iconify.addIcons(size) + lifecycleScope.launch { + val sizeValue = getMediaSize(episode) + txtvSize = if (sizeValue <= 0) "" else formatShortFileSize(activity, sizeValue) + } + } + else -> txtvSize = "" + } + +// val imgLocFB = ImageResourceUtils.getFallbackImageLocation(episode!!) +// val imageLoader = imgvCover.context.imageLoader +// val imageRequest = ImageRequest.Builder(requireContext()) +// .data(episode!!.imageLocation) +// .placeholder(R.color.light_gray) +// .listener(object : ImageRequest.Listener { +// override fun onError(request: ImageRequest, result: ErrorResult) { +// val fallbackImageRequest = ImageRequest.Builder(requireContext()) +// .data(imgLocFB) +// .setHeader("User-Agent", "Mozilla/5.0") +// .error(R.mipmap.ic_launcher) +// .target(imgvCover) +// .build() +// imageLoader.enqueue(fallbackImageRequest) +// } +// }) +// .target(imgvCover) +// .build() +// imageLoader.enqueue(imageRequest) + + updateButtons() + } + + var hasMedia by mutableStateOf(true) + @UnstableApi + private fun updateButtons() { +// binding.circularProgressBar.visibility = View.GONE + val dls = DownloadServiceInterface.get() + if (episode != null && episode!!.media != null && episode!!.media!!.downloadUrl != null) { + val url = episode!!.media!!.downloadUrl!! + if (dls != null && dls.isDownloadingEpisode(url)) { +// binding.circularProgressBar.visibility = View.VISIBLE +// binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), episode) +// binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url)) + } + } + + val media: EpisodeMedia? = episode?.media + if (media == null) { + if (episode != null) { +// actionButton1 = VisitWebsiteActionButton(episode!!) +// butAction1.visibility = View.INVISIBLE + actionButton2 = VisitWebsiteActionButton(episode!!) + } + hasMedia = false + } else { + hasMedia = true + if (media.getDuration() > 0) { + txtvDuration = DurationConverter.getDurationStringLong(media.getDuration()) +// binding.txtvDuration.setContentDescription(DurationConverter.getDurationStringLocalized(requireContext(), media.getDuration().toLong())) + } + if (episode != null) { + actionButton1 = when { +// media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode!!) + InTheatre.isCurrentlyPlaying(media) -> PauseActionButton(episode!!) + episode!!.feed != null && episode!!.feed!!.isLocalFeed -> PlayLocalActionButton(episode!!) + media.downloaded -> PlayActionButton(episode!!) + else -> StreamActionButton(episode!!) + } + actionButton2 = when { + episode!!.feed?.type == Feed.FeedType.YOUTUBE.name -> VisitWebsiteActionButton(episode!!) + dls != null && media.downloadUrl != null && dls.isDownloadingEpisode(media.downloadUrl!!) -> CancelDownloadActionButton(episode!!) + !media.downloaded -> DownloadActionButton(episode!!) + else -> DeleteActionButton(episode!!) + } +// if (actionButton2 != null && media.getMediaType() == MediaType.FLASH) actionButton2!!.visibility = View.GONE + } + } + + if (actionButton1 != null) { +// butAction1.setImageResource(actionButton1!!.getDrawable()) +// butAction1.visibility = actionButton1!!.visibility + } + if (actionButton2 != null) { +// butAction2.setImageResource(actionButton2!!.getDrawable()) +// butAction2.visibility = actionButton2!!.visibility + } + } + +// override fun onContextItemSelected(item: MenuItem): Boolean { +// return webvDescription.onContextItemSelected(item) +// } + + @OptIn(UnstableApi::class) private fun openPodcast() { + if (episode?.feedId == null) return + + val fragment: Fragment = FeedEpisodesFragment.newInstance(episode!!.feedId!!) + (activity as MainActivity).loadChildFragment(fragment) + } + + private var eventSink: Job? = null + private var eventStickySink: Job? = null + private fun cancelFlowEvents() { + eventSink?.cancel() + eventSink = null + eventStickySink?.cancel() + eventStickySink = null + } + private fun procFlowEvents() { + if (eventSink == null) eventSink = lifecycleScope.launch { + EventFlow.events.collectLatest { event -> + Logd(TAG, "Received event: ${event.TAG}") + when (event) { + is FlowEvent.QueueEvent -> onQueueEvent(event) + is FlowEvent.FavoritesEvent -> onFavoriteEvent(event) + is FlowEvent.EpisodeEvent -> onEpisodeEvent(event) + is FlowEvent.PlayerSettingsEvent -> updateButtons() + is FlowEvent.EpisodePlayedEvent -> load() + else -> {} + } + } + } + if (eventStickySink == null) eventStickySink = lifecycleScope.launch { + EventFlow.stickyEvents.collectLatest { event -> + Logd(TAG, "Received event: ${event.TAG}") + when (event) { + is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event) + else -> {} + } + } + } + } + + private fun onFavoriteEvent(event: FlowEvent.FavoritesEvent) { + if (episode?.id == event.episode.id) { + episode = unmanaged(episode!!) + episode!!.isFavorite = event.episode.isFavorite +// episode = event.episode + prepareMenu() + } + } + + private fun onQueueEvent(event: FlowEvent.QueueEvent) { + var i = 0 + val size: Int = event.episodes.size + while (i < size) { + val item_ = event.episodes[i] + if (item_.id == episode?.id) { +// episode = unmanaged(item_) +// episode = item_ + prepareMenu() + break + } + i++ + } + } + + private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) { +// Logd(TAG, "onEventMainThread() called with ${event.TAG}") + if (this.episode == null) return + for (item in event.episodes) { + if (this.episode!!.id == item.id) { + load() + return + } + } + } + + private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { + if (episode == null || episode!!.media == null) return + if (!event.urls.contains(episode!!.media!!.downloadUrl)) return + if (itemLoaded && activity != null) updateButtons() + } + + private var loadItemsRunning = false + @UnstableApi + private fun load() { +// if (!itemLoaded) binding.progbarLoading.visibility = View.VISIBLE + Logd(TAG, "load() called") + if (!loadItemsRunning) { + loadItemsRunning = true + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + if (episode != null) { + val duration = episode!!.media?.getDuration() ?: Int.MAX_VALUE + Logd(TAG, "description: ${episode?.description}") + val url = episode!!.media?.downloadUrl + if (url?.contains("youtube.com") == true && episode!!.description?.startsWith("Short:") == true) { + Logd(TAG, "getting extended description: ${episode!!.title}") + try { + val info = episode!!.streamInfo + if (info?.description?.content != null) { + episode = upsert(episode!!) { it.description = info.description?.content } + webviewData = shownotesCleaner.processShownotes(info.description!!.content, duration) + } else webviewData = shownotesCleaner.processShownotes(episode!!.description ?: "", duration) + } catch (e: Exception) { Logd(TAG, "StreamInfo error: ${e.message}") } + } else webviewData = shownotesCleaner.processShownotes(episode!!.description ?: "", duration) + } + } + withContext(Dispatchers.Main) { +// binding.progbarLoading.visibility = View.GONE + onFragmentLoaded() + itemLoaded = true + } + } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) + } finally { loadItemsRunning = false } + } + } + } + + fun setItem(item_: Episode) { + episode = item_ + } + + /** + * Displays information about an Episode (FeedItem) and actions. + */ + class EpisodeHomeFragment : Fragment() { + private var _binding: EpisodeHomeFragmentBinding? = null + private val binding get() = _binding!! + + private var startIndex = 0 + private var ttsSpeed = 1.0f + + private lateinit var toolbar: MaterialToolbar + + private var readerText: String? = null + private var cleanedNotes: String? = null + private var readerhtml: String? = null + private var readMode = true + private var ttsPlaying = false + private var jsEnabled = false + + private var tts: TextToSpeech? = null + private var ttsReady = false + + @UnstableApi + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + super.onCreateView(inflater, container, savedInstanceState) + _binding = EpisodeHomeFragmentBinding.inflate(inflater, container, false) + Logd(TAG, "fragment onCreateView") + toolbar = binding.toolbar + toolbar.title = "" + toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } + toolbar.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED) + + if (!episode?.link.isNullOrEmpty()) showContent() + else { + Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() + parentFragmentManager.popBackStack() + } + binding.webView.apply { + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + val isEmpty = view?.title.isNullOrEmpty() && view?.contentDescription.isNullOrEmpty() + if (isEmpty) Logd(TAG, "content is empty") + } + } + } + updateAppearance() + return binding.root + } + + @OptIn(UnstableApi::class) private fun switchMode() { + readMode = !readMode + showContent() + updateAppearance() + } + + @OptIn(UnstableApi::class) private fun showReaderContent() { + runOnIOScope { + if (!episode?.link.isNullOrEmpty()) { + if (cleanedNotes == null) { + if (episode?.transcript == null) { + val url = episode!!.link!! + val htmlSource = fetchHtmlSource(url) + val article = Readability4JExtended(episode?.link!!, htmlSource).parse() + readerText = article.textContent +// Log.d(TAG, "readability4J: ${article.textContent}") + readerhtml = article.contentWithDocumentsCharsetOrUtf8 + } else { + readerhtml = episode!!.transcript + readerText = HtmlCompat.fromHtml(readerhtml!!, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + } + if (!readerhtml.isNullOrEmpty()) { + val shownotesCleaner = ShownotesCleaner(requireContext()) + cleanedNotes = shownotesCleaner.processShownotes(readerhtml!!, 0) + episode = upsertBlk(episode!!) { + it.setTranscriptIfLonger(readerhtml) + } +// persistEpisode(episode) + } + } + } + if (!cleanedNotes.isNullOrEmpty()) { + if (!ttsReady) initializeTTS(requireContext()) + withContext(Dispatchers.Main) { + binding.readerView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes ?: "No notes", + "text/html", "UTF-8", null) + binding.readerView.visibility = View.VISIBLE + binding.webView.visibility = View.GONE + } + } else { + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() + } + } + } + } + + private fun initializeTTS(context: Context) { + Logd(TAG, "starting TTS") + if (tts == null) { + tts = TextToSpeech(context) { status: Int -> + if (status == TextToSpeech.SUCCESS) { + if (episode?.feed?.language != null) { + val result = tts?.setLanguage(Locale(episode!!.feed!!.language!!)) + if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { + Log.w(TAG, "TTS language not supported ${episode?.feed?.language}") + requireActivity().runOnUiThread { + Toast.makeText(context, getString(R.string.language_not_supported_by_tts) + " ${episode?.feed?.language}", Toast.LENGTH_LONG).show() + } + } + } + ttsReady = true +// semaphore.release() + Logd(TAG, "TTS init success") + } else { + Log.w(TAG, "TTS init failed") + requireActivity().runOnUiThread { Toast.makeText(context, R.string.tts_init_failed, Toast.LENGTH_LONG).show() } + } + } + } + } + + private fun showWebContent() { + if (!episode?.link.isNullOrEmpty()) { + binding.webView.settings.javaScriptEnabled = jsEnabled + Logd(TAG, "currentItem!!.link ${episode!!.link}") + binding.webView.loadUrl(episode!!.link!!) + binding.readerView.visibility = View.GONE + binding.webView.visibility = View.VISIBLE + } else Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() + } + + private fun showContent() { + if (readMode) showReaderContent() + else showWebContent() + } + + private val menuProvider = object: MenuProvider { + override fun onPrepareMenu(menu: Menu) { +// super.onPrepareMenu(menu) + Logd(TAG, "onPrepareMenu called") + val textSpeech = menu.findItem(R.id.text_speech) + textSpeech?.isVisible = readMode && tts != null + if (textSpeech?.isVisible == true) { + if (ttsPlaying) textSpeech.setIcon(R.drawable.ic_pause) else textSpeech.setIcon(R.drawable.ic_play_24dp) + } + menu.findItem(R.id.share_notes)?.setVisible(readMode) + menu.findItem(R.id.switchJS)?.setVisible(!readMode) + val btn = menu.findItem(R.id.switch_home) + if (readMode) btn?.setIcon(R.drawable.baseline_home_24) + else btn?.setIcon(R.drawable.outline_home_24) + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.episode_home, menu) + onPrepareMenu(menu) + } + + @OptIn(UnstableApi::class) override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + when (menuItem.itemId) { + R.id.switch_home -> { + switchMode() + return true + } + R.id.switchJS -> { + jsEnabled = !jsEnabled + showWebContent() + return true + } + R.id.text_speech -> { + Logd(TAG, "text_speech selected: $readerText") + if (tts != null) { + if (tts!!.isSpeaking) tts?.stop() + if (!ttsPlaying) { + ttsPlaying = true + if (!readerText.isNullOrEmpty()) { + ttsSpeed = episode?.feed?.preferences?.playSpeed ?: 1.0f + tts?.setSpeechRate(ttsSpeed) + while (startIndex < readerText!!.length) { + val endIndex = minOf(startIndex + MAX_CHUNK_LENGTH, readerText!!.length) + val chunk = readerText!!.substring(startIndex, endIndex) + tts?.speak(chunk, TextToSpeech.QUEUE_ADD, null, null) + startIndex += MAX_CHUNK_LENGTH + } + } + } else ttsPlaying = false + updateAppearance() + } else Toast.makeText(context, R.string.tts_not_available, Toast.LENGTH_LONG).show() + + return true + } + R.id.share_notes -> { + val notes = readerhtml + if (!notes.isNullOrEmpty()) { + val shareText = HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + val context = requireContext() + val intent = ShareCompat.IntentBuilder(context) + .setType("text/plain") + .setText(shareText) + .setChooserTitle(R.string.share_notes_label) + .createChooserIntent() + context.startActivity(intent) + } + return true + } + else -> { + return episode != null + } + } + } + } + + @UnstableApi + override fun onResume() { + super.onResume() + updateAppearance() + } + + private fun cleatWebview(webview: WebView) { + binding.root.removeView(webview) + webview.clearHistory() + webview.clearCache(true) + webview.clearView() + webview.destroy() + } + + @OptIn(UnstableApi::class) override fun onDestroyView() { + Logd(TAG, "onDestroyView") + cleatWebview(binding.webView) + cleatWebview(binding.readerView) + _binding = null + tts?.stop() + tts?.shutdown() + tts = null + super.onDestroyView() + } + + @UnstableApi + private fun updateAppearance() { + if (episode == null) { + Logd(TAG, "updateAppearance currentItem is null") + return + } +// onPrepareOptionsMenu(toolbar.menu) + toolbar.invalidateMenu() +// menuProvider.onPrepareMenu(toolbar.menu) + } + + companion object { + private val TAG: String = EpisodeHomeFragment::class.simpleName ?: "Anonymous" + private const val MAX_CHUNK_LENGTH = 2000 + + var episode: Episode? = null // unmanged + + fun newInstance(item: Episode): EpisodeHomeFragment { + val fragment = EpisodeHomeFragment() + Logd(TAG, "item.itemIdentifier ${item.identifier}") + if (item.identifier != episode?.identifier) episode = item + return fragment + } + } + } + + companion object { + private val TAG: String = EpisodeInfoFragment::class.simpleName ?: "Anonymous" + + private suspend fun getMediaSize(episode: Episode?) : Long { + return withContext(Dispatchers.IO) { + if (!isEpisodeHeadDownloadAllowed) return@withContext -1 + val media = episode?.media ?: return@withContext -1 + + var size = Int.MIN_VALUE.toLong() + when { + media.downloaded -> { + val url = media.getLocalMediaUrl() + if (!url.isNullOrEmpty()) { + val mediaFile = File(url) + if (mediaFile.exists()) size = mediaFile.length() + } + } + !media.checkedOnSizeButUnknown() -> { + // only query the network if we haven't already checked + + val url = media.downloadUrl + if (url.isNullOrEmpty()) return@withContext -1 + + val client = getHttpClient() + val httpReq: Builder = Builder().url(url).header("Accept-Encoding", "identity").head() + try { + val response = client.newCall(httpReq.build()).execute() + if (response.isSuccessful) { + val contentLength = response.header("Content-Length")?:"0" + try { + size = contentLength.toInt().toLong() + } catch (e: NumberFormatException) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + } catch (e: Exception) { + Log.e(TAG, Log.getStackTraceString(e)) + return@withContext -1 // better luck next time + } + } + } + // they didn't tell us the size, but we don't want to keep querying on it + upsert(episode) { + if (size <= 0) it.media?.setCheckedOnSizeButUnknown() + else it.media?.size = size + } + size + } + } + + fun newInstance(item: Episode): EpisodeInfoFragment { + val fragment = EpisodeInfoFragment() + fragment.setItem(item) + return fragment + } + } +} 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 4fcefab0..0bee1df5 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 @@ -3,7 +3,8 @@ package ac.mdiq.podcini.ui.fragment //import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.FeedItemListFragmentBinding -import ac.mdiq.podcini.databinding.MoreContentListFooterBinding +import ac.mdiq.podcini.net.download.DownloadStatus +import ac.mdiq.podcini.net.download.DownloadStatus.State.UNKNOWN import ac.mdiq.podcini.net.feed.FeedUpdateManager import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.storage.database.Feeds.getFeed @@ -14,13 +15,10 @@ import ac.mdiq.podcini.storage.database.RealmDB.upsert import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.model.EpisodeSortOrder.Companion.fromCode -import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor -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.activity.MainActivity -import ac.mdiq.podcini.ui.adapter.SelectableAdapter import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.EpisodeLazyColumn import ac.mdiq.podcini.ui.compose.FeedEpisodesHeader @@ -74,6 +72,8 @@ import java.util.concurrent.Semaphore private var feedID: Long = 0 private var feed by mutableStateOf(null) private val episodes = mutableStateListOf() + private var ieMap: Map = mapOf() + private var ueMap: Map = mapOf() private var enableFilter: Boolean = true private var filterButColor = mutableStateOf(Color.White) @@ -133,6 +133,8 @@ import java.util.concurrent.Semaphore if (sortOrder != null) getPermutor(sortOrder).reorder(etmp) episodes.clear() 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() loadItemsRunning = false } } @@ -217,8 +219,9 @@ import java.util.concurrent.Semaphore ioScope.cancel() feed = null + ieMap = mapOf() + ueMap = mapOf() episodes.clear() - tts?.stop() tts?.shutdown() ttsWorking = false @@ -308,7 +311,8 @@ import java.util.concurrent.Semaphore while (i < size) { val item = event.episodes[i++] if (item.feedId != feed!!.id) continue -// adapter.notifyDataSetChanged() + val pos: Int = ieMap[item.id] ?: -1 + if (pos >= 0) episodes[pos].inQueueState.value = event.inQueue() break } } @@ -316,8 +320,9 @@ import java.util.concurrent.Semaphore private fun onPlayEvent(event: FlowEvent.PlayEvent) { Logd(TAG, "onPlayEvent ${event.episode.title}") if (feed != null) { - val pos: Int = EpisodeUtil.indexOfItemWithId(episodes, event.episode.id) + 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) } @@ -328,10 +333,12 @@ import java.util.concurrent.Semaphore // Logd(TAG, "onEpisodeDownloadEvent() called with ${event.TAG}") if (loadItemsRunning) return if (feed == null || episodes.isEmpty()) return - for (downloadUrl in event.urls) { - val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(episodes, downloadUrl) + for (url in event.urls) { +// if (!event.isCompleted(url)) continue + val pos: Int = ueMap[url] ?: -1 if (pos >= 0) { - Logd(TAG, "onEpisodeDownloadEvent $pos ${episodes[pos].title}") + Logd(TAG, "onEpisodeDownloadEvent $pos ${event.map[url]?.state} ${episodes[pos].media?.downloaded} ${episodes[pos].title}") + episodes[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal // adapter.notifyItemChangedCompat(pos) } } @@ -464,6 +471,8 @@ import java.util.concurrent.Semaphore 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 @@ -474,6 +483,8 @@ import java.util.concurrent.Semaphore val episodes_ = list ?: episodes.toList() episodes.clear() episodes.addAll(episodes_.filter { feed!!.episodeFilter.matches(it) }) + ieMap = episodes.withIndex().associate { (index, episode) -> episode.id to index } + ueMap = episodes.mapIndexedNotNull { index, episode -> episode.media?.downloadUrl?.let { it to index } }.toMap() } } @@ -496,6 +507,8 @@ import java.util.concurrent.Semaphore if (sortOrder != null) getPermutor(sortOrder).reorder(etmp) episodes.clear() 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() 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 a2e742ab..e5e52045 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 @@ -827,7 +827,6 @@ class OnlineFeedFragment : Fragment() { toolbar.inflateMenu(R.menu.episodes) toolbar.setTitle(R.string.episodes_label) updateToolbar() -// adapter.setOnSelectModeListener(null) return root } override fun onStart() { 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 99873d96..755215e5 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 @@ -2,8 +2,8 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.CheckboxDoNotShowAgainBinding -import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding import ac.mdiq.podcini.databinding.QueueFragmentBinding +import ac.mdiq.podcini.net.download.DownloadStatus import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybackSpeed import ac.mdiq.podcini.playback.service.PlaybackService @@ -13,7 +13,6 @@ import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.storage.database.Queues.clearQueue import ac.mdiq.podcini.storage.database.Queues.isQueueKeepSorted import ac.mdiq.podcini.storage.database.Queues.isQueueLocked -import ac.mdiq.podcini.storage.database.Queues.moveInQueue import ac.mdiq.podcini.storage.database.Queues.queueKeepSortedOrder import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope @@ -26,21 +25,18 @@ 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.handler.EpisodeMultiSelectHandler +import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.activity.MainActivity -import ac.mdiq.podcini.ui.adapter.EpisodesAdapter -import ac.mdiq.podcini.ui.adapter.SelectableAdapter 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.dialog.ConfirmationDialog import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog import ac.mdiq.podcini.ui.utils.EmptyViewHandler -import ac.mdiq.podcini.ui.utils.LiftOnScrollListener -import ac.mdiq.podcini.ui.view.EpisodeViewHolder -import ac.mdiq.podcini.ui.view.EpisodesRecyclerView -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.content.ComponentName import android.content.Context @@ -54,7 +50,10 @@ import android.widget.ArrayAdapter import android.widget.CheckBox import android.widget.Spinner import androidx.appcompat.widget.Toolbar -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.Card @@ -71,34 +70,35 @@ import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaBrowser import androidx.media3.session.SessionToken -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.appbar.MaterialToolbar 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 com.leinardi.android.speeddial.SpeedDialView -import kotlinx.coroutines.* +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import java.util.* import kotlin.math.max /** * Shows all items in the queue. */ -@UnstableApi class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener, SelectableAdapter.OnSelectModeListener { +@UnstableApi class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener { private var _binding: QueueFragmentBinding? = null private val binding get() = _binding!! - private lateinit var recyclerView: EpisodesRecyclerView private lateinit var emptyViewHandler: EmptyViewHandler private lateinit var toolbar: MaterialToolbar private lateinit var swipeActions: SwipeActions - private lateinit var speedDialView: SpeedDialView + + private var infoBarText = mutableStateOf("") + private var leftActionState = mutableStateOf(null) + private var rightActionState = mutableStateOf(null) private lateinit var spinnerLayout: View private lateinit var queueNames: Array @@ -108,13 +108,13 @@ import kotlin.math.max private lateinit var queues: List private var displayUpArrow = false - private var queueItems: MutableList = mutableListOf() - - private var adapter: QueueRecyclerAdapter? = null + private val queueItems = mutableStateListOf() private var showBin: Boolean = false private var addToQueueActionItem: SpeedDialActionItem? = null + private var dragDropEnabled: Boolean = !(isQueueKeepSorted || isQueueLocked) + private lateinit var browserFuture: ListenableFuture override fun onCreate(savedInstanceState: Bundle?) { @@ -129,11 +129,11 @@ import kotlin.math.max Logd(TAG, "fragment onCreateView") toolbar = binding.toolbar toolbar.setOnMenuItemClickListener(this) - toolbar.setOnLongClickListener { - recyclerView.scrollToPosition(5) - recyclerView.post { recyclerView.smoothScrollToPosition(0) } - false - } +// toolbar.setOnLongClickListener { +// recyclerView.scrollToPosition(5) +// recyclerView.post { recyclerView.smoothScrollToPosition(0) } +// false +// } displayUpArrow = parentFragmentManager.backStackEntryCount != 0 if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW) @@ -172,63 +172,42 @@ import kotlin.math.max (activity as MainActivity).setupToolbarToggle(toolbar, displayUpArrow) toolbar.inflateMenu(R.menu.queue) refreshMenuItems() - binding.progressBar.visibility = View.VISIBLE - recyclerView = binding.recyclerView - val animator: RecyclerView.ItemAnimator? = recyclerView.itemAnimator - if (animator != null && animator is SimpleItemAnimator) animator.supportsChangeAnimations = false +// recyclerView.setRecycledViewPool((activity as MainActivity).recycledViewPool) +// registerForContextMenu(recyclerView) +// recyclerView.addOnScrollListener(LiftOnScrollListener(binding.appbar)) - recyclerView.setRecycledViewPool((activity as MainActivity).recycledViewPool) - registerForContextMenu(recyclerView) - recyclerView.addOnScrollListener(LiftOnScrollListener(binding.appbar)) + swipeActions = SwipeActions(this, TAG) + swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.queued.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 = queueItems, + 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())}, ) + } + } - swipeActions = QueueSwipeActions() lifecycle.addObserver(swipeActions) - swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name)) - swipeActions.attachTo(recyclerView) refreshSwipeTelltale() - binding.leftActionIcon.setOnClickListener { swipeActions.showDialog() } - binding.rightActionIcon.setOnClickListener { swipeActions.showDialog() } - - adapter = QueueRecyclerAdapter() - adapter?.setOnSelectModeListener(this) - recyclerView.adapter = adapter emptyViewHandler = EmptyViewHandler(requireContext()) - emptyViewHandler.attachToRecyclerView(recyclerView) +// emptyViewHandler.attachToRecyclerView(recyclerView) emptyViewHandler.setIcon(R.drawable.ic_playlist_play) emptyViewHandler.setTitle(R.string.no_items_header_label) emptyViewHandler.setMessage(R.string.no_items_label) - emptyViewHandler.updateAdapter(adapter) - - val multiSelectDial = MultiSelectSpeedDialBinding.bind(binding.root) - speedDialView = multiSelectDial.fabSD - speedDialView.overlayLayout = multiSelectDial.fabSDOverlay - speedDialView.inflate(R.menu.episodes_apply_action_speeddial) - addToQueueActionItem = speedDialView.removeActionItemById(R.id.add_to_queue_batch) - speedDialView.setOnChangeListener(object : SpeedDialView.OnChangeListener { - override fun onMainActionSelected(): Boolean { - return false - } - override fun onToggleChanged(open: Boolean) { - if (open && adapter!!.selectedCount == 0) { - (activity as MainActivity).showSnackbarAbovePlayer(R.string.no_items_selected, Snackbar.LENGTH_SHORT) - speedDialView.close() - } - } - }) - speedDialView.setOnActionSelectedListener { actionItem: SpeedDialActionItem -> - EpisodeMultiSelectHandler((activity as MainActivity), actionItem.id).handleAction(adapter!!.selectedItems) - adapter?.endSelectMode() - true - } +// emptyViewHandler.updateAdapter(adapter) + return binding.root } override fun onStart() { Logd(TAG, "onStart() called") super.onStart() - adapter?.refreshFragPosCallback = ::refreshPosCallback loadCurQueue(true) procFlowEvents() val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), PlaybackService::class.java)) @@ -238,29 +217,21 @@ import kotlin.math.max mediaBrowser = browserFuture.get() mediaBrowser?.subscribe("CurQueue", null) }, MoreExecutors.directExecutor()) -// if (queueItems.isNotEmpty()) recyclerView.restoreScrollPosition(TAG) } override fun onStop() { Logd(TAG, "onStop() called") super.onStop() - adapter?.refreshFragPosCallback = null cancelFlowEvents() mediaBrowser?.unsubscribe("CurQueue") mediaBrowser = null MediaBrowser.releaseFuture(browserFuture) - val childCount = recyclerView.childCount - for (i in 0 until childCount) { - val child = recyclerView.getChildAt(i) - val viewHolder = recyclerView.getChildViewHolder(child) as? EpisodeViewHolder - viewHolder?.stopDBMonitor() - } } override fun onPause() { Logd(TAG, "onPause() called") super.onPause() - recyclerView.saveScrollPosition(TAG) +// recyclerView.saveScrollPosition(TAG) } private var eventSink: Job? = null @@ -307,31 +278,24 @@ import kotlin.math.max } } - private fun refreshPosCallback(pos: Int, episode: Episode) { -// Logd(TAG, "Queue refreshPosCallback: $pos ${episode.title}") - if (isAdded && activity != null) refreshInfoBar() - } +// private fun refreshPosCallback(pos: Int, episode: Episode) { +//// Logd(TAG, "Queue refreshPosCallback: $pos ${episode.title}") +// if (isAdded && activity != null) refreshInfoBar() +// } private fun onQueueEvent(event: FlowEvent.QueueEvent) { Logd(TAG, "onQueueEvent() called with ${event.action.name}") if (showBin) return - if (adapter == null) { - loadCurQueue(true) - return - } when (event.action) { FlowEvent.QueueEvent.Action.ADDED -> { if (event.episodes.isNotEmpty() && !curQueue.contains(event.episodes[0])) { - val pos = queueItems.size queueItems.addAll(event.episodes) - adapter?.notifyItemRangeInserted(pos, queueItems.size) - adapter?.notifyItemRangeChanged(pos, event.episodes.size) } } FlowEvent.QueueEvent.Action.SET_QUEUE, FlowEvent.QueueEvent.Action.SORTED -> { queueItems.clear() queueItems.addAll(event.episodes) - adapter?.updateItems(queueItems) +// adapter?.updateItems(queueItems) } FlowEvent.QueueEvent.Action.REMOVED, FlowEvent.QueueEvent.Action.IRREVERSIBLE_REMOVED -> { if (event.episodes.isNotEmpty()) { @@ -339,15 +303,8 @@ import kotlin.math.max val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, e.id) if (pos >= 0) { Logd(TAG, "removing episode $pos ${queueItems[pos].title} $e") -// val holder = recyclerView.findViewHolderForItemId(e.id) as? EpisodeViewHolder - val holder = recyclerView.findViewHolderForLayoutPosition(pos) as? EpisodeViewHolder - if (holder != null) { - holder.stopDBMonitor() -// holder?.unbind() - queueItems.removeAt(pos) - adapter?.notifyItemRemoved(pos) -// adapter?.notifyItemRangeChanged(pos, adapter!!.itemCount - pos) - } + queueItems[pos].stopMonitoring.value = true + queueItems.removeAt(pos) } else { Log.e(TAG, "Trying to remove item non-existent from queue ${e.id} ${e.title}") continue @@ -361,28 +318,28 @@ import kotlin.math.max } FlowEvent.QueueEvent.Action.CLEARED -> { queueItems.clear() - adapter?.updateItems(queueItems) } FlowEvent.QueueEvent.Action.MOVED, FlowEvent.QueueEvent.Action.DELETED_MEDIA -> return } - adapter?.updateDragDropEnabled() +// adapter?.updateDragDropEnabled() refreshMenuItems() - recyclerView.saveScrollPosition(TAG) +// recyclerView.saveScrollPosition(TAG) refreshInfoBar() } 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) adapter?.notifyItemChangedCompat(pos) + if (pos >= 0) queueItems[pos].isPlayingState.value = event.isPlaying() } private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { // Logd(TAG, "onEventMainThread() called with ${event.TAG}") if (loadItemsRunning) return - for (downloadUrl in event.urls) { - val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(queueItems.toList(), downloadUrl) - if (pos >= 0) adapter?.notifyItemChangedCompat(pos) + 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 } } @@ -410,16 +367,16 @@ import kotlin.math.max } private fun refreshSwipeTelltale() { - if (swipeActions.actions?.left != null) binding.leftActionIcon.setImageResource(swipeActions.actions!!.left!!.getActionIcon()) - if (swipeActions.actions?.right != null) binding.rightActionIcon.setImageResource(swipeActions.actions!!.right!!.getActionIcon()) + leftActionState.value = swipeActions.actions?.left + rightActionState.value = swipeActions.actions?.right } @SuppressLint("RestrictedApi") private fun onKeyUp(event: KeyEvent) { if (!isAdded || !isVisible || !isMenuVisible) return when (event.keyCode) { - KeyEvent.KEYCODE_T -> recyclerView.smoothScrollToPosition(0) - KeyEvent.KEYCODE_B -> recyclerView.smoothScrollToPosition(adapter!!.itemCount - 1) +// KeyEvent.KEYCODE_T -> recyclerView.smoothScrollToPosition(0) +// KeyEvent.KEYCODE_B -> recyclerView.smoothScrollToPosition(adapter!!.itemCount - 1) else -> {} } } @@ -427,10 +384,7 @@ import kotlin.math.max override fun onDestroyView() { Logd(TAG, "onDestroyView") _binding = null - queueItems = mutableListOf() - adapter?.endSelectMode() - adapter?.clearData() - adapter = null + queueItems.clear() toolbar.setOnMenuItemClickListener(null) toolbar.setOnLongClickListener(null) super.onDestroyView() @@ -468,10 +422,10 @@ import kotlin.math.max refreshMenuItems() if (showBin) { item.setIcon(R.drawable.playlist_play) - speedDialView.addActionItem(addToQueueActionItem) +// speedDialView.addActionItem(addToQueueActionItem) } else { item.setIcon(R.drawable.ic_history) - speedDialView.removeActionItem(addToQueueActionItem) +// speedDialView.removeActionItem(addToQueueActionItem) } loadCurQueue(false) } @@ -635,7 +589,7 @@ import kotlin.math.max @UnstableApi private fun setQueueLocked(locked: Boolean) { isQueueLocked = locked refreshMenuItems() - adapter?.updateDragDropEnabled() +// adapter?.updateDragDropEnabled() if (queueItems.size == 0) { if (locked) (activity as MainActivity).showSnackbarAbovePlayer(R.string.queue_locked, Snackbar.LENGTH_SHORT) @@ -664,7 +618,7 @@ import kotlin.math.max info += " • " info += DurationConverter.getDurationStringLocalized(requireActivity(), timeLeft) } - binding.infoBar.text = info + infoBarText.value = info // toolbar.title = "${getString(R.string.queue_label)}: ${curQueue.name}" } @@ -685,30 +639,13 @@ import kotlin.math.max } Logd(TAG, "loadCurQueue() curQueue.episodes: ${curQueue.episodes.size}") - binding.progressBar.visibility = View.GONE -// adapter?.setDummyViews(0) - adapter?.updateItems(queueItems) - if (restoreScrollPosition) recyclerView.restoreScrollPosition(TAG) +// if (restoreScrollPosition) recyclerView.restoreScrollPosition(TAG) refreshInfoBar() // playbackService?.notifyCurQueueItemsChanged() loadItemsRunning = false } } - override fun onStartSelectMode() { - swipeActions.detach() - speedDialView.visibility = View.VISIBLE - refreshMenuItems() - binding.infoBar.visibility = View.GONE - } - - override fun onEndSelectMode() { - speedDialView.close() - speedDialView.visibility = View.GONE - binding.infoBar.visibility = View.VISIBLE - swipeActions.attachTo(recyclerView) - } - class QueueSortDialog : EpisodeSortDialog() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { if (isQueueKeepSorted) sortOrder = queueKeepSortedOrder @@ -757,88 +694,6 @@ import kotlin.math.max } } - private inner class QueueSwipeActions : SwipeActions(ItemTouchHelper.UP or ItemTouchHelper.DOWN, this@QueuesFragment, TAG) { - // Position tracking whilst dragging - var dragFrom: Int = -1 - var dragTo: Int = -1 - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - val fromPosition = viewHolder.bindingAdapterPosition - val toPosition = target.bindingAdapterPosition - - // Update tracked position - if (dragFrom == -1) dragFrom = fromPosition - dragTo = toPosition - - val from = viewHolder.bindingAdapterPosition - val to = target.bindingAdapterPosition - Logd(TAG, "move($from, $to) in memory") - if (from >= queueItems.size || to >= queueItems.size || from < 0 || to < 0) return false - - queueItems.add(to, queueItems.removeAt(from)) - adapter?.notifyItemMoved(from, to) - return true - } - @UnstableApi override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - //SwipeActions - super.onSwiped(viewHolder, direction) - } - override fun isLongPressDragEnabled(): Boolean { - return false - } - @UnstableApi override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - super.clearView(recyclerView, viewHolder) - // Check if drag finished - if (dragFrom != -1 && dragTo != -1 && dragFrom != dragTo) reallyMoved(dragFrom, dragTo) - dragTo = -1 - dragFrom = dragTo - } - @UnstableApi private fun reallyMoved(from: Int, to: Int) { - Logd(TAG, "Write to database move($from, $to)") - moveInQueue(from, to, true) - } - } - - private inner class QueueRecyclerAdapter : EpisodesAdapter(activity as MainActivity) { - private var dragDropEnabled: Boolean - - init { - dragDropEnabled = !(isQueueKeepSorted || isQueueLocked) - } - fun updateDragDropEnabled() { - dragDropEnabled = !(isQueueKeepSorted || isQueueLocked) - notifyDataSetChanged() - } - @UnstableApi - override fun afterBindViewHolder(holder: EpisodeViewHolder, pos: Int) { - if (inActionMode() || !dragDropEnabled) { - holder.dragHandle.visibility = View.GONE - holder.dragHandle.setOnTouchListener(null) -// holder.coverHolder.setOnTouchListener(null) - } else { - holder.dragHandle.visibility = View.VISIBLE - holder.dragHandle.setOnTouchListener { _: View?, event: MotionEvent -> - if (event.actionMasked == MotionEvent.ACTION_DOWN) swipeActions.startDrag(holder) - false - } - holder.coverHolder.setOnTouchListener { v1, event -> - if (!inActionMode() && event.actionMasked == MotionEvent.ACTION_DOWN) { - val isLtr = holder.itemView.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR - val factor = (if (isLtr) 1 else -1).toFloat() - if (factor * event.x < factor * 0.5 * v1.width) swipeActions.startDrag(holder) - else Logd(TAG, "Ignoring drag in right half of the image") - } - false - } - } - if (inActionMode()) { - holder.dragHandle.setOnTouchListener(null) -// holder.coverHolder.setOnTouchListener(null) - } - holder.isInQueue.setVisibility(View.GONE) - } - } - companion object { val TAG = QueuesFragment::class.simpleName ?: "Anonymous" 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 df7f3f06..2c7115e1 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 @@ -3,25 +3,21 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.HorizontalFeedItemBinding -import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding import ac.mdiq.podcini.databinding.SearchFragmentBinding +import ac.mdiq.podcini.net.download.DownloadStatus import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher 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.EpisodeMultiSelectHandler -import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler import ac.mdiq.podcini.ui.actions.handler.MenuItemUtils import ac.mdiq.podcini.ui.activity.MainActivity -import ac.mdiq.podcini.ui.adapter.EpisodesAdapter -import ac.mdiq.podcini.ui.adapter.SelectableAdapter +import ac.mdiq.podcini.ui.compose.CustomTheme +import ac.mdiq.podcini.ui.compose.EpisodeLazyColumn 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.utils.LiftOnScrollListener -import ac.mdiq.podcini.ui.view.EpisodesRecyclerView import ac.mdiq.podcini.ui.view.SquareImageView import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.EventFlow @@ -37,10 +33,10 @@ import android.util.Pair import android.view.* import android.view.inputmethod.InputMethodManager import android.widget.Button -import android.widget.ProgressBar import androidx.annotation.StringRes import androidx.appcompat.widget.SearchView import androidx.cardview.widget.CardView +import androidx.compose.runtime.mutableStateListOf import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi @@ -51,9 +47,6 @@ import coil.load import coil.request.ImageRequest import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.chip.Chip -import com.google.android.material.snackbar.Snackbar -import com.leinardi.android.speeddial.SpeedDialActionItem -import com.leinardi.android.speeddial.SpeedDialView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest @@ -65,21 +58,17 @@ import java.lang.ref.WeakReference * Performs a search operation on all feeds or one specific feed and displays the search result. */ @UnstableApi -class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { +class SearchFragment : Fragment() { private var _binding: SearchFragmentBinding? = null private val binding get() = _binding!! - private lateinit var adapter: EpisodesAdapter private lateinit var adapterFeeds: HorizontalFeedListAdapter - private lateinit var progressBar: ProgressBar private lateinit var emptyViewHandler: EmptyViewHandler - private lateinit var recyclerView: EpisodesRecyclerView private lateinit var searchView: SearchView - private lateinit var sdBinding: MultiSelectSpeedDialBinding private lateinit var chip: Chip private lateinit var automaticSearchDebouncer: Handler - private var results: MutableList = mutableListOf() + private val results = mutableStateListOf() private var lastQueryChange: Long = 0 private var isOtherViewInFoucus = false @@ -92,18 +81,16 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = SearchFragmentBinding.inflate(inflater) - Logd(TAG, "fragment onCreateView") setupToolbar(binding.toolbar) - sdBinding = MultiSelectSpeedDialBinding.bind(binding.root) - progressBar = binding.progressBar - recyclerView = binding.recyclerView - recyclerView.setRecycledViewPool((activity as MainActivity).recycledViewPool) - registerForContextMenu(recyclerView) - adapter = object : EpisodesAdapter(activity as MainActivity) {} - adapter.setOnSelectModeListener(this) - recyclerView.adapter = adapter - recyclerView.addOnScrollListener(LiftOnScrollListener(binding.appbar)) + + binding.lazyColumn.setContent { + CustomTheme(requireContext()) { + EpisodeLazyColumn(activity as MainActivity, episodes = results, + leftSwipeCB = {}, + rightSwipeCB = { }) + } + } val recyclerViewFeeds = binding.recyclerViewFeeds val layoutManagerFeeds = LinearLayoutManager(activity) @@ -118,7 +105,7 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { recyclerViewFeeds.adapter = adapterFeeds emptyViewHandler = EmptyViewHandler(requireContext()) - emptyViewHandler.attachToRecyclerView(recyclerView) +// emptyViewHandler.attachToRecyclerView(recyclerView) emptyViewHandler.setIcon(R.drawable.ic_search) emptyViewHandler.setTitle(R.string.search_status_no_results) emptyViewHandler.setMessage(R.string.type_to_search) @@ -135,35 +122,6 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { searchView.setOnQueryTextFocusChangeListener { view: View, hasFocus: Boolean -> if (hasFocus && !isOtherViewInFoucus) showInputMethod(view.findFocus()) } - recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - super.onScrollStateChanged(recyclerView, newState) - if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { - val imm = activity!!.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(recyclerView.windowToken, 0) - } - } - }) - sdBinding.fabSD.overlayLayout = sdBinding.fabSDOverlay - sdBinding.fabSD.inflate(R.menu.episodes_apply_action_speeddial) - sdBinding.fabSD.setOnChangeListener(object : SpeedDialView.OnChangeListener { - override fun onMainActionSelected(): Boolean { - return false - } - override fun onToggleChanged(open: Boolean) { - if (open && adapter.selectedCount == 0) { - (activity as MainActivity).showSnackbarAbovePlayer(R.string.no_items_selected, Snackbar.LENGTH_SHORT) - sdBinding.fabSD.close() - } - } - }) - sdBinding.fabSD.setOnActionSelectedListener { actionItem: SpeedDialActionItem -> - EpisodeMultiSelectHandler(activity as MainActivity, actionItem.id) - .handleAction(adapter.selectedItems) - adapter.endSelectMode() - true - } - return binding.root } @@ -180,8 +138,7 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { override fun onDestroyView() { Logd(TAG, "onDestroyView") _binding = null - adapter.clearData() - results = mutableListOf() + results.clear() super.onDestroyView() } @@ -204,12 +161,10 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { } @UnstableApi override fun onQueryTextChange(s: String): Boolean { automaticSearchDebouncer.removeCallbacksAndMessages(null) - if (s.isEmpty() || s.endsWith(" ") || (lastQueryChange != 0L && System.currentTimeMillis() > lastQueryChange + SEARCH_DEBOUNCE_INTERVAL)) { + if (s.isEmpty() || s.endsWith(" ") || (lastQueryChange != 0L && System.currentTimeMillis() > lastQueryChange + SEARCH_DEBOUNCE_INTERVAL)) search() - } else { - // Don't search instantly with first symbol after some pause - automaticSearchDebouncer.postDelayed({ search(); lastQueryChange = 0 }, (SEARCH_DEBOUNCE_INTERVAL / 2).toLong()) - } + // Don't search instantly with first symbol after some pause + else automaticSearchDebouncer.postDelayed({ search(); lastQueryChange = 0 }, (SEARCH_DEBOUNCE_INTERVAL / 2).toLong()) lastQueryChange = System.currentTimeMillis() return false } @@ -228,12 +183,6 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { override fun onContextItemSelected(item: MenuItem): Boolean { val selectedFeedItem: Feed? = adapterFeeds.longPressedItem if (selectedFeedItem != null && onMenuItemClicked(this, item.itemId, selectedFeedItem) {}) return true - - val selectedItem: Episode? = adapter.longPressedItem - if (selectedItem != null) { -// if (adapter.onContextItemSelected(item)) return true - if (EpisodeMenuHandler.onMenuItemClicked(this, item.itemId, selectedItem)) return true - } return super.onContextItemSelected(item) } @@ -280,14 +229,14 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { } private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { - for (downloadUrl in event.urls) { - val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(results, downloadUrl) - if (pos >= 0) adapter.notifyItemChangedCompat(pos) + 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 + } } @UnstableApi private fun searchWithProgressBar() { - progressBar.visibility = View.VISIBLE emptyViewHandler.hide() search() } @@ -299,23 +248,20 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { lifecycleScope.launch { try { - val results = withContext(Dispatchers.IO) { performSearch() } + val results_ = withContext(Dispatchers.IO) { performSearch() } withContext(Dispatchers.Main) { - progressBar.visibility = View.GONE - if (results.first != null) { - val first_ = results.first!!.toMutableList() - this@SearchFragment.results = first_ - adapter.updateItems(first_) + if (results_.first != null) { + val first_ = results_.first!!.toMutableList() + results.clear() + results.addAll(first_) } if (requireArguments().getLong(ARG_FEED, 0) == 0L) { - if (results.second != null) adapterFeeds.updateData(results.second!!) + if (results_.second != null) adapterFeeds.updateData(results_.second!!) } else adapterFeeds.updateData(emptyList()) if (searchView.query.toString().isEmpty()) emptyViewHandler.setMessage(R.string.type_to_search) else emptyViewHandler.setMessage(getString(R.string.no_results_for_query, searchView.query)) } - } catch (e: Throwable) { - Log.e(TAG, Log.getStackTraceString(e)) - } + } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) } } } @@ -402,29 +348,6 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { (activity as MainActivity).loadChildFragment(SearchResultsFragment.newInstance(CombinedSearcher::class.java, query)) } - override fun onStartSelectMode() { - searchViewFocusOff() - sdBinding.fabSD.removeActionItemById(R.id.remove_from_queue_batch) - sdBinding.fabSD.removeActionItemById(R.id.delete_batch) - sdBinding.fabSD.visibility = View.VISIBLE - } - - override fun onEndSelectMode() { - sdBinding.fabSD.close() - sdBinding.fabSD.visibility = View.GONE - searchViewFocusOn() - } - - private fun searchViewFocusOff() { - isOtherViewInFoucus = true - searchView.clearFocus() - } - - private fun searchViewFocusOn() { - isOtherViewInFoucus = false - searchView.requestFocus() - } - open class HorizontalFeedListAdapter(mainActivity: MainActivity) : RecyclerView.Adapter(), View.OnCreateContextMenuListener { @@ -436,9 +359,6 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { private var endButtonText = 0 private var endButtonAction: Runnable? = null -// fun setDummyViews(dummyViews: Int) { -// this.dummyViews = dummyViews -// } fun updateData(newData: List?) { data.clear() data.addAll(newData!!) @@ -533,18 +453,12 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { return fragment } - /** - * Create a new SearchFragment that searches all feeds with pre-defined query. - */ fun newInstance(query: String?): SearchFragment { val fragment = newInstance() fragment.requireArguments().putString(ARG_QUERY, query) return fragment } - /** - * Create a new SearchFragment that searches one specific feed. - */ fun newInstance(feedId: Long, feedTitle: String?): SearchFragment { val fragment = newInstance() fragment.requireArguments().putLong(ARG_FEED, feedId) 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 59c1e439..28ea84c8 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 @@ -15,6 +15,7 @@ import ac.mdiq.podcini.storage.database.RealmDB.upsert import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.FeedAutoDeleteOptions +import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.adapter.SelectableAdapter import ac.mdiq.podcini.ui.compose.CustomTheme @@ -48,6 +49,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.Toolbar +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape @@ -57,8 +60,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.core.util.Consumer @@ -67,6 +72,7 @@ import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import coil.compose.AsyncImage import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog @@ -208,7 +214,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec // } // binding.progressBar.visibility = View.VISIBLE - binding.progressBar.visibility = View.GONE +// binding.progressBar.visibility = View.GONE val subscriptionAddButton: FloatingActionButton = binding.subscriptionsAdd subscriptionAddButton.setOnClickListener { @@ -571,6 +577,36 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec adapter.setItems(feedsOnly) } + @Composable + fun FeedExpanded(feed: Feed) { + Row { + val imgLoc = "" + AsyncImage(model = imgLoc, contentDescription = "imgvCover", + placeholder = painterResource(R.mipmap.ic_launcher), + modifier = Modifier.width(80.dp).height(80.dp) + .clickable(onClick = { + Logd(TAG, "icon clicked!") +// if (selectMode) toggleSelected() +// else activity.loadChildFragment(FeedInfoFragment.newInstance(episode.feed!!)) + })) + val textColor = MaterialTheme.colors.onSurface + Column(Modifier.fillMaxWidth()) { + Text("titleLabel", color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("producerLabel", color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row { + Text("episodeCount", color = textColor, ) + Spacer(modifier = Modifier.weight(1f)) + Text("sortInfo", color = textColor, ) + } + } + Icon(painter = painterResource(R.drawable.ic_error), contentDescription = "error") + } + } + + @Composable + fun FeedCompact(feed: Feed) { + + } @UnstableApi private inner class FeedMultiSelectActionHandler(private val activity: MainActivity, private val selectedItems: List) { fun handleAction(id: Int) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt deleted file mode 100644 index 10f9fb97..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodeViewHolder.kt +++ /dev/null @@ -1,327 +0,0 @@ -package ac.mdiq.podcini.ui.view - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.FeeditemlistItemBinding -import ac.mdiq.podcini.net.download.service.DownloadServiceInterface -import ac.mdiq.podcini.playback.base.InTheatre -import ac.mdiq.podcini.playback.base.InTheatre.curQueue -import ac.mdiq.podcini.preferences.UserPreferences -import ac.mdiq.podcini.storage.database.RealmDB.realm -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.Episode.PlayState -import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.storage.model.Feed.Companion.PREFIX_GENERATIVE_COVER -import ac.mdiq.podcini.storage.model.MediaType -import ac.mdiq.podcini.storage.model.Playable -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.actionbutton.TTSActionButton -import ac.mdiq.podcini.ui.activity.MainActivity -import ac.mdiq.podcini.ui.utils.CoverLoader -import ac.mdiq.podcini.ui.utils.ThemeUtils -import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev -import ac.mdiq.podcini.util.MiscFormatter.formatForAccessibility -import android.text.Layout -import android.text.format.Formatter -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.ProgressBar -import android.widget.TextView -import androidx.appcompat.content.res.AppCompatResources -import androidx.cardview.widget.CardView -import androidx.media3.common.util.UnstableApi -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.elevation.SurfaceColors -import io.realm.kotlin.notifications.SingleQueryChange -import io.realm.kotlin.notifications.UpdatedObject -import kotlinx.coroutines.* -import kotlin.math.max - -/** - * Holds the view which shows FeedItems. - */ -@UnstableApi -open class EpisodeViewHolder(private val activity: MainActivity, parent: ViewGroup, var refreshAdapterPosCallback: ((Int, Episode) -> Unit)? = null) - : RecyclerView.ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.feeditemlist_item, parent, false)) { - - val binding: FeeditemlistItemBinding = FeeditemlistItemBinding.bind(itemView) - - private val placeholder: TextView = binding.txtvPlaceholder - private val cover: ImageView = binding.imgvCover - private val title: TextView = binding.txtvTitle - private val position: TextView = binding.txtvPosition - private val duration: TextView = binding.txtvDuration - private val isVideo: ImageView = binding.ivIsVideo - private val progressBar: ProgressBar = binding.progressBar - - private var posIndex: Int = -1 - - private var actionButton: EpisodeActionButton? = null - private val secondaryActionProgress: CircularProgressBar = binding.secondaryActionButton.secondaryActionProgress - - protected val pubDate: TextView = binding.txtvPubDate - - @JvmField - val dragHandle: ImageView = binding.dragHandle - - @JvmField - val isInQueue: ImageView = binding.ivInPlaylist - - @JvmField - val secondaryActionButton: View = binding.secondaryActionButton.root - @JvmField - val secondaryActionIcon: ImageView = binding.secondaryActionButton.secondaryActionIcon - - @JvmField - val coverHolder: CardView = binding.coverHolder - @JvmField - val infoCard: LinearLayout = binding.infoCard - - var episode: Episode? = null - - private var episodeMonitor: Job? = null - private var mediaMonitor: Job? = null - private var notBond: Boolean = true - - private val isCurMedia: Boolean - get() = InTheatre.isCurMedia(this.episode?.media) - - init { - title.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL) - itemView.tag = this - } - - fun bind(item: Episode) { - if (episodeMonitor == null) { - val item_ = realm.query(Episode::class).query("id == ${item.id}").first() - episodeMonitor = CoroutineScope(Dispatchers.Default).launch { - val episodeFlow = item_.asFlow() - episodeFlow.collect { changes: SingleQueryChange -> - when (changes) { - is UpdatedObject -> { - Logd(TAG, "episodeMonitor UpdatedObject ${changes.obj.title} ${changes.changedFields.joinToString()}") - withContext(Dispatchers.Main) { - bind(changes.obj) - if (posIndex >= 0) refreshAdapterPosCallback?.invoke(posIndex, changes.obj) - } - } - else -> {} - } - } -// return - } - } - if (mediaMonitor == null) { - val item_ = realm.query(Episode::class).query("id == ${item.id}").first() - mediaMonitor = CoroutineScope(Dispatchers.Default).launch { - val episodeFlow = item_.asFlow(listOf("media.*")) - episodeFlow.collect { changes: SingleQueryChange -> - when (changes) { - is UpdatedObject -> { - Logd(TAG, "mediaMonitor UpdatedObject ${changes.obj.title} ${changes.changedFields.joinToString()}") - withContext(Dispatchers.Main) { - updatePlaybackPositionNew(changes.obj) -// bind(changes.obj) - if (posIndex >= 0) refreshAdapterPosCallback?.invoke(posIndex, changes.obj) - } - } - else -> {} - } - } - } - } - this.episode = item - placeholder.text = item.feed?.title - title.text = item.title - binding.container.alpha = if (item.isPlayed()) 0.7f else 1.0f - binding.leftPadding.contentDescription = item.title - binding.playedMark.visibility = View.GONE - binding.txtvPubDate.setTextColor(getColorFromAttr(activity, com.google.android.material.R.attr.colorOnSurfaceVariant)) - when { - item.isPlayed() -> { - binding.leftPadding.contentDescription = item.title + ". " + activity.getString(R.string.is_played) - binding.playedMark.visibility = View.VISIBLE - binding.playedMark.alpha = 1.0f - } - item.isNew -> { - binding.txtvPubDate.setTextColor(getColorFromAttr(activity, androidx.appcompat.R.attr.colorAccent)) - } - } - - setPubDate(item) - - binding.isFavorite.visibility = if (item.isFavorite) View.VISIBLE else View.GONE - isInQueue.visibility = if (curQueue.contains(item)) View.VISIBLE else View.GONE -// container.alpha = if (item.isPlayed()) 0.7f else 1.0f - - val newButton = EpisodeActionButton.forItem(item) -// Logd(TAG, "Trying to bind button ${actionButton?.TAG} ${newButton.TAG} ${item.title}") - // not using a new button to ensure valid progress values, for TTS audio generation - if (!(actionButton?.TAG == TTSActionButton::class.simpleName && newButton.TAG == TTSActionButton::class.simpleName)) { - actionButton = newButton - actionButton?.configure(secondaryActionButton, secondaryActionIcon, activity) - secondaryActionButton.isFocusable = false - } - -// Log.d(TAG, "bind called ${item.media}") - when { - item.media != null -> bind(item.media!!) - // for generating TTS files for episode without media - item.playState == PlayState.BUILDING.code -> { - secondaryActionProgress.setPercentage(actionButton!!.processing, item) - secondaryActionProgress.setIndeterminate(false) - } - else -> { - secondaryActionProgress.setPercentage(0f, item) - secondaryActionProgress.setIndeterminate(false) - isVideo.visibility = View.GONE - progressBar.visibility = View.GONE - duration.visibility = View.GONE - position.visibility = View.GONE - itemView.setBackgroundResource(ThemeUtils.getDrawableFromAttr(activity, androidx.appcompat.R.attr.selectableItemBackground)) - } - } - - if (notBond && coverHolder.visibility == View.VISIBLE) { - cover.setImageDrawable(null) - val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(item) -// Logd(TAG, "imgLoc $imgLoc ${item.feed?.imageUrl} ${item.title}") - if (!imgLoc.isNullOrBlank() && !imgLoc.contains(PREFIX_GENERATIVE_COVER)) - CoverLoader(activity) - .withUri(imgLoc) - .withFallbackUri(item.feed?.imageUrl) - .withPlaceholderView(placeholder) - .withCoverView(cover) - .load() - else { - Logd(TAG, "setting cover to ic_launcher") - cover.setImageDrawable(AppCompatResources.getDrawable(activity, R.drawable.ic_launcher_foreground)) - } -// if (item.isNew) cover.setColorFilter(ContextCompat.getColor(activity, R.color.gradient_100), PorterDuff.Mode.MULTIPLY) - } - notBond = false - } - - internal fun setPosIndex(index: Int) { - posIndex = index - } - - fun unbind() { - Logd(TAG, "unbind ${title.text}") - // Cancel coroutine here - itemView.setOnClickListener(null) - itemView.setOnCreateContextMenuListener(null) - itemView.setOnLongClickListener(null) - itemView.setOnTouchListener(null) - secondaryActionButton.setOnClickListener(null) - dragHandle.setOnTouchListener(null) - coverHolder.setOnTouchListener(null) - posIndex = -1 - episode = null - notBond = true - stopDBMonitor() - } - - fun stopDBMonitor() { - episodeMonitor?.cancel() - episodeMonitor = null - mediaMonitor?.cancel() - mediaMonitor = null - } - - open fun setPubDate(item: Episode) { - pubDate.text = formatAbbrev(activity, item.getPubDate()) - pubDate.setContentDescription(formatForAccessibility(item.getPubDate())) - } - - private fun bind(media: EpisodeMedia) { - isVideo.visibility = if (media.getMediaType() == MediaType.VIDEO) View.VISIBLE else View.GONE - duration.visibility = if (media.getDuration() > 0) View.VISIBLE else View.GONE - - if (isCurMedia) { - val density: Float = activity.resources.displayMetrics.density - itemView.setBackgroundColor(SurfaceColors.getColorForElevation(activity, 8 * density)) - } else itemView.setBackgroundResource(ThemeUtils.getDrawableFromAttr(activity, androidx.appcompat.R.attr.selectableItemBackground)) - - val dls = DownloadServiceInterface.get() - when { - media.downloadUrl != null && dls?.isDownloadingEpisode(media.downloadUrl!!) == true -> { - val percent: Float = 0.01f * dls.getProgress(media.downloadUrl!!) - secondaryActionProgress.setPercentage(max(percent, 0.01f), this.episode) - secondaryActionProgress.setIndeterminate(dls.isEpisodeQueued(media.downloadUrl!!)) - } - media.downloaded -> { - secondaryActionProgress.setPercentage(1f, this.episode) // Do not animate 100% -> 0% - secondaryActionProgress.setIndeterminate(false) - } - else -> { - secondaryActionProgress.setPercentage(0f, this.episode) // Animate X% -> 0% - secondaryActionProgress.setIndeterminate(false) - } - } - - duration.text = DurationConverter.getDurationStringLong(media.getDuration()) - duration.setContentDescription(activity.getString(R.string.chapter_duration, - DurationConverter.getDurationStringLocalized(activity, media.getDuration().toLong()))) - if (isCurMedia || this.episode?.isInProgress == true) { - val progress: Int = (100.0 * media.getPosition() / media.getDuration()).toInt() - val remainingTime = max((media.getDuration() - media.getPosition()).toDouble(), 0.0).toInt() - progressBar.progress = progress - position.text = DurationConverter.getDurationStringLong(media.getPosition()) - position.setContentDescription(activity.getString(R.string.position, - DurationConverter.getDurationStringLocalized(activity, media.getPosition().toLong()))) - progressBar.visibility = View.VISIBLE - position.visibility = View.VISIBLE - if (UserPreferences.shouldShowRemainingTime()) { - duration.text = (if ((remainingTime > 0)) "-" else "") + DurationConverter.getDurationStringLong(remainingTime) - duration.setContentDescription(activity.getString(R.string.chapter_duration, - DurationConverter.getDurationStringLocalized(activity, (media.getDuration() - media.getPosition()).toLong()))) - } - } else { - progressBar.visibility = View.GONE - position.visibility = View.GONE - } - - when { - media.size > 0 -> binding.size.text = Formatter.formatShortFileSize(activity, media.size) - else -> binding.size.text = "" - } - } - - fun updatePlaybackPositionNew(item: Episode) { - Logd(TAG, "updatePlaybackPositionNew called") - this.episode = item - val currentPosition = item.media?.position ?: 0 - val timeDuration = item.media?.duration ?: 0 - progressBar.progress = (100.0 * currentPosition / timeDuration).toInt() - position.text = DurationConverter.getDurationStringLong(currentPosition) - - val remainingTime = max((timeDuration - currentPosition).toDouble(), 0.0).toInt() - if (currentPosition == Playable.INVALID_TIME || timeDuration == Playable.INVALID_TIME) { - Log.w(TAG, "Could not react to position observer update because of invalid time") - return - } - if (UserPreferences.shouldShowRemainingTime()) duration.text = (if (remainingTime > 0) "-" else "") + DurationConverter.getDurationStringLong(remainingTime) - else duration.text = DurationConverter.getDurationStringLong(timeDuration) - duration.visibility = View.VISIBLE // Even if the duration was previously unknown, it is now known - } - - /** - * Hides the separator dot between icons and text if there are no icons. - */ - fun hideSeparatorIfNecessary() { - val hasIcons = isInQueue.visibility == View.VISIBLE || isVideo.visibility == View.VISIBLE || binding.isFavorite.visibility == View.VISIBLE - binding.separatorIcons.visibility = if (hasIcons) View.VISIBLE else View.GONE - } - - companion object { - private val TAG: String = EpisodeViewHolder::class.simpleName ?: "Anonymous" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt index 4a910554..679fccac 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt @@ -29,6 +29,9 @@ sealed class FlowEvent { data class PlayEvent(val episode: Episode, val action: Action = Action.START) : FlowEvent() { enum class Action { START, END, } + fun isPlaying(): Boolean { + return action == Action.START + } } data class PlayerErrorEvent(val message: String) : FlowEvent() @@ -63,6 +66,13 @@ sealed class FlowEvent { enum class Action { ADDED, SET_QUEUE, REMOVED, IRREVERSIBLE_REMOVED, CLEARED, DELETED_MEDIA, SORTED, MOVED, SWITCH_QUEUE } + fun inQueue(): Boolean { + return when (action) { + Action.ADDED, Action.SET_QUEUE, Action.SORTED, Action.MOVED, Action.SWITCH_QUEUE -> true + else -> false + } + + } companion object { fun added(episode: Episode, position: Int): QueueEvent { return QueueEvent(Action.ADDED, listOf(episode), position) @@ -149,6 +159,10 @@ sealed class FlowEvent { data class EpisodeDownloadEvent(val map: Map) : FlowEvent() { val urls: Set get() = map.keys + fun isCompleted(url: String): Boolean { + val stat = map[url] + return stat?.state == DownloadStatus.State.COMPLETED.ordinal + } } // TODO: need better handling at receving end diff --git a/app/src/main/res/layout/audioplayer_fragment.xml b/app/src/main/res/layout/audioplayer_fragment.xml index 7e267c31..29ccc747 100644 --- a/app/src/main/res/layout/audioplayer_fragment.xml +++ b/app/src/main/res/layout/audioplayer_fragment.xml @@ -1,88 +1,47 @@ - + android:layout_height="match_parent" + android:orientation="vertical"> - + android:outlineProvider="none"/> - + android:fitsSystemWindows="true" + android:orientation="vertical"> - + android:layout_weight="1" + android:layout_marginBottom="12dp"/> - + android:layout_height="@dimen/external_player_height"/> - - - - - - - - + - + diff --git a/app/src/main/res/layout/episode_info_fragment.xml b/app/src/main/res/layout/episode_info_fragment.xml index 5cb73bfc..179afb08 100644 --- a/app/src/main/res/layout/episode_info_fragment.xml +++ b/app/src/main/res/layout/episode_info_fragment.xml @@ -3,6 +3,7 @@ - + android:layout_height="wrap_content"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/feeditemlist_header.xml b/app/src/main/res/layout/feeditemlist_header.xml deleted file mode 100644 index 4a579892..00000000 --- a/app/src/main/res/layout/feeditemlist_header.xml +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_subscriptions.xml b/app/src/main/res/layout/fragment_subscriptions.xml index 36ed2349..9adc363a 100644 --- a/app/src/main/res/layout/fragment_subscriptions.xml +++ b/app/src/main/res/layout/fragment_subscriptions.xml @@ -136,13 +136,13 @@ - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/player_ui_fragment.xml b/app/src/main/res/layout/player_ui_fragment.xml deleted file mode 100644 index 5a279ce1..00000000 --- a/app/src/main/res/layout/player_ui_fragment.xml +++ /dev/null @@ -1,232 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/queue_fragment.xml b/app/src/main/res/layout/queue_fragment.xml index e1c69b76..e9b4a7ff 100644 --- a/app/src/main/res/layout/queue_fragment.xml +++ b/app/src/main/res/layout/queue_fragment.xml @@ -1,18 +1,18 @@ - + android:fitsSystemWindows="true"> - + android:layout_height="wrap_content"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content"/> - + diff --git a/app/src/main/res/layout/search_fragment.xml b/app/src/main/res/layout/search_fragment.xml index 1ab92bdb..48e719d1 100644 --- a/app/src/main/res/layout/search_fragment.xml +++ b/app/src/main/res/layout/search_fragment.xml @@ -1,9 +1,10 @@ - - - - - - + android:layout_height="wrap_content"/> - + diff --git a/changelog.md b/changelog.md index 5942feb8..e6f32e67 100644 --- a/changelog.md +++ b/changelog.md @@ -1,7 +1,24 @@ +# 6.8.2 (Preview release) + +* AudioPlayerFragment got overhauled. migrated to Jetpack Compose and PlayUI and PlayerDetailed fragments are Removed +* EpisodeInfo is now in Compose +* SearchFragment shows episodes list in Compose +* Episodes viewholder and adapter etc are removed +* SwipeActions class stripped out of View related operations +* more enhancements in Compose functionalities +* still have known issues + +# 6.8.1 (Preview release) + +* made Queues view in Jetpack Compose +* enhanced various Compose functionalities +* not yet ready for serious usage + # 6.8.0 (Preview release) * the Compose class of DownloadsC replaces the old Downloads view * FeedEpisodes, AllEpisodes, History, and OnlineFeed mostly migrated to Jetpack Compose +* there are still known issues and missing functions # 6.7.3 diff --git a/fastlane/metadata/android/en-US/changelogs/3020258.txt b/fastlane/metadata/android/en-US/changelogs/3020258.txt new file mode 100644 index 00000000..00a12317 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020258.txt @@ -0,0 +1,5 @@ + Version 6.8.1 (preview release): + +* made Queues view in Jetpack Compose +* enhanced various Compose functionalities +* not yet ready for serious usage diff --git a/fastlane/metadata/android/en-US/changelogs/3020259.txt b/fastlane/metadata/android/en-US/changelogs/3020259.txt new file mode 100644 index 00000000..a47bf393 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020259.txt @@ -0,0 +1,9 @@ + Version 6.8.2 (preview release): + +* AudioPlayerFragment got overhauled. migrated to Jetpack Compose and PlayUI and PlayerDetailed fragments are Removed +* EpisodeInfo is now in Compose +* SearchFragment shows episodes list in Compose +* Episodes viewholder and adapter etc are removed +* SwipeActions class stripped out of View related operations +* more enhancements in Compose functionalities +* still have known issues