6.5.3 commit
This commit is contained in:
parent
c29eb0bf1b
commit
b7f17b934d
|
@ -63,6 +63,9 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
|
|||
* 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
|
||||
* "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
|
||||
* 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
|
||||
|
@ -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
|
||||
* 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
|
||||
|
||||
### Podcast/Episode
|
||||
|
||||
* 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 has button showing number of episodes to open the FeedEpisodes view
|
||||
* FeedInfo view has feed setting in the header
|
||||
|
|
|
@ -31,8 +31,8 @@ android {
|
|||
testApplicationId "ac.mdiq.podcini.tests"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
versionCode 3020236
|
||||
versionName "6.5.2"
|
||||
versionCode 3020237
|
||||
versionName "6.5.3"
|
||||
|
||||
applicationId "ac.mdiq.podcini.R"
|
||||
def commit = ""
|
||||
|
|
|
@ -1,35 +1,43 @@
|
|||
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.util.Logd
|
||||
import ac.mdiq.podcini.util.config.ClientConfig
|
||||
import ac.mdiq.vista.extractor.downloader.Downloader
|
||||
import ac.mdiq.vista.extractor.downloader.Request
|
||||
import ac.mdiq.vista.extractor.downloader.Response
|
||||
import ac.mdiq.vista.extractor.exceptions.ReCaptchaException
|
||||
import android.content.Context
|
||||
import android.net.TrafficStats
|
||||
import androidx.preference.PreferenceManager
|
||||
import okhttp3.Cache
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Interceptor.Chain
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request.Builder
|
||||
import okhttp3.RequestBody
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.stream.Collectors
|
||||
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 client: OkHttpClient = builder
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
// .cache(Cache(File(context.getExternalCacheDir(), "okhttp"), 16 * 1024 * 1024))
|
||||
.build()
|
||||
private val client: OkHttpClient
|
||||
get() {
|
||||
builder.readTimeout(30, TimeUnit.SECONDS)
|
||||
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 {
|
||||
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
|
||||
return Stream.of(youtubeCookie, getCookie("recaptcha_cookies"))
|
||||
.filter { obj: String? -> Objects.nonNull(obj) }
|
||||
|
@ -73,11 +81,8 @@ class VistaDownloaderImpl private constructor(builder: OkHttpClient.Builder) : D
|
|||
try {
|
||||
val response = head(url)
|
||||
return response.getHeader("Content-Length")!!.toLong()
|
||||
} catch (e: NumberFormatException) {
|
||||
throw IOException("Invalid content length", e)
|
||||
} catch (e: ReCaptchaException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
} catch (e: NumberFormatException) { throw IOException("Invalid content length", e)
|
||||
} catch (e: ReCaptchaException) { throw IOException(e) }
|
||||
}
|
||||
|
||||
@Throws(IOException::class, ReCaptchaException::class)
|
||||
|
@ -115,10 +120,8 @@ class VistaDownloaderImpl private constructor(builder: OkHttpClient.Builder) : D
|
|||
response.close()
|
||||
throw ReCaptchaException("reCaptcha Challenge requested", url)
|
||||
}
|
||||
|
||||
val body = response.body
|
||||
val responseBodyToReturn: String? = body?.string()
|
||||
|
||||
val latestUrl = response.request.url.toString()
|
||||
return Response(response.code, response.message, response.headers.toMultimap(), responseBodyToReturn, latestUrl)
|
||||
} catch (e: Throwable) {
|
||||
|
|
|
@ -144,20 +144,15 @@ object PodciniHttpClient {
|
|||
init {
|
||||
try {
|
||||
var sslContext: SSLContext
|
||||
|
||||
try {
|
||||
sslContext = SSLContext.getInstance("TLSv1.3")
|
||||
try { sslContext = SSLContext.getInstance("TLSv1.3")
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
e.printStackTrace()
|
||||
// In the play flavor (security provider can vary), some devices only support TLSv1.2.
|
||||
sslContext = SSLContext.getInstance("TLSv1.2")
|
||||
}
|
||||
|
||||
sslContext.init(null, arrayOf(trustManager), null)
|
||||
factory = sslContext.socketFactory
|
||||
} catch (e: GeneralSecurityException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
} catch (e: GeneralSecurityException) { e.printStackTrace() }
|
||||
}
|
||||
|
||||
override fun getDefaultCipherSuites(): Array<String> {
|
||||
|
|
|
@ -352,8 +352,8 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
|||
if (streamurl != null) {
|
||||
val media = curMedia
|
||||
if (media is EpisodeMedia) {
|
||||
val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) }
|
||||
runBlocking { deferred.await() }
|
||||
val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) }
|
||||
if (startWhenPrepared) runBlocking { deferred.await() }
|
||||
// val preferences = media.episodeOrFetch()?.feed?.preferences
|
||||
// setDataSource(metadata, streamurl, preferences?.username, preferences?.password)
|
||||
} else setDataSource(metadata, streamurl, null, null)
|
||||
|
@ -805,14 +805,16 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
|||
}
|
||||
|
||||
private fun initLoudnessEnhancer(audioStreamId: Int) {
|
||||
val newEnhancer = LoudnessEnhancer(audioStreamId)
|
||||
val oldEnhancer = loudnessEnhancer
|
||||
if (oldEnhancer != null) {
|
||||
newEnhancer.setEnabled(oldEnhancer.enabled)
|
||||
if (oldEnhancer.enabled) newEnhancer.setTargetGain(oldEnhancer.targetGain.toInt())
|
||||
oldEnhancer.release()
|
||||
runOnIOScope {
|
||||
val newEnhancer = LoudnessEnhancer(audioStreamId)
|
||||
val oldEnhancer = loudnessEnhancer
|
||||
if (oldEnhancer != null) {
|
||||
newEnhancer.setEnabled(oldEnhancer.enabled)
|
||||
if (oldEnhancer.enabled) newEnhancer.setTargetGain(oldEnhancer.targetGain.toInt())
|
||||
oldEnhancer.release()
|
||||
}
|
||||
loudnessEnhancer = newEnhancer
|
||||
}
|
||||
loudnessEnhancer = newEnhancer
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
|
|
|
@ -74,6 +74,7 @@ import android.os.Build.VERSION_CODES
|
|||
import android.service.quicksettings.TileService
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.KeyEvent.KEYCODE_MEDIA_STOP
|
||||
import android.view.ViewConfiguration
|
||||
import android.webkit.URLUtil
|
||||
import android.widget.Toast
|
||||
|
@ -579,7 +580,7 @@ class PlaybackService : MediaLibraryService() {
|
|||
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)
|
||||
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) {
|
||||
val keyCode = keyEvent.keyCode
|
||||
|
@ -681,6 +682,7 @@ class PlaybackService : MediaLibraryService() {
|
|||
}
|
||||
|
||||
fun recreateMediaPlayer() {
|
||||
Logd(TAG, "recreateMediaPlayer")
|
||||
val media = curMedia
|
||||
var wasPlaying = false
|
||||
if (mPlayer != null) {
|
||||
|
@ -691,6 +693,7 @@ class PlaybackService : MediaLibraryService() {
|
|||
mPlayer = CastPsmp.getInstanceIfConnected(this, mediaPlayerCallback)
|
||||
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)
|
||||
isCasting = mPlayer!!.isCasting()
|
||||
}
|
||||
|
@ -788,7 +791,7 @@ class PlaybackService : MediaLibraryService() {
|
|||
handleKeycode(keycode, !hardwareButton)
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
keyEvent != null && keyEvent.keyCode != -1 -> {
|
||||
keyEvent?.keyCode == KEYCODE_MEDIA_STOP -> {
|
||||
Logd(TAG, "onStartCommand Received button event: ${keyEvent.keyCode}")
|
||||
handleKeycode(keyEvent.keyCode, !hardwareButton)
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
|
|
|
@ -1,23 +1,88 @@
|
|||
package ac.mdiq.podcini.ui.adapter
|
||||
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.databinding.EpisodeHomeFragmentBinding
|
||||
import ac.mdiq.podcini.databinding.EpisodeInfoFragmentBinding
|
||||
import ac.mdiq.podcini.net.download.service.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.EpisodeMedia
|
||||
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.fragment.EpisodeInfoFragment
|
||||
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment
|
||||
import ac.mdiq.podcini.ui.fragment.FeedInfoFragment
|
||||
import ac.mdiq.podcini.ui.utils.ShownotesCleaner
|
||||
import ac.mdiq.podcini.ui.utils.ThemeUtils
|
||||
import ac.mdiq.podcini.ui.view.EpisodeViewHolder
|
||||
import ac.mdiq.podcini.ui.view.ShownotesWebView
|
||||
import ac.mdiq.podcini.util.EventFlow
|
||||
import ac.mdiq.podcini.util.FlowEvent
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev
|
||||
import ac.mdiq.podcini.util.MiscFormatter.formatForAccessibility
|
||||
import android.R.color
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.text.Layout
|
||||
import android.text.TextUtils
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import android.util.Log
|
||||
import android.view.*
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import coil.imageLoader
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageRequest
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.skydoves.balloon.ArrowOrientation
|
||||
import com.skydoves.balloon.ArrowOrientationRules
|
||||
import com.skydoves.balloon.Balloon
|
||||
import com.skydoves.balloon.BalloonAnimation
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.dankito.readability4j.extended.Readability4JExtended
|
||||
import okhttp3.Request.Builder
|
||||
import java.io.File
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* 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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -48,6 +48,7 @@ import android.text.SpannableString
|
|||
import android.text.style.ForegroundColorSpan
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
|
@ -73,6 +74,7 @@ import java.io.IOException
|
|||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 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)
|
||||
if (results.isEmpty()) return@launch
|
||||
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)) {
|
||||
url = result.feedUrl
|
||||
break
|
||||
|
@ -268,9 +270,8 @@ class OnlineFeedViewFragment : Fragment() {
|
|||
val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs.first())
|
||||
Logd(TAG, "startFeedBuilding result1: $channelTabInfo ${channelTabInfo.relatedItems.size}")
|
||||
selectedDownloadUrl = prepareUrl(url)
|
||||
// selectedDownloadUrl = url
|
||||
val feed_ = Feed(selectedDownloadUrl, null)
|
||||
feed_.id = 1234567889L
|
||||
feed_.id = Feed.newId()
|
||||
feed_.type = Feed.FeedType.YOUTUBE.name
|
||||
feed_.hasVideoMedia = true
|
||||
feed_.title = channelInfo.name
|
||||
|
@ -472,6 +473,7 @@ class OnlineFeedViewFragment : Fragment() {
|
|||
feed.id = 0L
|
||||
for (item in feed.episodes) {
|
||||
item.id = 0L
|
||||
item.media?.id = 0L
|
||||
item.feedId = null
|
||||
item.feed = feed
|
||||
val media = item.media
|
||||
|
@ -529,8 +531,10 @@ class OnlineFeedViewFragment : Fragment() {
|
|||
Logd(TAG, "showEpisodes ${episodes.size}")
|
||||
if (episodes.isEmpty()) return
|
||||
episodes.sortByDescending { it.pubDate }
|
||||
var id_ = Feed.newId()
|
||||
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)
|
||||
(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 {
|
||||
const val ARG_FEEDURL: String = "arg.feedurl"
|
||||
const val ARG_WAS_MANUAL_URL: String = "manual_url"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -95,7 +95,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
|
|||
}
|
||||
if (videoControlsShowing) {
|
||||
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
|
||||
}
|
||||
return@OnTouchListener true
|
||||
|
@ -135,7 +135,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
|
|||
if (videoControlsShowing) {
|
||||
Logd(TAG, "Hiding video controls")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -398,7 +398,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
|
|||
fun toggleVideoControlsVisibility() {
|
||||
if (videoControlsShowing) {
|
||||
hideVideoControls(true)
|
||||
if (videoMode == VideoplayerActivity.VideoMode.FULL_SCREEN_VIEW) {
|
||||
if (videoMode == VideoMode.FULL_SCREEN_VIEW) {
|
||||
(activity as AppCompatActivity).supportActionBar?.hide()
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -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
|
||||
|
||||
* 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
|
||||
|
||||
# 6.5.1
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue