diff --git a/README.md b/README.md index 1ef51547..8f536f88 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin #### If you are migrating from Podcini version 5, please read the migrationTo5.md file for migration instructions. #### For Podcini to show up on car's HUD with Android Auto, please read AnroidAuto.md for instructions. -This project evolves from a fork of [AntennaPod]() as of Feb 5 2024. +This project is developed from a fork of [AntennaPod]() as of Feb 5 2024. Compared to AntennaPod this project: diff --git a/app/build.gradle b/app/build.gradle index 1c512cb9..7a4be160 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { testApplicationId "ac.mdiq.podcini.tests" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - versionCode 3020242 - versionName "6.5.8" + versionCode 3020243 + versionName "6.5.9" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt index 9a5a0361..c9ce3eaa 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt @@ -54,6 +54,7 @@ import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction import ac.mdiq.podcini.storage.utils.ChapterUtils import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded +import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter import ac.mdiq.podcini.ui.utils.NotificationUtils @@ -738,13 +739,10 @@ class PlaybackService : MediaLibraryService() { override fun onTaskRemoved(rootIntent: Intent?) { Logd(TAG, "onTaskRemoved") - val player = mediaSession?.player - if (player != null) { - if (!player.playWhenReady || player.mediaItemCount == 0 || player.playbackState == STATE_ENDED) { - // Stop the service if not playing, continue playing in the background - // otherwise. - stopSelf() - } + val player = mediaSession?.player ?: return + if (!player.playWhenReady || player.mediaItemCount == 0 || player.playbackState == STATE_ENDED) { + // Stop the service if not playing, continue playing in the background otherwise. + stopSelf() } } @@ -2569,7 +2567,9 @@ class PlaybackService : MediaLibraryService() { playbackService?.mPlayer?.prepare() playbackService?.taskManager?.restartSleepTimer() } - else -> Log.w(TAG, "Play/Pause button was pressed and PlaybackService state was unknown") + else -> { + Log.w(TAG, "Play/Pause button was pressed and PlaybackService state was unknown") + } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index 9307d3ba..d2b82f88 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -12,6 +12,7 @@ import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink import ac.mdiq.podcini.playback.cast.CastEnabledActivity +import ac.mdiq.podcini.playback.service.PlaybackService import ac.mdiq.podcini.preferences.ThemeSwitcher.getNoTitleTheme import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.backButtonOpensDrawer @@ -36,6 +37,7 @@ import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent import android.Manifest import android.annotation.SuppressLint +import android.content.ComponentName import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -64,6 +66,8 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken import androidx.recyclerview.widget.RecyclerView import androidx.work.WorkInfo import androidx.work.WorkManager @@ -72,6 +76,8 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback 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 kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest import org.apache.commons.lang3.ArrayUtils @@ -93,6 +99,7 @@ class MainActivity : CastEnabledActivity() { private lateinit var audioPlayerView: View private lateinit var navDrawer: View private lateinit var dummyView : View + private lateinit var controllerFuture: ListenableFuture lateinit var bottomSheet: LockableBottomSheetBehavior<*> private set @@ -174,7 +181,7 @@ class MainActivity : CastEnabledActivity() { buildTags() monitorFeeds() // InTheatre.apply { } - PlayerDetailsFragment.getSharedPrefs(this@MainActivity) + AudioPlayerFragment.PlayerDetailsFragment.getSharedPrefs(this@MainActivity) PlayerWidget.getSharedPrefs(this@MainActivity) StatisticsFragment.getSharedPrefs(this@MainActivity) OnlineFeedFragment.getSharedPrefs(this@MainActivity) @@ -189,13 +196,10 @@ class MainActivity : CastEnabledActivity() { // setContentView(R.layout.main_activity) setContentView(binding.root) recycledViewPool.setMaxRecycledViews(R.id.view_type_episode_item, 25) - dummyView = object : View(this) {} - drawerLayout = findViewById(R.id.main_layout) navDrawer = findViewById(R.id.navDrawerFragment) setNavDrawerSize() - mainView = findViewById(R.id.main_view) if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { @@ -208,9 +212,7 @@ class MainActivity : CastEnabledActivity() { ViewCompat.setOnApplyWindowInsetsListener(mainView) { _: View?, insets: WindowInsetsCompat -> navigationBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) updateInsets() - WindowInsetsCompat.Builder(insets) - .setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.NONE) - .build() + WindowInsetsCompat.Builder(insets).setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.NONE).build() } val fm = supportFragmentManager @@ -220,14 +222,9 @@ class MainActivity : CastEnabledActivity() { val lastFragment = NavDrawerFragment.getLastNavFragment() if (ArrayUtils.contains(NavDrawerFragment.NAV_DRAWER_TAGS, lastFragment)) loadFragment(lastFragment, null) else { - try { - loadFeedFragmentById(lastFragment.toInt().toLong(), null) - } catch (e: NumberFormatException) { - // it's not a number, this happens if we removed - // a label from the NAV_DRAWER_TAGS - // give them a nice default... - loadFragment(SubscriptionsFragment.TAG, null) - } + // it's not a number, this happens if we removed a label from the NAV_DRAWER_TAGS give them a nice default... + try { loadFeedFragmentById(lastFragment.toInt().toLong(), null) } + catch (e: NumberFormatException) { loadFragment(SubscriptionsFragment.TAG, null) } } } } @@ -367,6 +364,7 @@ class MainActivity : CastEnabledActivity() { _binding = null // realm.close() drawerLayout?.removeDrawerListener(drawerToggle!!) + MediaController.releaseFuture(controllerFuture) super.onDestroy() } @@ -467,14 +465,9 @@ class MainActivity : CastEnabledActivity() { @JvmOverloads fun loadChildFragment(fragment: Fragment, transition: TransitionEffect? = TransitionEffect.NONE) { val transaction = supportFragmentManager.beginTransaction() - when (transition) { TransitionEffect.FADE -> transaction.setCustomAnimations(R.anim.fade_in, R.anim.fade_out) - TransitionEffect.SLIDE -> transaction.setCustomAnimations( - R.anim.slide_right_in, - R.anim.slide_left_out, - R.anim.slide_left_in, - R.anim.slide_right_out) + TransitionEffect.SLIDE -> transaction.setCustomAnimations(R.anim.slide_right_in, R.anim.slide_left_out, R.anim.slide_left_in, R.anim.slide_right_out) TransitionEffect.NONE -> {} null -> {} } @@ -518,6 +511,12 @@ class MainActivity : CastEnabledActivity() { super.onStart() procFlowEvents() RatingDialog.init(this) + val sessionToken = SessionToken(this, ComponentName(this, PlaybackService::class.java)) + controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + controllerFuture.addListener({ +// mediaController = controllerFuture.get() +// Logd(TAG, "controllerFuture.addListener: $mediaController") + }, MoreExecutors.directExecutor()) } override fun onStop() { @@ -628,7 +627,6 @@ class MainActivity : CastEnabledActivity() { val tag = intent.getStringExtra(MainActivityStarter.Extras.fragment_tag.name) val args = intent.getBundleExtra(MainActivityStarter.Extras.fragment_args.name) if (tag != null) loadFragment(tag, args) - bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) } intent.getBooleanExtra(MainActivityStarter.Extras.open_player.name, false) -> { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index bcef46c4..8d885b37 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -2,7 +2,9 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.AudioplayerFragmentBinding +import ac.mdiq.podcini.databinding.PlayerDetailsFragmentBinding import ac.mdiq.podcini.databinding.PlayerUiFragmentBinding +import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource import ac.mdiq.podcini.playback.PlaybackServiceStarter import ac.mdiq.podcini.playback.ServiceStatusHandler import ac.mdiq.podcini.playback.base.InTheatre.curEpisode @@ -12,24 +14,27 @@ import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.getCurrentPlaybac import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.playback.base.VideoMode import ac.mdiq.podcini.playback.cast.CastEnabledActivity -import ac.mdiq.podcini.playback.service.PlaybackService import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curDurationFB import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curPositionFB import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curSpeedFB import ac.mdiq.podcini.playback.service.PlaybackService.Companion.getPlayerActivityIntent -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.toggleFallbackSpeed import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isPlayingVideoLocally import ac.mdiq.podcini.playback.service.PlaybackService.Companion.isSleepTimerActive import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playPause import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playbackService import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.toggleFallbackSpeed import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode import ac.mdiq.podcini.receiver.MediaButtonReceiver +import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope +import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.utils.ChapterUtils +import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.storage.utils.ImageResourceUtils +import ac.mdiq.podcini.storage.utils.TimeSpeedConverter import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode @@ -38,46 +43,58 @@ import ac.mdiq.podcini.ui.dialog.MediaPlayerErrorDialog import ac.mdiq.podcini.ui.dialog.SkipPreferenceDialog import ac.mdiq.podcini.ui.dialog.SleepTimerDialog import ac.mdiq.podcini.ui.dialog.VariableSpeedDialog +import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.view.ChapterSeekBar import ac.mdiq.podcini.ui.view.PlayButton -import ac.mdiq.podcini.storage.utils.DurationConverter -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.storage.utils.TimeSpeedConverter +import ac.mdiq.podcini.ui.view.ShownotesWebView import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent +import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.MiscFormatter +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ObjectAnimator import android.app.Activity -import android.content.ComponentName -import android.content.Intent +import android.content.* +import android.graphics.ColorFilter +import android.os.Build import android.os.Bundle import android.util.Log import android.view.* +import android.view.View.OnLayoutChangeListener import android.widget.ImageView import android.widget.SeekBar import android.widget.TextView +import android.widget.Toast import androidx.annotation.OptIn import androidx.appcompat.widget.Toolbar import androidx.cardview.widget.CardView import androidx.core.app.ShareCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment import androidx.interpolator.view.animation.FastOutSlowInInterpolator import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController -import androidx.media3.session.SessionToken import coil.imageLoader import coil.request.ErrorResult import coil.request.ImageRequest import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.elevation.SurfaceColors +import com.google.android.material.snackbar.Snackbar import com.google.common.util.concurrent.ListenableFuture -import com.google.common.util.concurrent.MoreExecutors 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.Readability4J +import org.apache.commons.lang3.StringUtils import java.text.DecimalFormat import java.text.NumberFormat import kotlin.math.max @@ -171,6 +188,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar _binding = null controller?.release() controller = null +// MediaController.releaseFuture(controllerFuture) super.onDestroyView() } @@ -293,12 +311,12 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar super.onStart() procFlowEvents() - val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), PlaybackService::class.java)) - controllerFuture = MediaController.Builder(requireContext(), sessionToken).buildAsync() - controllerFuture.addListener({ -// mediaController = controllerFuture.get() -// Logd(TAG, "controllerFuture.addListener: $mediaController") - }, MoreExecutors.directExecutor()) +// val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), PlaybackService::class.java)) +// controllerFuture = MediaController.Builder(requireContext(), sessionToken).buildAsync() +// controllerFuture.addListener({ +//// mediaController = controllerFuture.get() +//// Logd(TAG, "controllerFuture.addListener: $mediaController") +// }, MoreExecutors.directExecutor()) loadMediaInfo(false) } @@ -306,7 +324,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar override fun onStop() { Logd(TAG, "onStop()") super.onStop() - MediaController.releaseFuture(controllerFuture) +// MediaController.releaseFuture(controllerFuture) cancelFlowEvents() // progressIndicator.visibility = View.GONE // Controller released; we will not receive buffering updates } @@ -449,7 +467,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar EpisodeMenuHandler.onPrepareMenu(toolbar.menu, item) val mediaType = curMedia?.getMediaType() - toolbar.menu?.findItem(R.id.show_video)?.setVisible(mediaType == MediaType.VIDEO) + val notAudioOnly = (curMedia as? EpisodeMedia)?.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY + toolbar.menu?.findItem(R.id.show_video)?.setVisible(mediaType == MediaType.VIDEO && notAudioOnly) if (controller != null) { toolbar.menu.findItem(R.id.set_sleeptimer_item).setVisible(!isSleepTimerActive()) @@ -746,7 +765,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar .build() imageLoader.enqueue(imageRequest) } - if (isPlayingVideoLocally) { + if (isPlayingVideoLocally && (curMedia as? EpisodeMedia)?.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) { (activity as MainActivity).bottomSheet.setLocked(true) (activity as MainActivity).bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) } else { @@ -765,6 +784,424 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar } } + /** + * Displays the description of a Playable object in a Webview. + */ + @UnstableApi + class PlayerDetailsFragment : Fragment() { + private lateinit var shownoteView: ShownotesWebView + private var shownotesCleaner: ShownotesCleaner? = null + + private var _binding: PlayerDetailsFragmentBinding? = null + private val binding get() = _binding!! + + private var prevItem: Episode? = null + private var playable: Playable? = null + private var currentItem: Episode? = null + private var displayedChapterIndex = -1 + + private var cleanedNotes: String? = null + + private var isLoading = false + private var homeText: String? = null + internal var showHomeText = false + internal var readerhtml: String? = null + + private val currentChapter: Chapter? + get() { + if (playable == null || playable!!.getChapters().isEmpty() || displayedChapterIndex == -1) return null + return playable!!.getChapters()[displayedChapterIndex] + } + + @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + Logd(TAG, "fragment onCreateView") + _binding = PlayerDetailsFragmentBinding.inflate(inflater) + + val colorFilter: ColorFilter? = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(binding.txtvPodcastTitle.currentTextColor, BlendModeCompat.SRC_IN) + binding.butNextChapter.colorFilter = colorFilter + binding.butPrevChapter.colorFilter = colorFilter + binding.chapterButton.setOnClickListener { ChaptersFragment().show(childFragmentManager, ChaptersFragment.TAG) } + binding.butPrevChapter.setOnClickListener { seekToPrevChapter() } + binding.butNextChapter.setOnClickListener { seekToNextChapter() } + + Logd(TAG, "fragment onCreateView") + shownoteView = binding.webview + shownoteView.setTimecodeSelectedListener { time: Int -> seekTo(time) } + shownoteView.setPageFinishedListener { + // Restoring the scroll position might not always work + shownoteView.postDelayed({ this@PlayerDetailsFragment.restoreFromPreference() }, 50) + } + + binding.root.addOnLayoutChangeListener(object : OnLayoutChangeListener { + override fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) { + if (binding.root.measuredHeight != shownoteView.minimumHeight) shownoteView.setMinimumHeight(binding.root.measuredHeight) + binding.root.removeOnLayoutChangeListener(this) + } + }) + registerForContextMenu(shownoteView) + shownotesCleaner = ShownotesCleaner(requireContext()) + return binding.root + } + +// override fun onStart() { +// Logd(TAG, "onStart()") +// super.onStart() +// } + +// override fun onStop() { +// Logd(TAG, "onStop()") +// super.onStop() +// } + + override fun onDestroyView() { + Logd(TAG, "onDestroyView") + _binding = null + prevItem = null + currentItem = null + Logd(TAG, "Fragment destroyed") + shownoteView.removeAllViews() + shownoteView.destroy() + super.onDestroyView() + } + + override fun onContextItemSelected(item: MenuItem): Boolean { + return shownoteView.onContextItemSelected(item) + } + + internal fun updateInfo() { +// if (isLoading) return + lifecycleScope.launch { + Logd(TAG, "in updateInfo") + isLoading = true + withContext(Dispatchers.IO) { + if (currentItem == null) { + playable = curMedia + if (playable != null && playable is EpisodeMedia) { + val episodeMedia = playable as EpisodeMedia + currentItem = episodeMedia.episodeOrFetch() + showHomeText = false + homeText = null + } + } + if (currentItem != null) { + playable = currentItem!!.media + if (prevItem?.identifier != currentItem!!.identifier) cleanedNotes = null + if (cleanedNotes == null) { + Logd(TAG, "calling load description ${currentItem!!.description==null} ${currentItem!!.title}") + cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", playable?.getDuration()?:0) + } + prevItem = currentItem + } + } + withContext(Dispatchers.Main) { + Logd(TAG, "subscribe: ${playable?.getEpisodeTitle()}") + displayMediaInfo(playable!!) + shownoteView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank") + Logd(TAG, "Webview loaded") + } + }.invokeOnCompletion { throwable -> + isLoading = false + if (throwable != null) Log.e(TAG, Log.getStackTraceString(throwable)) + } + } + + fun buildHomeReaderText() { + showHomeText = !showHomeText + runOnIOScope { + if (showHomeText) { + homeText = currentItem!!.transcript + if (homeText == null && currentItem?.link != null) { + val url = currentItem!!.link!! + val htmlSource = fetchHtmlSource(url) + val readability4J = Readability4J(currentItem!!.link!!, htmlSource) + val article = readability4J.parse() + readerhtml = article.contentWithDocumentsCharsetOrUtf8 + if (!readerhtml.isNullOrEmpty()) { + currentItem = upsertBlk(currentItem!!) { + it.setTranscriptIfLonger(readerhtml) + } + homeText = currentItem!!.transcript +// persistEpisode(currentItem) + } + } + if (!homeText.isNullOrEmpty()) { +// val shownotesCleaner = ShownotesCleaner(requireContext()) + cleanedNotes = shownotesCleaner?.processShownotes(homeText!!, 0) + withContext(Dispatchers.Main) { + shownoteView.loadDataWithBaseURL("https://127.0.0.1", + cleanedNotes ?: "No notes", + "text/html", + "UTF-8", + null) + } + } else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() } + } else { +// val shownotesCleaner = ShownotesCleaner(requireContext()) + cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", playable?.getDuration() ?: 0) + if (!cleanedNotes.isNullOrEmpty()) { + withContext(Dispatchers.Main) { + shownoteView.loadDataWithBaseURL("https://127.0.0.1", + cleanedNotes ?: "No notes", + "text/html", + "UTF-8", + null) + } + } else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() } + } + } + } + + @UnstableApi private fun displayMediaInfo(media: Playable) { + Logd(TAG, "displayMediaInfo ${currentItem?.title} ${media.getEpisodeTitle()}") + val pubDateStr = MiscFormatter.formatAbbrev(context, media.getPubDate()) + binding.txtvPodcastTitle.text = StringUtils.stripToEmpty(media.getFeedTitle()) + if (media is EpisodeMedia) { + if (currentItem?.feedId != null) { + val openFeed: Intent = MainActivity.getIntentToOpenFeed(requireContext(), currentItem!!.feedId!!) + binding.txtvPodcastTitle.setOnClickListener { startActivity(openFeed) } + } + } else binding.txtvPodcastTitle.setOnClickListener(null) + + binding.txtvPodcastTitle.setOnLongClickListener { copyText(media.getFeedTitle()) } + binding.episodeDate.text = StringUtils.stripToEmpty(pubDateStr) + binding.txtvEpisodeTitle.text = currentItem?.title + binding.txtvEpisodeTitle.setOnLongClickListener { copyText(currentItem?.title?:"") } + binding.txtvEpisodeTitle.setOnClickListener { + val lines = binding.txtvEpisodeTitle.lineCount + val animUnit = 1500 + if (lines > binding.txtvEpisodeTitle.maxLines) { + val titleHeight = (binding.txtvEpisodeTitle.height - binding.txtvEpisodeTitle.paddingTop - binding.txtvEpisodeTitle.paddingBottom) + val verticalMarquee: ObjectAnimator = ObjectAnimator.ofInt(binding.txtvEpisodeTitle, "scrollY", 0, + (lines - binding.txtvEpisodeTitle.maxLines) * (titleHeight / binding.txtvEpisodeTitle.maxLines)).setDuration((lines * animUnit).toLong()) + val fadeOut: ObjectAnimator = ObjectAnimator.ofFloat(binding.txtvEpisodeTitle, "alpha", 0f) + fadeOut.setStartDelay(animUnit.toLong()) + fadeOut.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + binding.txtvEpisodeTitle.scrollTo(0, 0) + } + }) + val fadeBackIn: ObjectAnimator = ObjectAnimator.ofFloat(binding.txtvEpisodeTitle, "alpha", 1f) + val set = AnimatorSet() + set.playSequentially(verticalMarquee, fadeOut, fadeBackIn) + set.start() + } + } + displayedChapterIndex = -1 + refreshChapterData(ChapterUtils.getCurrentChapterIndex(media, media.getPosition())) //calls displayCoverImage + updateChapterControlVisibility() + } + + private fun updateChapterControlVisibility() { + var chapterControlVisible = false + when { + playable?.getChapters() != null -> chapterControlVisible = playable!!.getChapters().isNotEmpty() + playable is EpisodeMedia -> { + val item_ = (playable as EpisodeMedia).episodeOrFetch() + // If an item has chapters but they are not loaded yet, still display the button. + chapterControlVisible = !item_?.chapters.isNullOrEmpty() + } + } + val newVisibility = if (chapterControlVisible) View.VISIBLE else View.GONE + if (binding.chapterButton.visibility != newVisibility) { + binding.chapterButton.visibility = newVisibility + ObjectAnimator.ofFloat(binding.chapterButton, "alpha", + (if (chapterControlVisible) 0 else 1).toFloat(), (if (chapterControlVisible) 1 else 0).toFloat()).start() + } + } + + private fun refreshChapterData(chapterIndex: Int) { + Logd(TAG, "in refreshChapterData $chapterIndex") + if (playable != null && chapterIndex > -1) { + if (playable!!.getPosition() > playable!!.getDuration() || chapterIndex >= playable!!.getChapters().size - 1) { + displayedChapterIndex = playable!!.getChapters().size - 1 + binding.butNextChapter.visibility = View.INVISIBLE + } else { + displayedChapterIndex = chapterIndex + binding.butNextChapter.visibility = View.VISIBLE + } + } + displayCoverImage() + } + + private fun displayCoverImage() { + if (playable == null) return + if (displayedChapterIndex == -1 || playable!!.getChapters().isEmpty() || playable!!.getChapters()[displayedChapterIndex].imageUrl.isNullOrEmpty()) { + val imageLoader = binding.imgvCover.context.imageLoader + val imageRequest = ImageRequest.Builder(requireContext()) + .data(playable!!.getImageLocation()) + .setHeader("User-Agent", "Mozilla/5.0") + .placeholder(R.color.light_gray) + .listener(object : ImageRequest.Listener { + override fun onError(request: ImageRequest, result: ErrorResult) { + val fallbackImageRequest = ImageRequest.Builder(requireContext()) + .data(ImageResourceUtils.getFallbackImageLocation(playable!!)) + .setHeader("User-Agent", "Mozilla/5.0") + .error(R.mipmap.ic_launcher) + .target(binding.imgvCover) + .build() + imageLoader.enqueue(fallbackImageRequest) + } + }) + .target(binding.imgvCover) + .build() + imageLoader.enqueue(imageRequest) + + } else { + val imgLoc = EmbeddedChapterImage.getModelFor(playable!!, displayedChapterIndex) + val imageLoader = binding.imgvCover.context.imageLoader + val imageRequest = ImageRequest.Builder(requireContext()) + .data(imgLoc) + .setHeader("User-Agent", "Mozilla/5.0") + .placeholder(R.color.light_gray) + .listener(object : ImageRequest.Listener { + override fun onError(request: ImageRequest, result: ErrorResult) { + val fallbackImageRequest = ImageRequest.Builder(requireContext()) + .data(ImageResourceUtils.getFallbackImageLocation(playable!!)) + .setHeader("User-Agent", "Mozilla/5.0") + .error(R.mipmap.ic_launcher) + .target(binding.imgvCover) + .build() + imageLoader.enqueue(fallbackImageRequest) + } + }) + .target(binding.imgvCover) + .build() + imageLoader.enqueue(imageRequest) + } + } + + @UnstableApi private fun seekToPrevChapter() { + val curr: Chapter? = currentChapter + if (curr == null || displayedChapterIndex == -1) return + + when { + displayedChapterIndex < 1 -> seekTo(0) + (curPositionFB - 10000 * curSpeedFB) < curr.start -> { + refreshChapterData(displayedChapterIndex - 1) + if (playable != null) seekTo(playable!!.getChapters()[displayedChapterIndex].start.toInt()) + } + else -> seekTo(curr.start.toInt()) + } + } + + @UnstableApi private fun seekToNextChapter() { + if (playable == null || playable!!.getChapters().isEmpty() || displayedChapterIndex == -1 || displayedChapterIndex + 1 >= playable!!.getChapters().size) return + refreshChapterData(displayedChapterIndex + 1) + seekTo(playable!!.getChapters()[displayedChapterIndex].start.toInt()) + } + + + @UnstableApi override fun onPause() { + super.onPause() + savePreference() + } + + @UnstableApi private fun savePreference() { + Logd(TAG, "Saving preferences") + val editor = prefs?.edit() ?: return + if (curMedia != null) { + Logd(TAG, "Saving scroll position: " + binding.itemDescriptionFragment.scrollY) + editor.putInt(PREF_SCROLL_Y, binding.itemDescriptionFragment.scrollY) + editor.putString(PREF_PLAYABLE_ID, curMedia!!.getIdentifier().toString()) + } else { + Logd(TAG, "savePreferences was called while media or webview was null") + editor.putInt(PREF_SCROLL_Y, -1) + editor.putString(PREF_PLAYABLE_ID, "") + } + editor.apply() + } + + @UnstableApi private fun restoreFromPreference(): Boolean { + if ((activity as MainActivity).bottomSheet.state != BottomSheetBehavior.STATE_EXPANDED) return false + + Logd(TAG, "Restoring from preferences") + val activity: Activity? = activity + if (activity != null) { + val id = prefs!!.getString(PREF_PLAYABLE_ID, "") + val scrollY = prefs!!.getInt(PREF_SCROLL_Y, -1) + if (scrollY != -1) { + if (id == curMedia?.getIdentifier()?.toString()) { + Logd(TAG, "Restored scroll Position: $scrollY") + binding.itemDescriptionFragment.scrollTo(binding.itemDescriptionFragment.scrollX, scrollY) + return true + } + Logd(TAG, "reset scroll Position: 0") + binding.itemDescriptionFragment.scrollTo(0, 0) + return true + } + } + return false + } + + fun scrollToTop() { + binding.itemDescriptionFragment.scrollTo(0, 0) + savePreference() + } + + fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { + if (playable?.getIdentifier() != event.media?.getIdentifier()) return + val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(playable, event.position) + if (newChapterIndex >= 0 && newChapterIndex != displayedChapterIndex) refreshChapterData(newChapterIndex) + } + + fun setItem(item_: Episode) { + Logd(TAG, "setItem ${item_.title}") + if (currentItem?.identifier != item_.identifier) { + currentItem = item_ + showHomeText = false + homeText = null + } + } + +// override fun onConfigurationChanged(newConfig: Configuration) { +// super.onConfigurationChanged(newConfig) +// configureForOrientation(newConfig) +// } +// +// private fun configureForOrientation(newConfig: Configuration) { +// val isPortrait = newConfig.orientation == Configuration.ORIENTATION_PORTRAIT +// +//// binding.coverFragment.orientation = if (isPortrait) LinearLayout.VERTICAL else LinearLayout.HORIZONTAL +// +// if (isPortrait) { +// binding.coverHolder.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) +//// binding.coverFragmentTextContainer.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) +// } else { +// binding.coverHolder.layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f) +//// binding.coverFragmentTextContainer.layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f) +// } +// +// (binding.episodeDetails.parent as ViewGroup).removeView(binding.episodeDetails) +// if (isPortrait) { +// binding.coverFragment.addView(binding.episodeDetails) +// } else { +// binding.coverFragmentTextContainer.addView(binding.episodeDetails) +// } +// } + + @UnstableApi private fun copyText(text: String): Boolean { + val clipboardManager: ClipboardManager? = ContextCompat.getSystemService(requireContext(), ClipboardManager::class.java) + clipboardManager?.setPrimaryClip(ClipData.newPlainText("Podcini", text)) + if (Build.VERSION.SDK_INT <= 32) { + (requireActivity() as MainActivity).showSnackbarAbovePlayer(resources.getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT) + } + return true + } + + companion object { + private val TAG: String = PlayerDetailsFragment::class.simpleName ?: "Anonymous" + + private const val PREF = "ItemDescriptionFragmentPrefs" + private const val PREF_SCROLL_Y = "prefScrollY" + private const val PREF_PLAYABLE_ID = "prefPlayableId" + + var prefs: SharedPreferences? = null + fun getSharedPrefs(context: Context) { + if (prefs == null) prefs = context.getSharedPreferences(PREF, Context.MODE_PRIVATE) + } + } + } + companion object { val TAG = AudioPlayerFragment::class.simpleName ?: "Anonymous" } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt deleted file mode 100644 index c29e8741..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/PlayerDetailsFragment.kt +++ /dev/null @@ -1,470 +0,0 @@ -package ac.mdiq.podcini.ui.fragment - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.PlayerDetailsFragmentBinding -import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource -import ac.mdiq.podcini.playback.base.InTheatre.curMedia -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curPositionFB -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curSpeedFB -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo -import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope -import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk -import ac.mdiq.podcini.storage.model.* -import ac.mdiq.podcini.storage.utils.ChapterUtils -import ac.mdiq.podcini.storage.utils.ImageResourceUtils -import ac.mdiq.podcini.ui.activity.MainActivity -import ac.mdiq.podcini.ui.utils.ShownotesCleaner -import ac.mdiq.podcini.ui.view.ShownotesWebView -import ac.mdiq.podcini.util.MiscFormatter -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.FlowEvent -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.AnimatorSet -import android.animation.ObjectAnimator -import android.app.Activity -import android.content.* -import android.graphics.ColorFilter -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.View.OnLayoutChangeListener -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.core.graphics.BlendModeColorFilterCompat -import androidx.core.graphics.BlendModeCompat -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.media3.common.util.UnstableApi -import coil.imageLoader -import coil.request.ErrorResult -import coil.request.ImageRequest -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import net.dankito.readability4j.Readability4J -import org.apache.commons.lang3.StringUtils - -/** - * Displays the description of a Playable object in a Webview. - */ -@UnstableApi -class PlayerDetailsFragment : Fragment() { - private lateinit var shownoteView: ShownotesWebView - private var shownotesCleaner: ShownotesCleaner? = null - - private var _binding: PlayerDetailsFragmentBinding? = null - private val binding get() = _binding!! - - private var prevItem: Episode? = null - private var playable: Playable? = null - private var currentItem: Episode? = null - private var displayedChapterIndex = -1 - - private var cleanedNotes: String? = null - - private var isLoading = false - private var homeText: String? = null - internal var showHomeText = false - internal var readerhtml: String? = null - - private val currentChapter: Chapter? - get() { - if (playable == null || playable!!.getChapters().isEmpty() || displayedChapterIndex == -1) return null - return playable!!.getChapters()[displayedChapterIndex] - } - - @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - Logd(TAG, "fragment onCreateView") - _binding = PlayerDetailsFragmentBinding.inflate(inflater) - - val colorFilter: ColorFilter? = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(binding.txtvPodcastTitle.currentTextColor, BlendModeCompat.SRC_IN) - binding.butNextChapter.colorFilter = colorFilter - binding.butPrevChapter.colorFilter = colorFilter - binding.chapterButton.setOnClickListener { ChaptersFragment().show(childFragmentManager, ChaptersFragment.TAG) } - binding.butPrevChapter.setOnClickListener { seekToPrevChapter() } - binding.butNextChapter.setOnClickListener { seekToNextChapter() } - - Logd(TAG, "fragment onCreateView") - shownoteView = binding.webview - shownoteView.setTimecodeSelectedListener { time: Int -> seekTo(time) } - shownoteView.setPageFinishedListener { - // Restoring the scroll position might not always work - shownoteView.postDelayed({ this@PlayerDetailsFragment.restoreFromPreference() }, 50) - } - - binding.root.addOnLayoutChangeListener(object : OnLayoutChangeListener { - override fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) { - if (binding.root.measuredHeight != shownoteView.minimumHeight) shownoteView.setMinimumHeight(binding.root.measuredHeight) - binding.root.removeOnLayoutChangeListener(this) - } - }) - registerForContextMenu(shownoteView) - shownotesCleaner = ShownotesCleaner(requireContext()) - return binding.root - } - -// override fun onStart() { -// Logd(TAG, "onStart()") -// super.onStart() -// } - -// override fun onStop() { -// Logd(TAG, "onStop()") -// super.onStop() -// } - - override fun onDestroyView() { - Logd(TAG, "onDestroyView") - _binding = null - prevItem = null - currentItem = null - Logd(TAG, "Fragment destroyed") - shownoteView.removeAllViews() - shownoteView.destroy() - super.onDestroyView() - } - - override fun onContextItemSelected(item: MenuItem): Boolean { - return shownoteView.onContextItemSelected(item) - } - - internal fun updateInfo() { -// if (isLoading) return - lifecycleScope.launch { - Logd(TAG, "in updateInfo") - isLoading = true - withContext(Dispatchers.IO) { - if (currentItem == null) { - playable = curMedia - if (playable != null && playable is EpisodeMedia) { - val episodeMedia = playable as EpisodeMedia - currentItem = episodeMedia.episodeOrFetch() - showHomeText = false - homeText = null - } - } - if (currentItem != null) { - playable = currentItem!!.media - if (prevItem?.identifier != currentItem!!.identifier) cleanedNotes = null - if (cleanedNotes == null) { - Logd(TAG, "calling load description ${currentItem!!.description==null} ${currentItem!!.title}") - cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", playable?.getDuration()?:0) - } - prevItem = currentItem - } - } - withContext(Dispatchers.Main) { - Logd(TAG, "subscribe: ${playable?.getEpisodeTitle()}") - displayMediaInfo(playable!!) - shownoteView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank") - Logd(TAG, "Webview loaded") - } - }.invokeOnCompletion { throwable -> - isLoading = false - if (throwable != null) Log.e(TAG, Log.getStackTraceString(throwable)) - } - } - - fun buildHomeReaderText() { - showHomeText = !showHomeText - runOnIOScope { - if (showHomeText) { - homeText = currentItem!!.transcript - if (homeText == null && currentItem?.link != null) { - val url = currentItem!!.link!! - val htmlSource = fetchHtmlSource(url) - val readability4J = Readability4J(currentItem!!.link!!, htmlSource) - val article = readability4J.parse() - readerhtml = article.contentWithDocumentsCharsetOrUtf8 - if (!readerhtml.isNullOrEmpty()) { - currentItem = upsertBlk(currentItem!!) { - it.setTranscriptIfLonger(readerhtml) - } - homeText = currentItem!!.transcript -// persistEpisode(currentItem) - } - } - if (!homeText.isNullOrEmpty()) { -// val shownotesCleaner = ShownotesCleaner(requireContext()) - cleanedNotes = shownotesCleaner?.processShownotes(homeText!!, 0) - withContext(Dispatchers.Main) { - shownoteView.loadDataWithBaseURL("https://127.0.0.1", - cleanedNotes ?: "No notes", - "text/html", - "UTF-8", - null) - } - } else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() } - } else { -// val shownotesCleaner = ShownotesCleaner(requireContext()) - cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", playable?.getDuration() ?: 0) - if (!cleanedNotes.isNullOrEmpty()) { - withContext(Dispatchers.Main) { - shownoteView.loadDataWithBaseURL("https://127.0.0.1", - cleanedNotes ?: "No notes", - "text/html", - "UTF-8", - null) - } - } else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() } - } - } - } - - @UnstableApi private fun displayMediaInfo(media: Playable) { - Logd(TAG, "displayMediaInfo ${currentItem?.title} ${media.getEpisodeTitle()}") - val pubDateStr = MiscFormatter.formatAbbrev(context, media.getPubDate()) - binding.txtvPodcastTitle.text = StringUtils.stripToEmpty(media.getFeedTitle()) - if (media is EpisodeMedia) { - if (currentItem?.feedId != null) { - val openFeed: Intent = MainActivity.getIntentToOpenFeed(requireContext(), currentItem!!.feedId!!) - binding.txtvPodcastTitle.setOnClickListener { startActivity(openFeed) } - } - } else binding.txtvPodcastTitle.setOnClickListener(null) - - binding.txtvPodcastTitle.setOnLongClickListener { copyText(media.getFeedTitle()) } - binding.episodeDate.text = StringUtils.stripToEmpty(pubDateStr) - binding.txtvEpisodeTitle.text = currentItem?.title - binding.txtvEpisodeTitle.setOnLongClickListener { copyText(currentItem?.title?:"") } - binding.txtvEpisodeTitle.setOnClickListener { - val lines = binding.txtvEpisodeTitle.lineCount - val animUnit = 1500 - if (lines > binding.txtvEpisodeTitle.maxLines) { - val titleHeight = (binding.txtvEpisodeTitle.height - binding.txtvEpisodeTitle.paddingTop - binding.txtvEpisodeTitle.paddingBottom) - val verticalMarquee: ObjectAnimator = ObjectAnimator.ofInt(binding.txtvEpisodeTitle, "scrollY", 0, - (lines - binding.txtvEpisodeTitle.maxLines) * (titleHeight / binding.txtvEpisodeTitle.maxLines)).setDuration((lines * animUnit).toLong()) - val fadeOut: ObjectAnimator = ObjectAnimator.ofFloat(binding.txtvEpisodeTitle, "alpha", 0f) - fadeOut.setStartDelay(animUnit.toLong()) - fadeOut.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - binding.txtvEpisodeTitle.scrollTo(0, 0) - } - }) - val fadeBackIn: ObjectAnimator = ObjectAnimator.ofFloat(binding.txtvEpisodeTitle, "alpha", 1f) - val set = AnimatorSet() - set.playSequentially(verticalMarquee, fadeOut, fadeBackIn) - set.start() - } - } - displayedChapterIndex = -1 - refreshChapterData(ChapterUtils.getCurrentChapterIndex(media, media.getPosition())) //calls displayCoverImage - updateChapterControlVisibility() - } - - private fun updateChapterControlVisibility() { - var chapterControlVisible = false - when { - playable?.getChapters() != null -> chapterControlVisible = playable!!.getChapters().isNotEmpty() - playable is EpisodeMedia -> { - val item_ = (playable as EpisodeMedia).episodeOrFetch() - // If an item has chapters but they are not loaded yet, still display the button. - chapterControlVisible = !item_?.chapters.isNullOrEmpty() - } - } - val newVisibility = if (chapterControlVisible) View.VISIBLE else View.GONE - if (binding.chapterButton.visibility != newVisibility) { - binding.chapterButton.visibility = newVisibility - ObjectAnimator.ofFloat(binding.chapterButton, "alpha", - (if (chapterControlVisible) 0 else 1).toFloat(), (if (chapterControlVisible) 1 else 0).toFloat()).start() - } - } - - private fun refreshChapterData(chapterIndex: Int) { - Logd(TAG, "in refreshChapterData $chapterIndex") - if (playable != null && chapterIndex > -1) { - if (playable!!.getPosition() > playable!!.getDuration() || chapterIndex >= playable!!.getChapters().size - 1) { - displayedChapterIndex = playable!!.getChapters().size - 1 - binding.butNextChapter.visibility = View.INVISIBLE - } else { - displayedChapterIndex = chapterIndex - binding.butNextChapter.visibility = View.VISIBLE - } - } - displayCoverImage() - } - - private fun displayCoverImage() { - if (playable == null) return - if (displayedChapterIndex == -1 || playable!!.getChapters().isEmpty() || playable!!.getChapters()[displayedChapterIndex].imageUrl.isNullOrEmpty()) { - val imageLoader = binding.imgvCover.context.imageLoader - val imageRequest = ImageRequest.Builder(requireContext()) - .data(playable!!.getImageLocation()) - .setHeader("User-Agent", "Mozilla/5.0") - .placeholder(R.color.light_gray) - .listener(object : ImageRequest.Listener { - override fun onError(request: ImageRequest, result: ErrorResult) { - val fallbackImageRequest = ImageRequest.Builder(requireContext()) - .data(ImageResourceUtils.getFallbackImageLocation(playable!!)) - .setHeader("User-Agent", "Mozilla/5.0") - .error(R.mipmap.ic_launcher) - .target(binding.imgvCover) - .build() - imageLoader.enqueue(fallbackImageRequest) - } - }) - .target(binding.imgvCover) - .build() - imageLoader.enqueue(imageRequest) - - } else { - val imgLoc = EmbeddedChapterImage.getModelFor(playable!!, displayedChapterIndex) - val imageLoader = binding.imgvCover.context.imageLoader - val imageRequest = ImageRequest.Builder(requireContext()) - .data(imgLoc) - .setHeader("User-Agent", "Mozilla/5.0") - .placeholder(R.color.light_gray) - .listener(object : ImageRequest.Listener { - override fun onError(request: ImageRequest, result: ErrorResult) { - val fallbackImageRequest = ImageRequest.Builder(requireContext()) - .data(ImageResourceUtils.getFallbackImageLocation(playable!!)) - .setHeader("User-Agent", "Mozilla/5.0") - .error(R.mipmap.ic_launcher) - .target(binding.imgvCover) - .build() - imageLoader.enqueue(fallbackImageRequest) - } - }) - .target(binding.imgvCover) - .build() - imageLoader.enqueue(imageRequest) - } - } - - @UnstableApi private fun seekToPrevChapter() { - val curr: Chapter? = currentChapter - if (curr == null || displayedChapterIndex == -1) return - - when { - displayedChapterIndex < 1 -> seekTo(0) - (curPositionFB - 10000 * curSpeedFB) < curr.start -> { - refreshChapterData(displayedChapterIndex - 1) - if (playable != null) seekTo(playable!!.getChapters()[displayedChapterIndex].start.toInt()) - } - else -> seekTo(curr.start.toInt()) - } - } - - @UnstableApi private fun seekToNextChapter() { - if (playable == null || playable!!.getChapters().isEmpty() || displayedChapterIndex == -1 || displayedChapterIndex + 1 >= playable!!.getChapters().size) return - refreshChapterData(displayedChapterIndex + 1) - seekTo(playable!!.getChapters()[displayedChapterIndex].start.toInt()) - } - - - @UnstableApi override fun onPause() { - super.onPause() - savePreference() - } - - @UnstableApi private fun savePreference() { - Logd(TAG, "Saving preferences") - val editor = prefs?.edit() ?: return - if (curMedia != null) { - Logd(TAG, "Saving scroll position: " + binding.itemDescriptionFragment.scrollY) - editor.putInt(PREF_SCROLL_Y, binding.itemDescriptionFragment.scrollY) - editor.putString(PREF_PLAYABLE_ID, curMedia!!.getIdentifier().toString()) - } else { - Logd(TAG, "savePreferences was called while media or webview was null") - editor.putInt(PREF_SCROLL_Y, -1) - editor.putString(PREF_PLAYABLE_ID, "") - } - editor.apply() - } - - @UnstableApi private fun restoreFromPreference(): Boolean { - if ((activity as MainActivity).bottomSheet.state != BottomSheetBehavior.STATE_EXPANDED) return false - - Logd(TAG, "Restoring from preferences") - val activity: Activity? = activity - if (activity != null) { - val id = prefs!!.getString(PREF_PLAYABLE_ID, "") - val scrollY = prefs!!.getInt(PREF_SCROLL_Y, -1) - if (scrollY != -1) { - if (id == curMedia?.getIdentifier()?.toString()) { - Logd(TAG, "Restored scroll Position: $scrollY") - binding.itemDescriptionFragment.scrollTo(binding.itemDescriptionFragment.scrollX, scrollY) - return true - } - Logd(TAG, "reset scroll Position: 0") - binding.itemDescriptionFragment.scrollTo(0, 0) - return true - } - } - return false - } - - fun scrollToTop() { - binding.itemDescriptionFragment.scrollTo(0, 0) - savePreference() - } - - fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) { - if (playable?.getIdentifier() != event.media?.getIdentifier()) return - val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(playable, event.position) - if (newChapterIndex >= 0 && newChapterIndex != displayedChapterIndex) refreshChapterData(newChapterIndex) - } - - fun setItem(item_: Episode) { - Logd(TAG, "setItem ${item_.title}") - if (currentItem?.identifier != item_.identifier) { - currentItem = item_ - showHomeText = false - homeText = null - } - } - -// override fun onConfigurationChanged(newConfig: Configuration) { -// super.onConfigurationChanged(newConfig) -// configureForOrientation(newConfig) -// } -// -// private fun configureForOrientation(newConfig: Configuration) { -// val isPortrait = newConfig.orientation == Configuration.ORIENTATION_PORTRAIT -// -//// binding.coverFragment.orientation = if (isPortrait) LinearLayout.VERTICAL else LinearLayout.HORIZONTAL -// -// if (isPortrait) { -// binding.coverHolder.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) -//// binding.coverFragmentTextContainer.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) -// } else { -// binding.coverHolder.layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f) -//// binding.coverFragmentTextContainer.layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f) -// } -// -// (binding.episodeDetails.parent as ViewGroup).removeView(binding.episodeDetails) -// if (isPortrait) { -// binding.coverFragment.addView(binding.episodeDetails) -// } else { -// binding.coverFragmentTextContainer.addView(binding.episodeDetails) -// } -// } - - @UnstableApi private fun copyText(text: String): Boolean { - val clipboardManager: ClipboardManager? = ContextCompat.getSystemService(requireContext(), ClipboardManager::class.java) - clipboardManager?.setPrimaryClip(ClipData.newPlainText("Podcini", text)) - if (Build.VERSION.SDK_INT <= 32) { - (requireActivity() as MainActivity).showSnackbarAbovePlayer(resources.getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT) - } - return true - } - - companion object { - private val TAG: String = PlayerDetailsFragment::class.simpleName ?: "Anonymous" - - private const val PREF = "ItemDescriptionFragmentPrefs" - private const val PREF_SCROLL_Y = "prefScrollY" - private const val PREF_PLAYABLE_ID = "prefPlayableId" - - var prefs: SharedPreferences? = null - fun getSharedPrefs(context: Context) { - if (prefs == null) prefs = context.getSharedPreferences(PREF, Context.MODE_PRIVATE) - } - } -} diff --git a/changelog.md b/changelog.md index f1fb4b1e..93b79c29 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,12 @@ +# 6.5.9 + +* partially fixed an issue seen on Samsung Android 14 device where after playing the user-started episode, subsequent episode in the queue is not played in foreground service and there is not notification panel and can get stopped by the system. + * the current fix is though the subsequent episodes are still played without notification, the play is not stopped by the system. +* if videoMode of a feed is set to "audio only", + * press on icon in the player UI will expand the player detailed view (rather than video view) + * "show video" on the menu of AudioPlayer view is hidden +* some class restructuring + # 6.5.8 * corrected mis-behavior of speed settings for video media diff --git a/fastlane/metadata/android/en-US/changelogs/3020243.txt b/fastlane/metadata/android/en-US/changelogs/3020243.txt new file mode 100644 index 00000000..bbc92d6b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020243.txt @@ -0,0 +1,8 @@ + Version 6.5.9 brings several changes: + +* partially fixed an issue seen on Samsung Android 14 device where after playing the user-started episode, subsequent episode in the queue is not played in foreground service and there is not notification panel and can get stopped by the system. + * the current fix is though the subsequent episodes are still played without notification, the play is not stopped by the system. +* if videoMode of a feed is set to "audio only", + * press on icon in the player UI will expand the player detailed view (rather than video view) + * "show video" on the menu of AudioPlayer view is hidden +* some class restructuring diff --git a/gradle.properties b/gradle.properties index eb7344ce..eb92b871 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,5 +7,5 @@ android.nonFinalResIds=true org.gradle.caching=true org.gradle.jvmargs=-Xmx2048m - +kotlin.daemon.jvmargs=-Xmx1g org.gradle.configuration-cache=true