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
|
* 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
|
||||||
|
|
|
@ -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 = ""
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.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"
|
||||||
|
|
|
@ -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) {
|
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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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