6.8.3 commit

This commit is contained in:
Xilin Jia 2024-09-29 22:29:20 +01:00
parent 16841905dd
commit 89143c60d3
45 changed files with 2181 additions and 3839 deletions

View File

@ -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 = ""

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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)) }
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -22,5 +22,6 @@ class DeleteActionButton(item: Episode) : EpisodeActionButton(item) {
}
@UnstableApi override fun onClick(context: Context) {
deleteEpisodesWarnLocal(context, listOf(item))
actionState.value = getLabel()
}
}

View File

@ -43,6 +43,7 @@ class DownloadActionButton(item: Episode) : EpisodeActionButton(item) {
builder.show()
}
actionState.value = getLabel()
}
private fun shouldNotDownload(media: EpisodeMedia?): Boolean {

View File

@ -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) {

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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()
}
/**

View File

@ -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()
}
}

View File

@ -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) {

View File

@ -157,6 +157,7 @@ class TTSActionButton(item: Episode) : EpisodeActionButton(item) {
item.setPlayed(false)
processing = 1f
EventFlow.postEvent(FlowEvent.EpisodeEvent.updated(item))
actionState.value = getLabel()
}
}
}

View File

@ -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()
}
}

View File

@ -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 {

View File

@ -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<FragmentContainerView>(R.id.playerFragment1)
// val playerView = findViewById<FragmentContainerView>(R.id.playerFragment1)
val playerView = findViewById<ComposeView>(R.id.composeView1)
val playerParams = playerView?.layoutParams as? MarginLayoutParams
playerParams?.setMargins(navigationBarInsets.left, 0, navigationBarInsets.right, 0)
playerView?.layoutParams = playerParams

View File

@ -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<String>, leftAction: MutableState<SwipeAction?>,
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<Episode>, modifier: Modifier = Modifier) {
val TAG = "EpisodeSpeedDial"
fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList<Episode>, 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<Episode>, modifier:
fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episode>, 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<Long>()) }
val selected = remember { mutableListOf<Episode>()}
// val selectedIds = remember { mutableSetOf<Long>() }
var selectedSize by remember { mutableStateOf(0) }
val selected = remember { mutableStateListOf<Episode>() }
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<Episod
when (changes) {
is UpdatedObject -> {
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<Episod
Logd(TAG, "mediaMonitor UpdatedObject ${changes.obj.title} ${changes.changedFields.joinToString()}")
positionState = changes.obj.media?.position?:0
inProgressState = changes.obj.isInProgress
episodes[index] = changes.obj
// 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 -> {}
}
@ -230,6 +256,10 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
mediaMonitor?.cancel()
}
}
if (episodes[index].stopMonitoring.value) {
episodeMonitor?.cancel()
mediaMonitor?.cancel()
}
val velocityTracker = remember { VelocityTracker() }
val offsetX = remember { Animatable(0f) }
Box(
@ -260,14 +290,17 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
) {
var isSelected by remember { mutableStateOf(false) }
isSelected = selectMode && episode.id in selectedIds
LaunchedEffect(key1 = selectMode, key2 = selectedSize) {
isSelected = selectMode && episode in selected
// Logd(TAG, "LaunchedEffect $index $isSelected ${selected.size}")
}
fun toggleSelected() {
isSelected = !isSelected
if (isSelected) {
selectedIds.add(episode.id)
// selectedIds.add(episode.id)
selected.add(episodes[index])
} else {
selectedIds.remove(episode.id)
// selectedIds.remove(episode.id)
selected.remove(episodes[index])
}
}
@ -276,7 +309,7 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
if (false) {
val typedValue = TypedValue()
LocalContext.current.theme.resolveAttribute(R.attr.dragview_background, typedValue, true)
Image(painter = painterResource(typedValue.resourceId),
Icon(painter = painterResource(typedValue.resourceId), tint = textColor,
contentDescription = "drag handle",
modifier = Modifier.width(16.dp).align(Alignment.CenterVertically))
}
@ -284,7 +317,8 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
val (imgvCover, checkMark) = createRefs()
val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(episode)
AsyncImage(model = imgLoc, contentDescription = "imgvCover",
Modifier.width(56.dp).height(56.dp)
placeholder = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(56.dp).height(56.dp)
.constrainAs(imgvCover) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
@ -295,8 +329,8 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
else activity.loadChildFragment(FeedInfoFragment.newInstance(episode.feed!!))
}))
val alpha = if (playedState) 1.0f else 0f
if (playedState) Image(painter = painterResource(R.drawable.ic_check), contentDescription = "played_mark",
Modifier.background(Color.Green).alpha(alpha).constrainAs(checkMark) {
if (playedState) Icon(painter = painterResource(R.drawable.ic_check), tint = textColor, contentDescription = "played_mark",
modifier = Modifier.background(Color.Green).alpha(alpha).constrainAs(checkMark) {
bottom.linkTo(parent.bottom)
end.linkTo(parent.end)
})
@ -310,18 +344,24 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
selectMode = !selectMode
isSelected = selectMode
if (selectMode) {
selectedIds.add(episode.id)
// selectedIds.add(episode.id)
selected.add(episodes[index])
} else selectedIds.clear()
// selectedSize = selectedIds.size
longPressIndex = index
} else {
// selectedIds.clear()
selectedSize = 0
longPressIndex = -1
}
Logd(TAG, "long clicked: ${episode.title}")
})) {
Row {
if (episode.media?.getMediaType() == MediaType.VIDEO)
Image(painter = painterResource(R.drawable.ic_videocam), contentDescription = "isVideo", Modifier.width(14.dp).height(14.dp))
Icon(painter = painterResource(R.drawable.ic_videocam), tint = textColor, contentDescription = "isVideo", modifier = Modifier.width(14.dp).height(14.dp))
if (farvoriteState)
Image(painter = painterResource(R.drawable.ic_star), contentDescription = "isFavorite", Modifier.width(14.dp).height(14.dp))
if (curQueue.contains(episode))
Image(painter = painterResource(R.drawable.ic_playlist_play), contentDescription = "ivInPlaylist", Modifier.width(14.dp).height(14.dp))
Icon(painter = painterResource(R.drawable.ic_star), tint = textColor, contentDescription = "isFavorite", modifier = Modifier.width(14.dp).height(14.dp))
if (episode.inQueueState.value)
Icon(painter = painterResource(R.drawable.ic_playlist_play), tint = textColor, contentDescription = "ivInPlaylist", modifier = Modifier.width(14.dp).height(14.dp))
val dateSizeText = " · " + formatAbbrev(LocalContext.current, episode.getPubDate()) + " · " + if((episode.media?.size?:0) > 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<Episod
}
var actionButton by remember { mutableStateOf(if (actionButton_ == null) EpisodeActionButton.forItem(episodes[index]) else actionButton_(episodes[index])) }
var showAltActionsDialog by remember { mutableStateOf(false) }
val dls = remember { DownloadServiceInterface.get() }
var dlPercent by remember { mutableIntStateOf(0) }
fun isDownloading(): Boolean {
return episodes[index].downloadState.value > DownloadStatus.State.UNKNOWN.ordinal && episodes[index].downloadState.value < DownloadStatus.State.COMPLETED.ordinal
}
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))
}
}
}

View File

@ -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

View File

@ -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<Episode>())
//// 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

View File

@ -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<ChaptersListAdapter.ChapterHolder>() {
private var media: Playable? = null
private var currentChapterIndex = -1
private var currentChapterPosition: Long = -1

View File

@ -164,7 +164,7 @@ class DownloadLogFragment : BottomSheetDialogFragment(), OnItemClickListener, To
}
private class DownloadLogAdapter(private val context: Activity) : BaseAdapter() {
private var downloadLog: List<DownloadResult> = ArrayList()
private var downloadLog: List<DownloadResult> = listOf()
fun setDownloadLog(downloadLog: List<DownloadResult>) {
this.downloadLog = downloadLog

View File

@ -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<EpisodeActionButton?>(null)
private var actionButton2 by mutableStateOf<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.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
}
}
}

View File

@ -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<Feed?>(null)
private val episodes = mutableStateListOf<Episode>()
private var ieMap: Map<Long, Int> = mapOf()
private var ueMap: Map<String, Int> = 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) {

View File

@ -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() {

View File

@ -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<SwipeAction?>(null)
private var rightActionState = mutableStateOf<SwipeAction?>(null)
private lateinit var spinnerLayout: View
private lateinit var queueNames: Array<String>
@ -108,13 +108,13 @@ import kotlin.math.max
private lateinit var queues: List<PlayQueue>
private var displayUpArrow = false
private var queueItems: MutableList<Episode> = mutableListOf()
private var adapter: QueueRecyclerAdapter? = null
private val queueItems = mutableStateListOf<Episode>()
private var showBin: Boolean = false
private var addToQueueActionItem: SpeedDialActionItem? = null
private var dragDropEnabled: Boolean = !(isQueueKeepSorted || isQueueLocked)
private lateinit var browserFuture: ListenableFuture<MediaBrowser>
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 = QueueSwipeActions()
lifecycle.addObserver(swipeActions)
swipeActions = SwipeActions(this, TAG)
swipeActions.setFilter(EpisodeFilter(EpisodeFilter.States.queued.name))
swipeActions.attachTo(recyclerView)
refreshSwipeTelltale()
binding.leftActionIcon.setOnClickListener { swipeActions.showDialog() }
binding.rightActionIcon.setOnClickListener { swipeActions.showDialog() }
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())}, )
}
}
adapter = QueueRecyclerAdapter()
adapter?.setOnSelectModeListener(this)
recyclerView.adapter = adapter
lifecycle.addObserver(swipeActions)
refreshSwipeTelltale()
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)
// 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
}
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"

View File

@ -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<Episode> = mutableListOf()
private val results = mutableStateListOf<Episode>()
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<HorizontalFeedListAdapter.Holder>(), 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<Feed>?) {
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)

View File

@ -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<Feed>) {
fun handleAction(id: Int) {

View File

@ -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<Episode> ->
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<Episode> ->
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"
}
}

View File

@ -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<String, DownloadStatus>) : FlowEvent() {
val urls: Set<String>
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

View File

@ -1,88 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/audioplayer_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/playerFragment1"
<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeView1"
android:layout_width="match_parent"
android:layout_height="@dimen/external_player_height"
android:layout_gravity="top"
android:elevation="8dp"
android:outlineProvider="none"
tools:layout_height="@dimen/external_player_height" />
android:outlineProvider="none"/>
<RelativeLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
android:fitsSystemWindows="true"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:minHeight="?android:attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="@drawable/ic_arrow_down" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/itemDescription"
<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeDetailView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_above="@id/playerFragment2"
android:layout_below="@id/toolbar"
android:layout_marginBottom="12dp" />
android:layout_weight="1"
android:layout_marginBottom="12dp"/>
<ImageView
<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeView2"
android:layout_width="match_parent"
android:layout_height="8dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/bg_gradient"
app:tint="?android:attr/colorBackground"/>
android:layout_height="@dimen/external_player_height"/>
<androidx.cardview.widget.CardView
android:id="@+id/cardViewSeek"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBottom="@+id/itemDescription"
android:layout_centerHorizontal="true"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="12dp"
android:alpha="0"
app:cardBackgroundColor="?attr/seek_background"
app:cardCornerRadius="8dp"
app:cardElevation="0dp"
tools:alpha="1">
</LinearLayout>
<TextView
android:id="@+id/txtvSeek"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingLeft="24dp"
android:paddingTop="4dp"
android:paddingRight="24dp"
android:paddingBottom="4dp"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="1:06:29" />
</androidx.cardview.widget.CardView>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/playerFragment2"
android:layout_width="match_parent"
android:layout_height="@dimen/external_player_height"
android:layout_gravity="bottom"
android:layout_alignParentBottom="true"
tools:layout_height="@dimen/external_player_height" />
</RelativeLayout>
</FrameLayout>
</LinearLayout>

View File

@ -3,6 +3,7 @@
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/episode_info_fragment"
@ -18,226 +19,9 @@
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />
<RelativeLayout
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/feeditem_fragment"
<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeView"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="wrap_content"/>
<LinearLayout
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:id="@+id/imgvCover"
android:layout_width="@dimen/thumbnail_length_queue_item"
android:layout_height="@dimen/thumbnail_length_queue_item"
android:layout_gravity="center_vertical"
android:contentDescription="@string/open_podcast"
android:foreground="?attr/selectableItemBackground"
tools:src="@tools:sample/avatars" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp">
<TextView
android:id="@+id/txtvPodcast"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:foreground="?attr/selectableItemBackground"
android:importantForAccessibility="no"
style="@style/Podcini.TextView.ListItemSecondaryTitle"
tools:text="Podcast title" />
<TextView
android:id="@+id/txtvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary"
android:ellipsize="end"
android:maxLines="5"
tools:text="@sample/episodes.json/data/title" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/txtvPublished"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Podcini.TextView.ListItemSecondaryTitle"
tools:text="Jan 23" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginStart="4dp"
android:layout_marginRight="4dp"
android:layout_marginEnd="4dp"
android:importantForAccessibility="no"
android:text="·"
style="@style/Podcini.TextView.ListItemSecondaryTitle" />
<TextView
android:id="@+id/txtvDuration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Podcini.TextView.ListItemSecondaryTitle"
tools:text="@sample/episodes.json/data/duration" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginStart="4dp"
android:layout_marginRight="4dp"
android:layout_marginEnd="4dp"
android:importantForAccessibility="no"
android:text="·"
style="@style/Podcini.TextView.ListItemSecondaryTitle" />
<TextView
android:id="@+id/txtvSize"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Podcini.TextView.ListItemSecondaryTitle"
tools:text="@sample/episodes.json/data/duration" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:baselineAligned="false">
<ImageView
android:id="@+id/butAction1"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp"
tools:src="@drawable/ic_settings" />
<ImageView
android:id="@+id/homeButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginLeft="40dp"
android:layout_marginStart="40dp"
android:layout_marginRight="30dp"
android:layout_marginEnd="30dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp"
android:src="@drawable/baseline_home_work_24"
tools:src="@drawable/ic_settings" />
<FrameLayout
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:layout_gravity="bottom|end">
<ac.mdiq.podcini.ui.view.CircularProgressBar
android:id="@+id/circularProgressBar"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="center"
android:visibility="gone"
app:foregroundColor="?android:attr/textColorPrimary" />
<ImageView
android:id="@+id/butAction2"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
tools:src="@drawable/ic_settings" />
</FrameLayout>
</LinearLayout>
<TextView
android:id="@+id/noMediaLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:textAlignment="center"
android:background="?android:attr/dividerVertical"
android:text="@string/no_media_label" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/dividerVertical" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/header"
android:scrollbars="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ac.mdiq.podcini.ui.view.ShownotesWebView
android:id="@+id/webvDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<TextView
android:id="@+id/item_link"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="10dp"
style="@style/Podcini.TextView.ListItemBody"
tools:text="Item link" />
</LinearLayout>
</ScrollView>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/progbarLoading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>
</RelativeLayout>
</LinearLayout>

View File

@ -1,227 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/headerContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/additional_horizontal_spacing"
android:layout_marginTop="?android:attr/actionBarSize"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="130dp"
android:orientation="horizontal"
android:gravity="bottom">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:paddingVertical="2dp"
android:background="@color/image_readability_tint"
android:orientation="horizontal"
android:layout_alignParentBottom="true">
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"/>
<ImageButton
android:id="@+id/butFilter"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/filter"
android:scaleType="fitXY"
android:padding="3dp"
app:srcCompat="@drawable/ic_filter_white"
tools:visibility="visible" />
<View
android:layout_width="15dp"
android:layout_height="match_parent"/>
<ImageButton
android:id="@+id/butShowSettings"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/show_feed_settings_label"
android:scaleType="fitXY"
android:padding="3dp"
app:srcCompat="@drawable/ic_settings_white"
tools:visibility="visible" />
<TextView
android:id="@+id/counts"
android:layout_width="0dp"
android:layout_height="40dp"
android:ellipsize="end"
android:layout_weight="0.7"
android:gravity="end|center_vertical"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="@dimen/text_size_navdrawer"
tools:text="123" />
</LinearLayout>
<ImageView
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_alignParentBottom="true"
android:layout_alignParentLeft="true"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_rounded_corner_left" />
<ImageView
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_alignParentBottom="true"
android:layout_alignParentRight="true"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_rounded_corner_right" />
<androidx.cardview.widget.CardView
android:id="@+id/coverHolder"
android:layout_width="124dp"
android:layout_height="124dp"
android:layout_marginBottom="24dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_alignParentBottom="true"
app:cardBackgroundColor="@color/non_square_icon_background"
app:cardCornerRadius="8dp"
app:cardPreventCornerOverlap="false"
app:cardElevation="0dp">
<ImageView
android:id="@+id/imgvCover"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerVertical="true"
android:importantForAccessibility="no"
tools:src="@tools:sample/avatars" />
</androidx.cardview.widget.CardView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_toEndOf="@id/coverHolder"
android:layout_alignTop="@id/coverHolder"
android:layout_marginEnd="16dp"
android:orientation="vertical">
<TextView
android:id="@+id/txtvTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:shadowColor="@color/black"
android:shadowRadius="2"
android:textColor="@color/white"
style="@style/Podcini.TextView.Heading"
tools:text="Podcast title" />
<TextView
android:id="@+id/txtvAuthor"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:shadowColor="@color/black"
android:shadowRadius="2"
android:textColor="@color/white"
android:textSize="@dimen/text_size_small"
tools:text="Podcast author" />
</LinearLayout>
</RelativeLayout>
<com.mikepenz.iconics.view.IconicsTextView
android:id="@+id/txtvFailure"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="2dp"
android:background="?attr/icon_red"
android:gravity="center"
android:textColor="@color/white"
android:visibility="gone"
android:text="@string/refresh_failed_msg"
tools:visibility="visible"
tools:text="(!) Last refresh failed" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:id="@+id/left_action_icon"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_questionmark" />
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:scaleType="fitXY"
app:srcCompat="@drawable/baseline_arrow_left_alt_24" />
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"/>
<com.mikepenz.iconics.view.IconicsTextView
android:id="@+id/txtvInformation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="2dp"
android:background="?android:attr/colorBackground"
android:foreground="?android:attr/selectableItemBackground"
android:gravity="center"
android:textColor="?attr/colorAccent"
tools:visibility="visible"
tools:text="(i) Information" />
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"/>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:scaleType="fitXY"
app:srcCompat="@drawable/baseline_arrow_right_alt_24" />
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:id="@+id/right_action_icon"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_questionmark" />
</LinearLayout>
<com.mikepenz.iconics.view.IconicsTextView
android:id="@+id/txtvUpdatesDisabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="2dp"
android:background="?android:attr/colorBackground"
android:visibility="gone"
android:gravity="center"
android:textColor="?attr/colorAccent"
tools:visibility="visible"
tools:text="Updates disabled" />
</LinearLayout>

View File

@ -136,13 +136,13 @@
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:indeterminateOnly="true"
android:visibility="visible" />
<!-- <ProgressBar-->
<!-- android:id="@+id/progressBar"-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:layout_centerInParent="true"-->
<!-- android:indeterminateOnly="true"-->
<!-- android:visibility="visible" />-->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/subscriptions_add"

View File

@ -1,133 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/item_description_fragment"
android:fillViewport="false">
<LinearLayout
android:id="@+id/playtime_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/txtvPodcastTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:selectableItemBackground"
android:ellipsize="end"
android:gravity="center_horizontal"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorSecondary"
android:textStyle="bold"
android:textSize="@dimen/text_size_large"
android:layout_marginBottom="5dp"
tools:text="Podcast" />
<TextView
android:id="@+id/episodeDate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:maxLines="1"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textIsSelectable="false"
android:textSize="14sp"
tools:text="Episode" />
<TextView
android:id="@+id/txtvEpisodeTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_horizontal"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
android:textSize="18sp"
tools:text="Episode" />
<ac.mdiq.podcini.ui.view.ShownotesWebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<LinearLayout
android:id="@+id/chapterButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_margin="20dp"
android:background="@drawable/grey_border"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:minWidth="150dp"
android:orientation="horizontal"
android:visibility="gone"
tools:visibility="visible">
<ImageButton
android:id="@+id/butPrevChapter"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/prev_chapter"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_chapter_prev" />
<TextView
android:id="@+id/chapters_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="@string/chapters_label"
android:textColor="?android:attr/textColorSecondary"
android:textSize="@dimen/text_size_navdrawer" />
<ImageButton
android:id="@+id/butNextChapter"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/next_chapter"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_chapter_next" />
</LinearLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/coverHolder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<ImageView
android:id="@+id/imgvCover"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_gravity="center"
android:layout_marginHorizontal="32dp"
android:foreground="?attr/selectableItemBackgroundBorderless"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:src="@android:drawable/sym_def_app_icon" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</ScrollView>

View File

@ -1,232 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/player_ui_fragment"
android:layout_width="match_parent"
android:layout_height="@dimen/external_player_height"
android:background="?attr/selectableItemBackground"
android:orientation="vertical">
<TextView
android:id="@+id/title_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:gravity="start"
android:text="Title"
android:textStyle="bold"
android:maxLines="1"
android:textColor="?android:attr/textColorPrimary"
android:textSize="13sp"/>
<ac.mdiq.podcini.ui.view.ChapterSeekBar
android:id="@+id/sbPosition"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:clickable="true"
android:max="500"
android:visibility="invisible"
tools:progress="100" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ac.mdiq.podcini.ui.view.NoRelayoutTextView
android:id="@+id/txtvPosition"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentLeft="true"
android:layout_marginStart="5dp"
android:layout_marginLeft="5dp"
android:text="@string/position_default_label"
android:textColor="?android:attr/textColorSecondary"
android:textSize="@dimen/text_size_micro"/>
<ac.mdiq.podcini.ui.view.NoRelayoutTextView
android:id="@+id/txtvLength"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:layout_marginEnd="5dp"
android:layout_marginRight="5dp"
android:textAlignment="textEnd"
android:background="?android:attr/selectableItemBackground"
android:text="@string/position_default_label"
android:textColor="?android:attr/textColorSecondary"
android:textSize="@dimen/text_size_micro"/>
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="80dp"
android:orientation="horizontal">
<ImageView
android:id="@+id/imgvCover"
android:layout_width="80dp"
android:layout_height="match_parent"
android:cropToPadding="true"
android:scaleType="fitCenter"
android:background="@color/non_square_icon_background"
tools:src="@tools:sample/avatars" />
<RelativeLayout
android:id="@+id/player_control"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ac.mdiq.podcini.ui.view.PlayButton
android:id="@+id/butPlay"
android:layout_width="@dimen/audioplayer_playercontrols_length_big"
android:layout_height="@dimen/audioplayer_playercontrols_length_big"
android:layout_centerHorizontal="true"
android:layout_centerVertical="false"
android:layout_alignParentTop="true"
android:layout_marginStart="@dimen/audioplayer_playercontrols_margin"
android:layout_marginLeft="@dimen/audioplayer_playercontrols_margin"
android:layout_marginEnd="@dimen/audioplayer_playercontrols_margin"
android:layout_marginRight="@dimen/audioplayer_playercontrols_margin"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/pause_label"
android:padding="8dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_play_48dp"
tools:srcCompat="@drawable/ic_play_48dp"
app:tint="@color/medium_gray"/>
<ImageButton
android:id="@+id/butRev"
android:layout_width="@dimen/audioplayer_playercontrols_length"
android:layout_height="@dimen/audioplayer_playercontrols_length"
android:layout_centerVertical="false"
android:layout_alignParentTop="true"
android:layout_marginStart="@dimen/audioplayer_playercontrols_margin"
android:layout_marginLeft="@dimen/audioplayer_playercontrols_margin"
android:layout_toStartOf="@id/butPlay"
android:layout_toLeftOf="@id/butPlay"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/rewind_label"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_fast_rewind"
tools:srcCompat="@drawable/ic_fast_rewind"
app:tint="@color/medium_gray"/>
<TextView
android:id="@+id/txtvRev"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/butRev"
android:layout_alignStart="@id/butRev"
android:layout_alignLeft="@id/butRev"
android:layout_alignEnd="@id/butRev"
android:layout_alignRight="@id/butRev"
android:clickable="false"
android:gravity="center_horizontal"
android:text="30"
android:textColor="?android:attr/textColorSecondary"
android:textSize="12sp" />
<ac.mdiq.podcini.ui.view.PlaybackSpeedIndicatorView
android:id="@+id/butPlaybackSpeed"
android:layout_width="@dimen/audioplayer_playercontrols_length"
android:layout_height="@dimen/audioplayer_playercontrols_length"
android:layout_centerVertical="false"
android:layout_alignParentTop="true"
android:layout_marginEnd="@dimen/audioplayer_playercontrols_margin"
android:layout_marginRight="@dimen/audioplayer_playercontrols_margin"
android:layout_toStartOf="@id/butRev"
android:layout_toLeftOf="@id/butRev"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/playback_speed"
app:foregroundColor="@color/medium_gray"
tools:srcCompat="@drawable/ic_playback_speed"/>
<TextView
android:id="@+id/txtvPlaybackSpeed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/butPlaybackSpeed"
android:layout_alignStart="@id/butPlaybackSpeed"
android:layout_alignLeft="@id/butPlaybackSpeed"
android:layout_alignEnd="@id/butPlaybackSpeed"
android:layout_alignRight="@id/butPlaybackSpeed"
android:clickable="false"
android:gravity="center"
android:text="1.00"
android:textColor="?android:attr/textColorSecondary"
android:textSize="12sp" />
<ImageButton
android:id="@+id/butFF"
android:layout_width="@dimen/audioplayer_playercontrols_length"
android:layout_height="@dimen/audioplayer_playercontrols_length"
android:layout_centerVertical="false"
android:layout_alignParentTop="true"
android:layout_marginEnd="@dimen/audioplayer_playercontrols_margin"
android:layout_marginRight="@dimen/audioplayer_playercontrols_margin"
android:layout_toEndOf="@id/butPlay"
android:layout_toRightOf="@id/butPlay"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/fast_forward_label"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_fast_forward"
tools:srcCompat="@drawable/ic_fast_forward"
app:tint="@color/medium_gray"/>
<TextView
android:id="@+id/txtvFF"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/butFF"
android:layout_alignStart="@id/butFF"
android:layout_alignLeft="@id/butFF"
android:layout_alignEnd="@id/butFF"
android:layout_alignRight="@id/butFF"
android:clickable="false"
android:gravity="center"
android:text="30"
android:textColor="?android:attr/textColorSecondary"
android:textSize="12sp" />
<ImageButton
android:id="@+id/butSkip"
android:layout_width="@dimen/audioplayer_playercontrols_length"
android:layout_height="@dimen/audioplayer_playercontrols_length"
android:layout_centerVertical="false"
android:layout_alignParentTop="true"
android:layout_marginStart="@dimen/audioplayer_playercontrols_margin"
android:layout_marginLeft="@dimen/audioplayer_playercontrols_margin"
android:layout_toEndOf="@id/butFF"
android:layout_toRightOf="@id/butFF"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/skip_episode_label"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_skip_48dp"
tools:srcCompat="@drawable/ic_skip_48dp"
app:tint="@color/medium_gray"/>
<TextView
android:id="@+id/txtvSkip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/butSkip"
android:layout_alignStart="@id/butSkip"
android:layout_alignLeft="@id/butSkip"
android:layout_alignEnd="@id/butSkip"
android:layout_alignRight="@id/butSkip"
android:clickable="false"
android:gravity="center"
android:textColor="?android:attr/textColorSecondary"
android:textSize="12sp" />
</RelativeLayout>
</LinearLayout>
</LinearLayout>

View File

@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:id="@+id/queue_fragment">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
android:elevation="0dp">
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
@ -21,90 +21,16 @@
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator"/>
<LinearLayout
<androidx.compose.ui.platform.ComposeView
android:id="@+id/infobar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/toolbar"
android:layout_marginTop="-12dp"
android:orientation="horizontal">
android:layout_height="wrap_content"/>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:id="@+id/left_action_icon"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_questionmark" />
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:scaleType="fitXY"
app:srcCompat="@drawable/baseline_arrow_left_alt_24" />
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"/>
<TextView
android:id="@+id/info_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:layout_gravity="center_vertical"
tools:text="12 Episodes - 12 hours" />
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"/>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:scaleType="fitXY"
app:srcCompat="@drawable/baseline_arrow_right_alt_24" />
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:id="@+id/right_action_icon"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_questionmark" />
</LinearLayout>
</com.google.android.material.appbar.AppBarLayout>
<ac.mdiq.podcini.ui.view.EpisodesRecyclerView
android:id="@+id/recyclerView"
<androidx.compose.ui.platform.ComposeView
android:id="@+id/lazyColumn"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="@dimen/additional_horizontal_spacing"
android:layout_below="@id/appbar"/>
android:layout_height="wrap_content"/>
<!-- <androidx.swiperefreshlayout.widget.SwipeRefreshLayout-->
<!-- android:id="@+id/swipeRefresh"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="match_parent"-->
<!-- android:layout_below="@id/appbar">-->
<!-- <ac.mdiq.podcini.ui.view.EpisodesRecyclerView-->
<!-- android:id="@+id/recyclerView"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="match_parent"-->
<!-- android:paddingHorizontal="@dimen/additional_horizontal_spacing" />-->
<!-- </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>-->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:indeterminateOnly="true"
android:visibility="gone" />
<include
layout="@layout/multi_select_speed_dial" />
</RelativeLayout>
</LinearLayout>

View File

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:id="@+id/search_fragment">
<com.google.android.material.appbar.AppBarLayout
@ -29,40 +30,22 @@
android:id="@+id/feed_title_chip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/appbar"
android:layout_marginLeft="10dp"
android:layout_marginRight="0dp"
android:visibility="gone"
app:closeIconVisible="true" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_gravity="center"
android:visibility="gone"
style="?android:attr/progressBarStyle" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewFeeds"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/feed_title_chip"
android:paddingLeft="12dp"
android:paddingRight="12dp"
android:clipToPadding="false" />
<ac.mdiq.podcini.ui.view.EpisodesRecyclerView
android:id="@+id/recyclerView"
<androidx.compose.ui.platform.ComposeView
android:id="@+id/lazyColumn"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/recyclerViewFeeds"
android:layout_marginTop="-4dp"
android:paddingTop="12dp"
android:paddingHorizontal="@dimen/additional_horizontal_spacing" />
android:layout_height="wrap_content"/>
<include
layout="@layout/multi_select_speed_dial" />
</RelativeLayout>
</LinearLayout>

View File

@ -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

View File

@ -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

View File

@ -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