6.5.3 commit

This commit is contained in:
Xilin Jia 2024-09-03 21:19:19 +01:00
parent c29eb0bf1b
commit b7f17b934d
14 changed files with 966 additions and 1004 deletions

View File

@ -63,6 +63,9 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
* default video player mode setting in preferences * default video player mode setting in preferences
* when video mode is set to audio only, click on image on audio player on a video episode brings up the normal player detailed view * when video mode is set to audio only, click on image on audio player on a video episode brings up the normal player detailed view
* "Prefer streaming over download" is now on setting of individual feed * "Prefer streaming over download" is now on setting of individual feed
* added setting in individual feed to play audio only for video feeds,
* an added benefit for setting it enables Youtube media to only stream audio content, saving bandwidth.
* this differs from switching to "Audio only" on each episode, in which case, video is also streamed
* Multiple queues can be used: 5 queues are provided by default, user can rename or add up to 10 queues * Multiple queues can be used: 5 queues are provided by default, user can rename or add up to 10 queues
* on app startup, the most recently updated queue is set to curQueue * on app startup, the most recently updated queue is set to curQueue
* any episodes can be easily added/moved to the active or any designated queues * any episodes can be easily added/moved to the active or any designated queues
@ -100,10 +103,11 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
* on action bar of FeedEpisodes view there is a direct access to Queue * on action bar of FeedEpisodes view there is a direct access to Queue
* Long-press filter button in FeedEpisodes view enables/disables filters without changing filter settings * Long-press filter button in FeedEpisodes view enables/disables filters without changing filter settings
* History view shows time of last play, and allows filters and sorts * History view shows time of last play, and allows filters and sorts
### Podcast/Episode ### Podcast/Episode
* New share notes menu option on various episode views * New share notes menu option on various episode views
* Every feed can be associated with a queue allowing downloaded media to be added to the queue * Every feed (podcast) can be associated with a queue allowing downloaded media to be added to the queue
* FeedInfo view offers a link for direct search of feeds related to author * FeedInfo view offers a link for direct search of feeds related to author
* FeedInfo view has button showing number of episodes to open the FeedEpisodes view * FeedInfo view has button showing number of episodes to open the FeedEpisodes view
* FeedInfo view has feed setting in the header * FeedInfo view has feed setting in the header

View File

@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests" testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020236 versionCode 3020237
versionName "6.5.2" versionName "6.5.3"
applicationId "ac.mdiq.podcini.R" applicationId "ac.mdiq.podcini.R"
def commit = "" def commit = ""

View File

@ -1,35 +1,43 @@
package ac.mdiq.podcini.net.download package ac.mdiq.podcini.net.download
import ac.mdiq.podcini.net.download.service.PodciniHttpClient.UserAgentInterceptor
import ac.mdiq.podcini.storage.algorithms.InfoCache import ac.mdiq.podcini.storage.algorithms.InfoCache
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.config.ClientConfig
import ac.mdiq.vista.extractor.downloader.Downloader import ac.mdiq.vista.extractor.downloader.Downloader
import ac.mdiq.vista.extractor.downloader.Request import ac.mdiq.vista.extractor.downloader.Request
import ac.mdiq.vista.extractor.downloader.Response import ac.mdiq.vista.extractor.downloader.Response
import ac.mdiq.vista.extractor.exceptions.ReCaptchaException import ac.mdiq.vista.extractor.exceptions.ReCaptchaException
import android.content.Context import android.content.Context
import android.net.TrafficStats
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import okhttp3.Cache import okhttp3.Interceptor
import okhttp3.Interceptor.Chain
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request.Builder import okhttp3.Request.Builder
import okhttp3.RequestBody import okhttp3.RequestBody
import java.io.File
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.stream.Collectors import java.util.stream.Collectors
import java.util.stream.Stream import java.util.stream.Stream
class VistaDownloaderImpl private constructor(builder: OkHttpClient.Builder) : Downloader() { class VistaDownloaderImpl private constructor(val builder: OkHttpClient.Builder) : Downloader() {
private val mCookies: MutableMap<String, String> = HashMap() private val mCookies: MutableMap<String, String> = HashMap()
private val client: OkHttpClient = builder private val client: OkHttpClient
.readTimeout(30, TimeUnit.SECONDS) get() {
// .cache(Cache(File(context.getExternalCacheDir(), "okhttp"), 16 * 1024 * 1024)) builder.readTimeout(30, TimeUnit.SECONDS)
.build() builder.networkInterceptors().add(object : Interceptor {
override fun intercept(chain: Chain): okhttp3.Response {
TrafficStats.setThreadStatsTag(Thread.currentThread().id.toInt())
return chain.proceed(chain.request())
}
} )
return builder.build()
}
private fun getCookies(url: String): String { private fun getCookies(url: String): String {
val youtubeCookie = if (url.contains(YOUTUBE_DOMAIN)) getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) else null val youtubeCookie = if (url.contains(YOUTUBE_DOMAIN)) getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) else null
// Recaptcha cookie is always added TODO: not sure if this is necessary // Recaptcha cookie is always added TODO: not sure if this is necessary
return Stream.of(youtubeCookie, getCookie("recaptcha_cookies")) return Stream.of(youtubeCookie, getCookie("recaptcha_cookies"))
.filter { obj: String? -> Objects.nonNull(obj) } .filter { obj: String? -> Objects.nonNull(obj) }
@ -73,11 +81,8 @@ class VistaDownloaderImpl private constructor(builder: OkHttpClient.Builder) : D
try { try {
val response = head(url) val response = head(url)
return response.getHeader("Content-Length")!!.toLong() return response.getHeader("Content-Length")!!.toLong()
} catch (e: NumberFormatException) { } catch (e: NumberFormatException) { throw IOException("Invalid content length", e)
throw IOException("Invalid content length", e) } catch (e: ReCaptchaException) { throw IOException(e) }
} catch (e: ReCaptchaException) {
throw IOException(e)
}
} }
@Throws(IOException::class, ReCaptchaException::class) @Throws(IOException::class, ReCaptchaException::class)
@ -115,10 +120,8 @@ class VistaDownloaderImpl private constructor(builder: OkHttpClient.Builder) : D
response.close() response.close()
throw ReCaptchaException("reCaptcha Challenge requested", url) throw ReCaptchaException("reCaptcha Challenge requested", url)
} }
val body = response.body val body = response.body
val responseBodyToReturn: String? = body?.string() val responseBodyToReturn: String? = body?.string()
val latestUrl = response.request.url.toString() val latestUrl = response.request.url.toString()
return Response(response.code, response.message, response.headers.toMultimap(), responseBodyToReturn, latestUrl) return Response(response.code, response.message, response.headers.toMultimap(), responseBodyToReturn, latestUrl)
} catch (e: Throwable) { } catch (e: Throwable) {

View File

@ -144,20 +144,15 @@ object PodciniHttpClient {
init { init {
try { try {
var sslContext: SSLContext var sslContext: SSLContext
try { sslContext = SSLContext.getInstance("TLSv1.3")
try {
sslContext = SSLContext.getInstance("TLSv1.3")
} catch (e: NoSuchAlgorithmException) { } catch (e: NoSuchAlgorithmException) {
e.printStackTrace() e.printStackTrace()
// In the play flavor (security provider can vary), some devices only support TLSv1.2. // In the play flavor (security provider can vary), some devices only support TLSv1.2.
sslContext = SSLContext.getInstance("TLSv1.2") sslContext = SSLContext.getInstance("TLSv1.2")
} }
sslContext.init(null, arrayOf(trustManager), null) sslContext.init(null, arrayOf(trustManager), null)
factory = sslContext.socketFactory factory = sslContext.socketFactory
} catch (e: GeneralSecurityException) { } catch (e: GeneralSecurityException) { e.printStackTrace() }
e.printStackTrace()
}
} }
override fun getDefaultCipherSuites(): Array<String> { override fun getDefaultCipherSuites(): Array<String> {

View File

@ -352,8 +352,8 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
if (streamurl != null) { if (streamurl != null) {
val media = curMedia val media = curMedia
if (media is EpisodeMedia) { if (media is EpisodeMedia) {
val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) } val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) }
runBlocking { deferred.await() } if (startWhenPrepared) runBlocking { deferred.await() }
// val preferences = media.episodeOrFetch()?.feed?.preferences // val preferences = media.episodeOrFetch()?.feed?.preferences
// setDataSource(metadata, streamurl, preferences?.username, preferences?.password) // setDataSource(metadata, streamurl, preferences?.username, preferences?.password)
} else setDataSource(metadata, streamurl, null, null) } else setDataSource(metadata, streamurl, null, null)
@ -805,14 +805,16 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
} }
private fun initLoudnessEnhancer(audioStreamId: Int) { private fun initLoudnessEnhancer(audioStreamId: Int) {
val newEnhancer = LoudnessEnhancer(audioStreamId) runOnIOScope {
val oldEnhancer = loudnessEnhancer val newEnhancer = LoudnessEnhancer(audioStreamId)
if (oldEnhancer != null) { val oldEnhancer = loudnessEnhancer
newEnhancer.setEnabled(oldEnhancer.enabled) if (oldEnhancer != null) {
if (oldEnhancer.enabled) newEnhancer.setTargetGain(oldEnhancer.targetGain.toInt()) newEnhancer.setEnabled(oldEnhancer.enabled)
oldEnhancer.release() if (oldEnhancer.enabled) newEnhancer.setTargetGain(oldEnhancer.targetGain.toInt())
oldEnhancer.release()
}
loudnessEnhancer = newEnhancer
} }
loudnessEnhancer = newEnhancer
} }
fun cleanup() { fun cleanup() {

View File

@ -74,6 +74,7 @@ import android.os.Build.VERSION_CODES
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.KeyEvent.KEYCODE_MEDIA_STOP
import android.view.ViewConfiguration import android.view.ViewConfiguration
import android.webkit.URLUtil import android.webkit.URLUtil
import android.widget.Toast import android.widget.Toast
@ -579,7 +580,7 @@ class PlaybackService : MediaLibraryService() {
override fun onMediaButtonEvent(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, intent: Intent): Boolean { override fun onMediaButtonEvent(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, intent: Intent): Boolean {
val keyEvent = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) intent.extras!!.getParcelable(EXTRA_KEY_EVENT, KeyEvent::class.java) val keyEvent = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) intent.extras!!.getParcelable(EXTRA_KEY_EVENT, KeyEvent::class.java)
else intent.extras!!.getParcelable(EXTRA_KEY_EVENT) as? KeyEvent else intent.extras!!.getParcelable(EXTRA_KEY_EVENT) as? KeyEvent
Log.d(TAG, "onMediaButtonEvent ${keyEvent?.keyCode}") Logd(TAG, "onMediaButtonEvent ${keyEvent?.keyCode}")
if (keyEvent != null && keyEvent.action == KeyEvent.ACTION_DOWN && keyEvent.repeatCount == 0) { if (keyEvent != null && keyEvent.action == KeyEvent.ACTION_DOWN && keyEvent.repeatCount == 0) {
val keyCode = keyEvent.keyCode val keyCode = keyEvent.keyCode
@ -681,6 +682,7 @@ class PlaybackService : MediaLibraryService() {
} }
fun recreateMediaPlayer() { fun recreateMediaPlayer() {
Logd(TAG, "recreateMediaPlayer")
val media = curMedia val media = curMedia
var wasPlaying = false var wasPlaying = false
if (mPlayer != null) { if (mPlayer != null) {
@ -691,6 +693,7 @@ class PlaybackService : MediaLibraryService() {
mPlayer = CastPsmp.getInstanceIfConnected(this, mediaPlayerCallback) mPlayer = CastPsmp.getInstanceIfConnected(this, mediaPlayerCallback)
if (mPlayer == null) mPlayer = LocalMediaPlayer(applicationContext, mediaPlayerCallback) // Cast not supported or not connected if (mPlayer == null) mPlayer = LocalMediaPlayer(applicationContext, mediaPlayerCallback) // Cast not supported or not connected
Logd(TAG, "recreateMediaPlayer wasPlaying: $wasPlaying")
if (media != null) mPlayer!!.playMediaObject(media, !media.localFileAvailable(), wasPlaying, true) if (media != null) mPlayer!!.playMediaObject(media, !media.localFileAvailable(), wasPlaying, true)
isCasting = mPlayer!!.isCasting() isCasting = mPlayer!!.isCasting()
} }
@ -788,7 +791,7 @@ class PlaybackService : MediaLibraryService() {
handleKeycode(keycode, !hardwareButton) handleKeycode(keycode, !hardwareButton)
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
keyEvent != null && keyEvent.keyCode != -1 -> { keyEvent?.keyCode == KEYCODE_MEDIA_STOP -> {
Logd(TAG, "onStartCommand Received button event: ${keyEvent.keyCode}") Logd(TAG, "onStartCommand Received button event: ${keyEvent.keyCode}")
handleKeycode(keyEvent.keyCode, !hardwareButton) handleKeycode(keyEvent.keyCode, !hardwareButton)
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)

View File

@ -1,23 +1,88 @@
package ac.mdiq.podcini.ui.adapter package ac.mdiq.podcini.ui.adapter
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.EpisodeHomeFragmentBinding
import ac.mdiq.podcini.databinding.EpisodeInfoFragmentBinding
import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource
import ac.mdiq.podcini.net.utils.NetworkUtils.isEpisodeHeadDownloadAllowed
import ac.mdiq.podcini.playback.base.InTheatre
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo
import ac.mdiq.podcini.preferences.UsageStatistics
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.ui.actions.actionbutton.*
import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment
import ac.mdiq.podcini.ui.fragment.FeedInfoFragment import ac.mdiq.podcini.ui.fragment.FeedInfoFragment
import ac.mdiq.podcini.ui.utils.ShownotesCleaner
import ac.mdiq.podcini.ui.utils.ThemeUtils import ac.mdiq.podcini.ui.utils.ThemeUtils
import ac.mdiq.podcini.ui.view.EpisodeViewHolder import ac.mdiq.podcini.ui.view.EpisodeViewHolder
import ac.mdiq.podcini.ui.view.ShownotesWebView
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev
import ac.mdiq.podcini.util.MiscFormatter.formatForAccessibility
import android.R.color import android.R.color
import android.app.Activity import android.app.Activity
import android.content.Context
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.InputDevice import android.speech.tts.TextToSpeech
import android.view.MotionEvent import android.text.Layout
import android.view.View import android.text.TextUtils
import android.view.ViewGroup import android.text.format.Formatter.formatShortFileSize
import android.util.Log
import android.view.*
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ShareCompat
import androidx.core.text.HtmlCompat
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import coil.imageLoader
import coil.request.ErrorResult
import coil.request.ImageRequest
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.snackbar.Snackbar
import com.skydoves.balloon.ArrowOrientation
import com.skydoves.balloon.ArrowOrientationRules
import com.skydoves.balloon.Balloon
import com.skydoves.balloon.BalloonAnimation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.dankito.readability4j.extended.Readability4JExtended
import okhttp3.Request.Builder
import java.io.File
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.*
import kotlin.collections.ArrayList
import kotlin.math.max
/** /**
* List adapter for the list of new episodes. * List adapter for the list of new episodes.
@ -198,4 +263,740 @@ open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallbac
val item = if (index in episodes.indices) episodes[index] else null val item = if (index in episodes.indices) episodes[index] else null
return item return item
} }
/**
* Displays information about an Episode (FeedItem) and actions.
*/
@UnstableApi class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private var _binding: EpisodeInfoFragmentBinding? = null
private val binding get() = _binding!!
private var homeFragment: EpisodeHomeFragment? = null
private var itemLoaded = false
private var episode: Episode? = null // managed
private var webviewData: String? = null
private lateinit var shownotesCleaner: ShownotesCleaner
private lateinit var toolbar: MaterialToolbar
private lateinit var webvDescription: ShownotesWebView
private lateinit var imgvCover: ImageView
private lateinit var butAction1: ImageView
private lateinit var butAction2: ImageView
private var actionButton1: EpisodeActionButton? = null
private var actionButton2: EpisodeActionButton? = null
@UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
_binding = EpisodeInfoFragmentBinding.inflate(inflater, container, false)
Logd(TAG, "fragment onCreateView")
toolbar = binding.toolbar
toolbar.title = ""
toolbar.inflateMenu(R.menu.feeditem_options)
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
toolbar.setOnMenuItemClickListener(this)
binding.txtvPodcast.setOnClickListener { openPodcast() }
binding.txtvTitle.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL)
binding.txtvTitle.ellipsize = TextUtils.TruncateAt.END
webvDescription = binding.webvDescription
webvDescription.setTimecodeSelectedListener { time: Int? ->
val cMedia = curMedia
if (episode?.media?.getIdentifier() == cMedia?.getIdentifier()) seekTo(time ?: 0)
else (activity as MainActivity).showSnackbarAbovePlayer(R.string.play_this_to_seek_position, Snackbar.LENGTH_LONG)
}
registerForContextMenu(webvDescription)
imgvCover = binding.imgvCover
imgvCover.setOnClickListener { openPodcast() }
butAction1 = binding.butAction1
butAction2 = binding.butAction2
binding.homeButton.setOnClickListener {
if (!episode?.link.isNullOrEmpty()) {
homeFragment = EpisodeHomeFragment.newInstance(episode!!)
(activity as MainActivity).loadChildFragment(homeFragment!!)
} else Toast.makeText(context, "Episode link is not valid ${episode?.link}", Toast.LENGTH_LONG).show()
}
butAction1.setOnClickListener(View.OnClickListener {
when {
actionButton1 is StreamActionButton && !UserPreferences.isStreamOverDownload
&& UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_STREAM) -> {
showOnDemandConfigBalloon(true)
return@OnClickListener
}
actionButton1 == null -> return@OnClickListener // Not loaded yet
else -> actionButton1?.onClick(requireContext())
}
})
butAction2.setOnClickListener(View.OnClickListener {
when {
actionButton2 is DownloadActionButton && UserPreferences.isStreamOverDownload
&& UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_DOWNLOAD) -> {
showOnDemandConfigBalloon(false)
return@OnClickListener
}
actionButton2 == null -> return@OnClickListener // Not loaded yet
else -> actionButton2?.onClick(requireContext())
}
})
shownotesCleaner = ShownotesCleaner(requireContext())
load()
return binding.root
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
@OptIn(UnstableApi::class)
private fun showOnDemandConfigBalloon(offerStreaming: Boolean) {
val isLocaleRtl = (TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL)
val balloon: Balloon = Balloon.Builder(requireContext())
.setArrowOrientation(ArrowOrientation.TOP)
.setArrowOrientationRules(ArrowOrientationRules.ALIGN_FIXED)
.setArrowPosition(0.25f + (if (isLocaleRtl xor offerStreaming) 0f else 0.5f))
.setWidthRatio(1.0f)
.setMarginLeft(8)
.setMarginRight(8)
.setBackgroundColor(ThemeUtils.getColorFromAttr(requireContext(), com.google.android.material.R.attr.colorSecondary))
.setBalloonAnimation(BalloonAnimation.OVERSHOOT)
.setLayout(R.layout.popup_bubble_view)
.setDismissWhenTouchOutside(true)
.setLifecycleOwner(this)
.build()
val ballonView = balloon.getContentView()
val positiveButton: Button = ballonView.findViewById(R.id.balloon_button_positive)
val negativeButton: Button = ballonView.findViewById(R.id.balloon_button_negative)
val message: TextView = ballonView.findViewById(R.id.balloon_message)
message.setText(if (offerStreaming) R.string.on_demand_config_stream_text
else R.string.on_demand_config_download_text)
positiveButton.setOnClickListener {
UserPreferences.isStreamOverDownload = offerStreaming
// Update all visible lists to reflect new streaming action button
// TODO: need another event type?
EventFlow.postEvent(FlowEvent.EpisodePlayedEvent())
(activity as MainActivity).showSnackbarAbovePlayer(R.string.on_demand_config_setting_changed, Snackbar.LENGTH_SHORT)
balloon.dismiss()
}
negativeButton.setOnClickListener {
UsageStatistics.doNotAskAgain(UsageStatistics.ACTION_STREAM) // Type does not matter. Both are silenced.
balloon.dismiss()
}
balloon.showAlignBottom(butAction1, 0, (-12 * resources.displayMetrics.density).toInt())
}
@UnstableApi override fun onMenuItemClick(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.share_notes -> {
if (episode == null) return false
val notes = episode!!.description
if (!notes.isNullOrEmpty()) {
val shareText = if (Build.VERSION.SDK_INT >= 24) HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString()
else HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString()
val context = requireContext()
val intent = ShareCompat.IntentBuilder(context)
.setType("text/plain")
.setText(shareText)
.setChooserTitle(R.string.share_notes_label)
.createChooserIntent()
context.startActivity(intent)
}
return true
}
else -> {
if (episode == null) return false
return EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, episode!!)
}
}
}
@UnstableApi override fun onResume() {
super.onResume()
if (itemLoaded) {
binding.progbarLoading.visibility = View.GONE
updateAppearance()
}
}
@OptIn(UnstableApi::class) override fun onDestroyView() {
Logd(TAG, "onDestroyView")
binding.root.removeView(webvDescription)
episode = null
webvDescription.clearHistory()
webvDescription.clearCache(true)
webvDescription.clearView()
webvDescription.destroy()
_binding = null
super.onDestroyView()
}
@UnstableApi private fun onFragmentLoaded() {
if (webviewData != null && !itemLoaded)
webvDescription.loadDataWithBaseURL("https://127.0.0.1", webviewData!!, "text/html", "utf-8", "about:blank")
// if (item?.link != null) binding.webView.loadUrl(item!!.link!!)
updateAppearance()
}
private fun prepareMenu() {
if (episode!!.media != null) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast)
// these are already available via button1 and button2
else EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast, R.id.mark_read_item, R.id.visit_website_item)
}
@UnstableApi private fun updateAppearance() {
if (episode == null) {
Logd(TAG, "updateAppearance item is null")
return
}
prepareMenu()
if (episode!!.feed != null) binding.txtvPodcast.text = episode!!.feed!!.title
binding.txtvTitle.text = episode!!.title
binding.itemLink.text = episode!!.link
if (episode?.pubDate != null) {
val pubDateStr = formatAbbrev(context, Date(episode!!.pubDate))
binding.txtvPublished.text = pubDateStr
binding.txtvPublished.setContentDescription(formatForAccessibility(Date(episode!!.pubDate)))
}
val media = episode?.media
when {
media == null -> binding.txtvSize.text = ""
media.size > 0 -> binding.txtvSize.text = formatShortFileSize(activity, media.size)
isEpisodeHeadDownloadAllowed && !media.checkedOnSizeButUnknown() -> {
binding.txtvSize.text = "{faw_spinner}"
// Iconify.addIcons(size)
lifecycleScope.launch {
val sizeValue = getMediaSize(episode)
if (sizeValue <= 0) binding.txtvSize.text = ""
else binding.txtvSize.text = formatShortFileSize(activity, sizeValue)
}
}
else -> binding.txtvSize.text = ""
}
val imgLocFB = ImageResourceUtils.getFallbackImageLocation(episode!!)
val imageLoader = imgvCover.context.imageLoader
val imageRequest = ImageRequest.Builder(requireContext())
.data(episode!!.imageLocation)
.placeholder(R.color.light_gray)
.listener(object : ImageRequest.Listener {
override fun onError(request: ImageRequest, result: ErrorResult) {
val fallbackImageRequest = ImageRequest.Builder(requireContext())
.data(imgLocFB)
.setHeader("User-Agent", "Mozilla/5.0")
.error(R.mipmap.ic_launcher)
.target(imgvCover)
.build()
imageLoader.enqueue(fallbackImageRequest)
}
})
.target(imgvCover)
.build()
imageLoader.enqueue(imageRequest)
updateButtons()
}
@UnstableApi private fun updateButtons() {
binding.circularProgressBar.visibility = View.GONE
val dls = DownloadServiceInterface.get()
if (episode != null && episode!!.media != null && episode!!.media!!.downloadUrl != null) {
val url = episode!!.media!!.downloadUrl!!
if (dls != null && dls.isDownloadingEpisode(url)) {
binding.circularProgressBar.visibility = View.VISIBLE
binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), episode)
binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url))
}
}
val media: EpisodeMedia? = episode?.media
if (media == null) {
if (episode != null) {
// actionButton1 = VisitWebsiteActionButton(item!!)
butAction1.visibility = View.INVISIBLE
actionButton2 = VisitWebsiteActionButton(episode!!)
}
binding.noMediaLabel.visibility = View.VISIBLE
} else {
binding.noMediaLabel.visibility = View.GONE
if (media.getDuration() > 0) {
binding.txtvDuration.text = DurationConverter.getDurationStringLong(media.getDuration())
binding.txtvDuration.setContentDescription(DurationConverter.getDurationStringLocalized(requireContext(), media.getDuration().toLong()))
}
if (episode != null) {
actionButton1 = when {
media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode!!)
InTheatre.isCurrentlyPlaying(media) -> PauseActionButton(episode!!)
episode!!.feed != null && episode!!.feed!!.isLocalFeed -> PlayLocalActionButton(episode!!)
media.downloaded -> PlayActionButton(episode!!)
else -> StreamActionButton(episode!!)
}
actionButton2 = when {
media.getMediaType() == MediaType.FLASH || 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
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

@ -1,289 +0,0 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.EpisodeHomeFragmentBinding
import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.ui.utils.ShownotesCleaner
import ac.mdiq.podcini.util.Logd
import android.content.Context
import android.os.Bundle
import android.speech.tts.TextToSpeech
import android.util.Log
import android.view.*
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.annotation.OptIn
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.media3.common.util.UnstableApi
import com.google.android.material.appbar.MaterialToolbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.dankito.readability4j.extended.Readability4JExtended
import java.util.*
/**
* 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
}
}
}

View File

@ -1,543 +0,0 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.EpisodeInfoFragmentBinding
import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.net.utils.NetworkUtils.isEpisodeHeadDownloadAllowed
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
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.unmanaged
import ac.mdiq.podcini.storage.database.RealmDB.upsert
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.model.MediaType
import ac.mdiq.podcini.ui.actions.actionbutton.*
import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler
import ac.mdiq.podcini.ui.activity.MainActivity
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.storage.utils.DurationConverter
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev
import ac.mdiq.podcini.util.MiscFormatter.formatForAccessibility
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.os.Build
import android.os.Bundle
import android.text.Layout
import android.text.TextUtils
import android.text.format.Formatter.formatShortFileSize
import android.util.Log
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.annotation.OptIn
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ShareCompat
import androidx.core.text.HtmlCompat
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.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 okhttp3.Request.Builder
import java.io.File
import java.util.*
import kotlin.math.max
/**
* Displays information about an Episode (FeedItem) and actions.
*/
@UnstableApi class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private var _binding: EpisodeInfoFragmentBinding? = null
private val binding get() = _binding!!
private var homeFragment: EpisodeHomeFragment? = null
private var itemLoaded = false
private var episode: Episode? = null // managed
private var webviewData: String? = null
private lateinit var shownotesCleaner: ShownotesCleaner
private lateinit var toolbar: MaterialToolbar
private lateinit var webvDescription: ShownotesWebView
private lateinit var imgvCover: ImageView
private lateinit var butAction1: ImageView
private lateinit var butAction2: ImageView
private var actionButton1: EpisodeActionButton? = null
private var actionButton2: EpisodeActionButton? = null
@UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
_binding = EpisodeInfoFragmentBinding.inflate(inflater, container, false)
Logd(TAG, "fragment onCreateView")
toolbar = binding.toolbar
toolbar.title = ""
toolbar.inflateMenu(R.menu.feeditem_options)
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
toolbar.setOnMenuItemClickListener(this)
binding.txtvPodcast.setOnClickListener { openPodcast() }
binding.txtvTitle.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL)
binding.txtvTitle.ellipsize = TextUtils.TruncateAt.END
webvDescription = binding.webvDescription
webvDescription.setTimecodeSelectedListener { time: Int? ->
val cMedia = curMedia
if (episode?.media?.getIdentifier() == cMedia?.getIdentifier()) seekTo(time ?: 0)
else (activity as MainActivity).showSnackbarAbovePlayer(R.string.play_this_to_seek_position, Snackbar.LENGTH_LONG)
}
registerForContextMenu(webvDescription)
imgvCover = binding.imgvCover
imgvCover.setOnClickListener { openPodcast() }
butAction1 = binding.butAction1
butAction2 = binding.butAction2
binding.homeButton.setOnClickListener {
if (!episode?.link.isNullOrEmpty()) {
homeFragment = EpisodeHomeFragment.newInstance(episode!!)
(activity as MainActivity).loadChildFragment(homeFragment!!)
} else Toast.makeText(context, "Episode link is not valid ${episode?.link}", Toast.LENGTH_LONG).show()
}
butAction1.setOnClickListener(View.OnClickListener {
when {
actionButton1 is StreamActionButton && !UserPreferences.isStreamOverDownload
&& UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_STREAM) -> {
showOnDemandConfigBalloon(true)
return@OnClickListener
}
actionButton1 == null -> return@OnClickListener // Not loaded yet
else -> actionButton1?.onClick(requireContext())
}
})
butAction2.setOnClickListener(View.OnClickListener {
when {
actionButton2 is DownloadActionButton && UserPreferences.isStreamOverDownload
&& UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_DOWNLOAD) -> {
showOnDemandConfigBalloon(false)
return@OnClickListener
}
actionButton2 == null -> return@OnClickListener // Not loaded yet
else -> actionButton2?.onClick(requireContext())
}
})
shownotesCleaner = ShownotesCleaner(requireContext())
load()
return binding.root
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
@OptIn(UnstableApi::class)
private fun showOnDemandConfigBalloon(offerStreaming: Boolean) {
val isLocaleRtl = (TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL)
val balloon: Balloon = Balloon.Builder(requireContext())
.setArrowOrientation(ArrowOrientation.TOP)
.setArrowOrientationRules(ArrowOrientationRules.ALIGN_FIXED)
.setArrowPosition(0.25f + (if (isLocaleRtl xor offerStreaming) 0f else 0.5f))
.setWidthRatio(1.0f)
.setMarginLeft(8)
.setMarginRight(8)
.setBackgroundColor(ThemeUtils.getColorFromAttr(requireContext(), com.google.android.material.R.attr.colorSecondary))
.setBalloonAnimation(BalloonAnimation.OVERSHOOT)
.setLayout(R.layout.popup_bubble_view)
.setDismissWhenTouchOutside(true)
.setLifecycleOwner(this)
.build()
val ballonView = balloon.getContentView()
val positiveButton: Button = ballonView.findViewById(R.id.balloon_button_positive)
val negativeButton: Button = ballonView.findViewById(R.id.balloon_button_negative)
val message: TextView = ballonView.findViewById(R.id.balloon_message)
message.setText(if (offerStreaming) R.string.on_demand_config_stream_text
else R.string.on_demand_config_download_text)
positiveButton.setOnClickListener {
UserPreferences.isStreamOverDownload = offerStreaming
// Update all visible lists to reflect new streaming action button
// TODO: need another event type?
EventFlow.postEvent(FlowEvent.EpisodePlayedEvent())
(activity as MainActivity).showSnackbarAbovePlayer(R.string.on_demand_config_setting_changed, Snackbar.LENGTH_SHORT)
balloon.dismiss()
}
negativeButton.setOnClickListener {
UsageStatistics.doNotAskAgain(UsageStatistics.ACTION_STREAM) // Type does not matter. Both are silenced.
balloon.dismiss()
}
balloon.showAlignBottom(butAction1, 0, (-12 * resources.displayMetrics.density).toInt())
}
@UnstableApi override fun onMenuItemClick(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.share_notes -> {
if (episode == null) return false
val notes = episode!!.description
if (!notes.isNullOrEmpty()) {
val shareText = if (Build.VERSION.SDK_INT >= 24) HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString()
else HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString()
val context = requireContext()
val intent = ShareCompat.IntentBuilder(context)
.setType("text/plain")
.setText(shareText)
.setChooserTitle(R.string.share_notes_label)
.createChooserIntent()
context.startActivity(intent)
}
return true
}
else -> {
if (episode == null) return false
return EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, episode!!)
}
}
}
@UnstableApi override fun onResume() {
super.onResume()
if (itemLoaded) {
binding.progbarLoading.visibility = View.GONE
updateAppearance()
}
}
@OptIn(UnstableApi::class) override fun onDestroyView() {
Logd(TAG, "onDestroyView")
binding.root.removeView(webvDescription)
episode = null
webvDescription.clearHistory()
webvDescription.clearCache(true)
webvDescription.clearView()
webvDescription.destroy()
_binding = null
super.onDestroyView()
}
@UnstableApi private fun onFragmentLoaded() {
if (webviewData != null && !itemLoaded)
webvDescription.loadDataWithBaseURL("https://127.0.0.1", webviewData!!, "text/html", "utf-8", "about:blank")
// if (item?.link != null) binding.webView.loadUrl(item!!.link!!)
updateAppearance()
}
private fun prepareMenu() {
if (episode!!.media != null) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast)
// these are already available via button1 and button2
else EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast, R.id.mark_read_item, R.id.visit_website_item)
}
@UnstableApi private fun updateAppearance() {
if (episode == null) {
Logd(TAG, "updateAppearance item is null")
return
}
prepareMenu()
if (episode!!.feed != null) binding.txtvPodcast.text = episode!!.feed!!.title
binding.txtvTitle.text = episode!!.title
binding.itemLink.text = episode!!.link
if (episode?.pubDate != null) {
val pubDateStr = formatAbbrev(context, Date(episode!!.pubDate))
binding.txtvPublished.text = pubDateStr
binding.txtvPublished.setContentDescription(formatForAccessibility(Date(episode!!.pubDate)))
}
val media = episode?.media
when {
media == null -> binding.txtvSize.text = ""
media.size > 0 -> binding.txtvSize.text = formatShortFileSize(activity, media.size)
isEpisodeHeadDownloadAllowed && !media.checkedOnSizeButUnknown() -> {
binding.txtvSize.text = "{faw_spinner}"
// Iconify.addIcons(size)
lifecycleScope.launch {
val sizeValue = getMediaSize(episode)
if (sizeValue <= 0) binding.txtvSize.text = ""
else binding.txtvSize.text = formatShortFileSize(activity, sizeValue)
}
}
else -> binding.txtvSize.text = ""
}
val imgLocFB = ImageResourceUtils.getFallbackImageLocation(episode!!)
val imageLoader = imgvCover.context.imageLoader
val imageRequest = ImageRequest.Builder(requireContext())
.data(episode!!.imageLocation)
.placeholder(R.color.light_gray)
.listener(object : ImageRequest.Listener {
override fun onError(request: ImageRequest, result: ErrorResult) {
val fallbackImageRequest = ImageRequest.Builder(requireContext())
.data(imgLocFB)
.setHeader("User-Agent", "Mozilla/5.0")
.error(R.mipmap.ic_launcher)
.target(imgvCover)
.build()
imageLoader.enqueue(fallbackImageRequest)
}
})
.target(imgvCover)
.build()
imageLoader.enqueue(imageRequest)
updateButtons()
}
@UnstableApi private fun updateButtons() {
binding.circularProgressBar.visibility = View.GONE
val dls = DownloadServiceInterface.get()
if (episode != null && episode!!.media != null && episode!!.media!!.downloadUrl != null) {
val url = episode!!.media!!.downloadUrl!!
if (dls != null && dls.isDownloadingEpisode(url)) {
binding.circularProgressBar.visibility = View.VISIBLE
binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), episode)
binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url))
}
}
val media: EpisodeMedia? = episode?.media
if (media == null) {
if (episode != null) {
// actionButton1 = VisitWebsiteActionButton(item!!)
butAction1.visibility = View.INVISIBLE
actionButton2 = VisitWebsiteActionButton(episode!!)
}
binding.noMediaLabel.visibility = View.VISIBLE
} else {
binding.noMediaLabel.visibility = View.GONE
if (media.getDuration() > 0) {
binding.txtvDuration.text = DurationConverter.getDurationStringLong(media.getDuration())
binding.txtvDuration.setContentDescription(DurationConverter.getDurationStringLocalized(requireContext(), media.getDuration().toLong()))
}
if (episode != null) {
actionButton1 = when {
media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode!!)
InTheatre.isCurrentlyPlaying(media) -> PauseActionButton(episode!!)
episode!!.feed != null && episode!!.feed!!.isLocalFeed -> PlayLocalActionButton(episode!!)
media.downloaded -> PlayActionButton(episode!!)
else -> StreamActionButton(episode!!)
}
actionButton2 = when {
media.getMediaType() == MediaType.FLASH || 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
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_
}
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

@ -48,6 +48,7 @@ import android.text.SpannableString
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
@ -73,6 +74,7 @@ import java.io.IOException
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.math.min
/** /**
* Downloads a feed from a feed URL and parses it. Subclasses can display the * Downloads a feed from a feed URL and parses it. Subclasses can display the
@ -212,7 +214,7 @@ class OnlineFeedViewFragment : Fragment() {
val results = searcher.search(query) val results = searcher.search(query)
if (results.isEmpty()) return@launch if (results.isEmpty()) return@launch
for (result in results) { for (result in results) {
if (result?.feedUrl != null && result.author != null && result.author.equals(error.artistName, ignoreCase = true) if (result.feedUrl != null && result.author != null && result.author.equals(error.artistName, ignoreCase = true)
&& result.title.equals(error.trackName, ignoreCase = true)) { && result.title.equals(error.trackName, ignoreCase = true)) {
url = result.feedUrl url = result.feedUrl
break break
@ -268,9 +270,8 @@ class OnlineFeedViewFragment : Fragment() {
val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs.first()) val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs.first())
Logd(TAG, "startFeedBuilding result1: $channelTabInfo ${channelTabInfo.relatedItems.size}") Logd(TAG, "startFeedBuilding result1: $channelTabInfo ${channelTabInfo.relatedItems.size}")
selectedDownloadUrl = prepareUrl(url) selectedDownloadUrl = prepareUrl(url)
// selectedDownloadUrl = url
val feed_ = Feed(selectedDownloadUrl, null) val feed_ = Feed(selectedDownloadUrl, null)
feed_.id = 1234567889L feed_.id = Feed.newId()
feed_.type = Feed.FeedType.YOUTUBE.name feed_.type = Feed.FeedType.YOUTUBE.name
feed_.hasVideoMedia = true feed_.hasVideoMedia = true
feed_.title = channelInfo.name feed_.title = channelInfo.name
@ -472,6 +473,7 @@ class OnlineFeedViewFragment : Fragment() {
feed.id = 0L feed.id = 0L
for (item in feed.episodes) { for (item in feed.episodes) {
item.id = 0L item.id = 0L
item.media?.id = 0L
item.feedId = null item.feedId = null
item.feed = feed item.feed = feed
val media = item.media val media = item.media
@ -529,8 +531,10 @@ class OnlineFeedViewFragment : Fragment() {
Logd(TAG, "showEpisodes ${episodes.size}") Logd(TAG, "showEpisodes ${episodes.size}")
if (episodes.isEmpty()) return if (episodes.isEmpty()) return
episodes.sortByDescending { it.pubDate } episodes.sortByDescending { it.pubDate }
var id_ = Feed.newId()
for (i in 0..<episodes.size) { for (i in 0..<episodes.size) {
episodes[i].id = 1234567890L + i episodes[i].id = id_++
episodes[i].media?.id = episodes[i].id
} }
val fragment: Fragment = RemoteEpisodesFragment.newInstance(episodes) val fragment: Fragment = RemoteEpisodesFragment.newInstance(episodes)
(activity as MainActivity).loadChildFragment(fragment) (activity as MainActivity).loadChildFragment(fragment)
@ -735,6 +739,97 @@ class OnlineFeedViewFragment : Fragment() {
} }
} }
/**
* Shows all episodes (possibly filtered by user).
*/
@UnstableApi
class RemoteEpisodesFragment : BaseEpisodesFragment() {
private val episodeList: MutableList<Episode> = mutableListOf()
@UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val root = super.onCreateView(inflater, container, savedInstanceState)
Logd(TAG, "fragment onCreateView")
toolbar.inflateMenu(R.menu.episodes)
toolbar.setTitle(R.string.episodes_label)
updateToolbar()
adapter.setOnSelectModeListener(null)
return root
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
override fun onDestroyView() {
episodeList.clear()
super.onDestroyView()
}
fun setEpisodes(episodeList_: MutableList<Episode>) {
episodeList.clear()
episodeList.addAll(episodeList_)
}
override fun loadData(): List<Episode> {
if (episodeList.isEmpty()) return listOf()
return episodeList.subList(0, min(episodeList.size-1, page * EPISODES_PER_PAGE))
}
override fun loadMoreData(page: Int): List<Episode> {
val offset = (page - 1) * EPISODES_PER_PAGE
if (offset >= episodeList.size) return listOf()
val toIndex = offset + EPISODES_PER_PAGE
return episodeList.subList(offset, min(episodeList.size, toIndex))
}
override fun loadTotalItemCount(): Int {
return episodeList.size
}
override fun getPrefName(): String {
return PREF_NAME
}
override fun updateToolbar() {
binding.toolbar.menu.findItem(R.id.episodes_sort).setVisible(false)
// binding.toolbar.menu.findItem(R.id.refresh_item).setVisible(false)
binding.toolbar.menu.findItem(R.id.action_search).setVisible(false)
binding.toolbar.menu.findItem(R.id.action_favorites).setVisible(false)
binding.toolbar.menu.findItem(R.id.filter_items).setVisible(false)
}
@OptIn(UnstableApi::class) override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item)) return true
when (item.itemId) {
else -> return false
}
}
private var eventSink: Job? = null
private fun cancelFlowEvents() {
eventSink?.cancel()
eventSink = null
}
private fun procFlowEvents() {
if (eventSink != null) return
eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.AllEpisodesFilterEvent -> page = 1
else -> {}
}
}
}
}
companion object {
const val PREF_NAME: String = "EpisodesListFragment"
fun newInstance(episodes: MutableList<Episode>): RemoteEpisodesFragment {
val i = RemoteEpisodesFragment()
i.setEpisodes(episodes)
return i
}
}
}
companion object { companion object {
const val ARG_FEEDURL: String = "arg.feedurl" const val ARG_FEEDURL: String = "arg.feedurl"
const val ARG_WAS_MANUAL_URL: String = "manual_url" const val ARG_WAS_MANUAL_URL: String = "manual_url"

View File

@ -1,122 +0,0 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlin.math.min
/**
* Shows all episodes (possibly filtered by user).
*/
@UnstableApi
class RemoteEpisodesFragment : BaseEpisodesFragment() {
private val episodeList: MutableList<Episode> = mutableListOf()
@UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val root = super.onCreateView(inflater, container, savedInstanceState)
Logd(TAG, "fragment onCreateView")
toolbar.inflateMenu(R.menu.episodes)
toolbar.setTitle(R.string.episodes_label)
updateToolbar()
adapter.setOnSelectModeListener(null)
return root
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
override fun onDestroyView() {
episodeList.clear()
super.onDestroyView()
}
fun setEpisodes(episodeList_: MutableList<Episode>) {
episodeList.clear()
episodeList.addAll(episodeList_)
}
override fun loadData(): List<Episode> {
if (episodeList.isEmpty()) return listOf()
return episodeList.subList(0, min(episodeList.size-1, page * EPISODES_PER_PAGE))
}
override fun loadMoreData(page: Int): List<Episode> {
val offset = (page - 1) * EPISODES_PER_PAGE
if (offset >= episodeList.size) return listOf()
val toIndex = offset + EPISODES_PER_PAGE
return episodeList.subList(offset, min(episodeList.size, toIndex))
}
override fun loadTotalItemCount(): Int {
return episodeList.size
}
override fun getPrefName(): String {
return PREF_NAME
}
override fun updateToolbar() {
binding.toolbar.menu.findItem(R.id.episodes_sort).setVisible(false)
// binding.toolbar.menu.findItem(R.id.refresh_item).setVisible(false)
binding.toolbar.menu.findItem(R.id.action_search).setVisible(false)
binding.toolbar.menu.findItem(R.id.action_favorites).setVisible(false)
binding.toolbar.menu.findItem(R.id.filter_items).setVisible(false)
}
@OptIn(UnstableApi::class) override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item)) return true
when (item.itemId) {
else -> return false
}
}
private var eventSink: Job? = null
private fun cancelFlowEvents() {
eventSink?.cancel()
eventSink = null
}
private fun procFlowEvents() {
if (eventSink != null) return
eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.AllEpisodesFilterEvent -> page = 1
else -> {}
}
}
}
}
companion object {
const val PREF_NAME: String = "EpisodesListFragment"
fun newInstance(episodes: MutableList<Episode>): RemoteEpisodesFragment {
val i = RemoteEpisodesFragment()
i.setEpisodes(episodes)
return i
}
}
}

View File

@ -95,7 +95,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
} }
if (videoControlsShowing) { if (videoControlsShowing) {
hideVideoControls(false) hideVideoControls(false)
if (videoMode == VideoplayerActivity.VideoMode.FULL_SCREEN_VIEW) (activity as AppCompatActivity).supportActionBar?.hide() if (videoMode == VideoMode.FULL_SCREEN_VIEW) (activity as AppCompatActivity).supportActionBar?.hide()
videoControlsShowing = false videoControlsShowing = false
} }
return@OnTouchListener true return@OnTouchListener true
@ -135,7 +135,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
if (videoControlsShowing) { if (videoControlsShowing) {
Logd(TAG, "Hiding video controls") Logd(TAG, "Hiding video controls")
hideVideoControls(true) hideVideoControls(true)
if (videoMode == VideoplayerActivity.VideoMode.FULL_SCREEN_VIEW) (activity as? AppCompatActivity)?.supportActionBar?.hide() if (videoMode == VideoMode.FULL_SCREEN_VIEW) (activity as? AppCompatActivity)?.supportActionBar?.hide()
videoControlsShowing = false videoControlsShowing = false
} }
} }
@ -398,7 +398,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
fun toggleVideoControlsVisibility() { fun toggleVideoControlsVisibility() {
if (videoControlsShowing) { if (videoControlsShowing) {
hideVideoControls(true) hideVideoControls(true)
if (videoMode == VideoplayerActivity.VideoMode.FULL_SCREEN_VIEW) { if (videoMode == VideoMode.FULL_SCREEN_VIEW) {
(activity as AppCompatActivity).supportActionBar?.hide() (activity as AppCompatActivity).supportActionBar?.hide()
} }
} else { } else {

View File

@ -1,6 +1,13 @@
# 6.5.3
* properly assigning ids to remote episodes in OnlineFeedView to resolve the issue of duplicates
* fixed possible startup hang when previous media was Youtube media
* the fixed for random starts in 6.4.0 conflicts with notification play/pause button, narrowed handling to only KEYCODE_MEDIA_STOP
* some fragment class restructuring
# 6.5.2 # 6.5.2
* replace all url of http to https * replaced all url of http to https
* resolved the nasty issue of Youtube media not properly played in release app * resolved the nasty issue of Youtube media not properly played in release app
# 6.5.1 # 6.5.1

View File

@ -0,0 +1,6 @@
Version 6.5.3 brings several changes:
* properly assigning ids to remote episodes in OnlineFeedView to resolve the issue of duplicates
* fixed possible startup hang when previous media was Youtube media
* the fixed for random starts in 6.4.0 conflicts with notification play/pause button, narrowed handling to only KEYCODE_MEDIA_STOP
* some fragment class restructuring