diff --git a/README.md b/README.md index a224a1a6..b32319ef 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,9 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c * default video player mode setting in preferences * when video mode is set to audio only, click on image on audio player on a video episode brings up the normal player detailed view * "Prefer streaming over download" is now on setting of individual feed +* added setting in individual feed to play audio only for video feeds, + * an added benefit for setting it enables Youtube media to only stream audio content, saving bandwidth. + * this differs from switching to "Audio only" on each episode, in which case, video is also streamed * Multiple queues can be used: 5 queues are provided by default, user can rename or add up to 10 queues * on app startup, the most recently updated queue is set to curQueue * any episodes can be easily added/moved to the active or any designated queues @@ -100,10 +103,11 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c * on action bar of FeedEpisodes view there is a direct access to Queue * Long-press filter button in FeedEpisodes view enables/disables filters without changing filter settings * History view shows time of last play, and allows filters and sorts + ### Podcast/Episode * New share notes menu option on various episode views -* Every feed can be associated with a queue allowing downloaded media to be added to the queue +* Every feed (podcast) can be associated with a queue allowing downloaded media to be added to the queue * FeedInfo view offers a link for direct search of feeds related to author * FeedInfo view has button showing number of episodes to open the FeedEpisodes view * FeedInfo view has feed setting in the header diff --git a/app/build.gradle b/app/build.gradle index 5e4d59b3..e02802c4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { testApplicationId "ac.mdiq.podcini.tests" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - versionCode 3020236 - versionName "6.5.2" + versionCode 3020237 + versionName "6.5.3" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/VistaDownloaderImpl.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/VistaDownloaderImpl.kt index 47d6495b..981fe8a1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/VistaDownloaderImpl.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/download/VistaDownloaderImpl.kt @@ -1,35 +1,43 @@ package ac.mdiq.podcini.net.download +import ac.mdiq.podcini.net.download.service.PodciniHttpClient.UserAgentInterceptor import ac.mdiq.podcini.storage.algorithms.InfoCache -import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.config.ClientConfig import ac.mdiq.vista.extractor.downloader.Downloader import ac.mdiq.vista.extractor.downloader.Request import ac.mdiq.vista.extractor.downloader.Response import ac.mdiq.vista.extractor.exceptions.ReCaptchaException import android.content.Context +import android.net.TrafficStats import androidx.preference.PreferenceManager -import okhttp3.Cache +import okhttp3.Interceptor +import okhttp3.Interceptor.Chain import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request.Builder import okhttp3.RequestBody -import java.io.File import java.io.IOException import java.util.* import java.util.concurrent.TimeUnit import java.util.stream.Collectors import java.util.stream.Stream -class VistaDownloaderImpl private constructor(builder: OkHttpClient.Builder) : Downloader() { +class VistaDownloaderImpl private constructor(val builder: OkHttpClient.Builder) : Downloader() { private val mCookies: MutableMap = HashMap() - private val client: OkHttpClient = builder - .readTimeout(30, TimeUnit.SECONDS) -// .cache(Cache(File(context.getExternalCacheDir(), "okhttp"), 16 * 1024 * 1024)) - .build() + private val client: OkHttpClient + get() { + builder.readTimeout(30, TimeUnit.SECONDS) + builder.networkInterceptors().add(object : Interceptor { + override fun intercept(chain: Chain): okhttp3.Response { + TrafficStats.setThreadStatsTag(Thread.currentThread().id.toInt()) + return chain.proceed(chain.request()) + } + } ) + return builder.build() + } private fun getCookies(url: String): String { val youtubeCookie = if (url.contains(YOUTUBE_DOMAIN)) getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) else null - // Recaptcha cookie is always added TODO: not sure if this is necessary return Stream.of(youtubeCookie, getCookie("recaptcha_cookies")) .filter { obj: String? -> Objects.nonNull(obj) } @@ -73,11 +81,8 @@ class VistaDownloaderImpl private constructor(builder: OkHttpClient.Builder) : D try { val response = head(url) return response.getHeader("Content-Length")!!.toLong() - } catch (e: NumberFormatException) { - throw IOException("Invalid content length", e) - } catch (e: ReCaptchaException) { - throw IOException(e) - } + } catch (e: NumberFormatException) { throw IOException("Invalid content length", e) + } catch (e: ReCaptchaException) { throw IOException(e) } } @Throws(IOException::class, ReCaptchaException::class) @@ -115,10 +120,8 @@ class VistaDownloaderImpl private constructor(builder: OkHttpClient.Builder) : D response.close() throw ReCaptchaException("reCaptcha Challenge requested", url) } - val body = response.body val responseBodyToReturn: String? = body?.string() - val latestUrl = response.request.url.toString() return Response(response.code, response.message, response.headers.toMultimap(), responseBodyToReturn, latestUrl) } catch (e: Throwable) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/PodciniHttpClient.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/PodciniHttpClient.kt index de3a762c..c4e694c6 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/PodciniHttpClient.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/PodciniHttpClient.kt @@ -144,20 +144,15 @@ object PodciniHttpClient { init { try { var sslContext: SSLContext - - try { - sslContext = SSLContext.getInstance("TLSv1.3") + try { sslContext = SSLContext.getInstance("TLSv1.3") } catch (e: NoSuchAlgorithmException) { e.printStackTrace() // In the play flavor (security provider can vary), some devices only support TLSv1.2. sslContext = SSLContext.getInstance("TLSv1.2") } - sslContext.init(null, arrayOf(trustManager), null) factory = sslContext.socketFactory - } catch (e: GeneralSecurityException) { - e.printStackTrace() - } + } catch (e: GeneralSecurityException) { e.printStackTrace() } } override fun getDefaultCipherSuites(): Array { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt index 46752f1d..27f94405 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt @@ -352,8 +352,8 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP if (streamurl != null) { val media = curMedia if (media is EpisodeMedia) { - val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) } - runBlocking { deferred.await() } + val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) } + if (startWhenPrepared) runBlocking { deferred.await() } // val preferences = media.episodeOrFetch()?.feed?.preferences // setDataSource(metadata, streamurl, preferences?.username, preferences?.password) } else setDataSource(metadata, streamurl, null, null) @@ -805,14 +805,16 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP } private fun initLoudnessEnhancer(audioStreamId: Int) { - val newEnhancer = LoudnessEnhancer(audioStreamId) - val oldEnhancer = loudnessEnhancer - if (oldEnhancer != null) { - newEnhancer.setEnabled(oldEnhancer.enabled) - if (oldEnhancer.enabled) newEnhancer.setTargetGain(oldEnhancer.targetGain.toInt()) - oldEnhancer.release() + runOnIOScope { + val newEnhancer = LoudnessEnhancer(audioStreamId) + val oldEnhancer = loudnessEnhancer + if (oldEnhancer != null) { + newEnhancer.setEnabled(oldEnhancer.enabled) + if (oldEnhancer.enabled) newEnhancer.setTargetGain(oldEnhancer.targetGain.toInt()) + oldEnhancer.release() + } + loudnessEnhancer = newEnhancer } - loudnessEnhancer = newEnhancer } fun cleanup() { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt index 1450c6c3..b689adc6 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt @@ -74,6 +74,7 @@ import android.os.Build.VERSION_CODES import android.service.quicksettings.TileService import android.util.Log import android.view.KeyEvent +import android.view.KeyEvent.KEYCODE_MEDIA_STOP import android.view.ViewConfiguration import android.webkit.URLUtil import android.widget.Toast @@ -579,7 +580,7 @@ class PlaybackService : MediaLibraryService() { override fun onMediaButtonEvent(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, intent: Intent): Boolean { val keyEvent = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) intent.extras!!.getParcelable(EXTRA_KEY_EVENT, KeyEvent::class.java) else intent.extras!!.getParcelable(EXTRA_KEY_EVENT) as? KeyEvent - Log.d(TAG, "onMediaButtonEvent ${keyEvent?.keyCode}") + Logd(TAG, "onMediaButtonEvent ${keyEvent?.keyCode}") if (keyEvent != null && keyEvent.action == KeyEvent.ACTION_DOWN && keyEvent.repeatCount == 0) { val keyCode = keyEvent.keyCode @@ -681,6 +682,7 @@ class PlaybackService : MediaLibraryService() { } fun recreateMediaPlayer() { + Logd(TAG, "recreateMediaPlayer") val media = curMedia var wasPlaying = false if (mPlayer != null) { @@ -691,6 +693,7 @@ class PlaybackService : MediaLibraryService() { mPlayer = CastPsmp.getInstanceIfConnected(this, mediaPlayerCallback) if (mPlayer == null) mPlayer = LocalMediaPlayer(applicationContext, mediaPlayerCallback) // Cast not supported or not connected + Logd(TAG, "recreateMediaPlayer wasPlaying: $wasPlaying") if (media != null) mPlayer!!.playMediaObject(media, !media.localFileAvailable(), wasPlaying, true) isCasting = mPlayer!!.isCasting() } @@ -788,7 +791,7 @@ class PlaybackService : MediaLibraryService() { handleKeycode(keycode, !hardwareButton) return super.onStartCommand(intent, flags, startId) } - keyEvent != null && keyEvent.keyCode != -1 -> { + keyEvent?.keyCode == KEYCODE_MEDIA_STOP -> { Logd(TAG, "onStartCommand Received button event: ${keyEvent.keyCode}") handleKeycode(keyEvent.keyCode, !hardwareButton) return super.onStartCommand(intent, flags, startId) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt index 8b0b2391..71fb62c8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt @@ -1,23 +1,88 @@ package ac.mdiq.podcini.ui.adapter import ac.mdiq.podcini.R +import ac.mdiq.podcini.databinding.EpisodeHomeFragmentBinding +import ac.mdiq.podcini.databinding.EpisodeInfoFragmentBinding +import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient +import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface +import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource +import ac.mdiq.podcini.net.utils.NetworkUtils.isEpisodeHeadDownloadAllowed +import ac.mdiq.podcini.playback.base.InTheatre +import ac.mdiq.podcini.playback.base.InTheatre.curMedia +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo +import ac.mdiq.podcini.preferences.UsageStatistics +import ac.mdiq.podcini.preferences.UserPreferences +import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope +import ac.mdiq.podcini.storage.database.RealmDB.unmanaged +import ac.mdiq.podcini.storage.database.RealmDB.upsert +import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.Episode +import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.Feed +import ac.mdiq.podcini.storage.model.MediaType +import ac.mdiq.podcini.storage.utils.DurationConverter +import ac.mdiq.podcini.storage.utils.ImageResourceUtils +import ac.mdiq.podcini.ui.actions.actionbutton.* +import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler import ac.mdiq.podcini.ui.activity.MainActivity -import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment +import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment import ac.mdiq.podcini.ui.fragment.FeedInfoFragment +import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.utils.ThemeUtils import ac.mdiq.podcini.ui.view.EpisodeViewHolder +import ac.mdiq.podcini.ui.view.ShownotesWebView +import ac.mdiq.podcini.util.EventFlow +import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev +import ac.mdiq.podcini.util.MiscFormatter.formatForAccessibility import android.R.color import android.app.Activity +import android.content.Context +import android.os.Build import android.os.Bundle -import android.view.InputDevice -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup +import android.speech.tts.TextToSpeech +import android.text.Layout +import android.text.TextUtils +import android.text.format.Formatter.formatShortFileSize +import android.util.Log +import android.view.* +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.appcompat.widget.Toolbar +import androidx.core.app.ShareCompat +import androidx.core.text.HtmlCompat +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi +import coil.imageLoader +import coil.request.ErrorResult +import coil.request.ImageRequest +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.snackbar.Snackbar +import com.skydoves.balloon.ArrowOrientation +import com.skydoves.balloon.ArrowOrientationRules +import com.skydoves.balloon.Balloon +import com.skydoves.balloon.BalloonAnimation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.dankito.readability4j.extended.Readability4JExtended +import okhttp3.Request.Builder +import java.io.File import java.lang.ref.WeakReference +import java.util.* +import kotlin.collections.ArrayList +import kotlin.math.max /** * List adapter for the list of new episodes. @@ -198,4 +263,740 @@ open class EpisodesAdapter(mainActivity: MainActivity, var refreshFragPosCallbac val item = if (index in episodes.indices) episodes[index] else null return item } + + /** + * Displays information about an Episode (FeedItem) and actions. + */ + @UnstableApi class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { + private var _binding: EpisodeInfoFragmentBinding? = null + private val binding get() = _binding!! + + private var homeFragment: EpisodeHomeFragment? = null + + private var itemLoaded = false + private var episode: Episode? = null // managed + private var webviewData: String? = null + + private lateinit var shownotesCleaner: ShownotesCleaner + private lateinit var toolbar: MaterialToolbar + private lateinit var webvDescription: ShownotesWebView + private lateinit var imgvCover: ImageView + + private lateinit var butAction1: ImageView + private lateinit var butAction2: ImageView + + private var actionButton1: EpisodeActionButton? = null + private var actionButton2: EpisodeActionButton? = null + + @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + super.onCreateView(inflater, container, savedInstanceState) + + _binding = EpisodeInfoFragmentBinding.inflate(inflater, container, false) + Logd(TAG, "fragment onCreateView") + + toolbar = binding.toolbar + toolbar.title = "" + toolbar.inflateMenu(R.menu.feeditem_options) + toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } + toolbar.setOnMenuItemClickListener(this) + + binding.txtvPodcast.setOnClickListener { openPodcast() } + binding.txtvTitle.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL) + binding.txtvTitle.ellipsize = TextUtils.TruncateAt.END + webvDescription = binding.webvDescription + webvDescription.setTimecodeSelectedListener { time: Int? -> + val cMedia = curMedia + if (episode?.media?.getIdentifier() == cMedia?.getIdentifier()) seekTo(time ?: 0) + else (activity as MainActivity).showSnackbarAbovePlayer(R.string.play_this_to_seek_position, Snackbar.LENGTH_LONG) + } + registerForContextMenu(webvDescription) + + imgvCover = binding.imgvCover + imgvCover.setOnClickListener { openPodcast() } + butAction1 = binding.butAction1 + butAction2 = binding.butAction2 + + binding.homeButton.setOnClickListener { + if (!episode?.link.isNullOrEmpty()) { + homeFragment = EpisodeHomeFragment.newInstance(episode!!) + (activity as MainActivity).loadChildFragment(homeFragment!!) + } else Toast.makeText(context, "Episode link is not valid ${episode?.link}", Toast.LENGTH_LONG).show() + } + + butAction1.setOnClickListener(View.OnClickListener { + when { + actionButton1 is StreamActionButton && !UserPreferences.isStreamOverDownload + && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_STREAM) -> { + showOnDemandConfigBalloon(true) + return@OnClickListener + } + actionButton1 == null -> return@OnClickListener // Not loaded yet + else -> actionButton1?.onClick(requireContext()) + } + }) + butAction2.setOnClickListener(View.OnClickListener { + when { + actionButton2 is DownloadActionButton && UserPreferences.isStreamOverDownload + && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_DOWNLOAD) -> { + showOnDemandConfigBalloon(false) + return@OnClickListener + } + actionButton2 == null -> return@OnClickListener // Not loaded yet + else -> actionButton2?.onClick(requireContext()) + } + }) + shownotesCleaner = ShownotesCleaner(requireContext()) + load() + return binding.root + } + + override fun onStart() { + super.onStart() + procFlowEvents() + } + + override fun onStop() { + super.onStop() + cancelFlowEvents() + } + + @OptIn(UnstableApi::class) + private fun showOnDemandConfigBalloon(offerStreaming: Boolean) { + val isLocaleRtl = (TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL) + val balloon: Balloon = Balloon.Builder(requireContext()) + .setArrowOrientation(ArrowOrientation.TOP) + .setArrowOrientationRules(ArrowOrientationRules.ALIGN_FIXED) + .setArrowPosition(0.25f + (if (isLocaleRtl xor offerStreaming) 0f else 0.5f)) + .setWidthRatio(1.0f) + .setMarginLeft(8) + .setMarginRight(8) + .setBackgroundColor(ThemeUtils.getColorFromAttr(requireContext(), com.google.android.material.R.attr.colorSecondary)) + .setBalloonAnimation(BalloonAnimation.OVERSHOOT) + .setLayout(R.layout.popup_bubble_view) + .setDismissWhenTouchOutside(true) + .setLifecycleOwner(this) + .build() + val ballonView = balloon.getContentView() + val positiveButton: Button = ballonView.findViewById(R.id.balloon_button_positive) + val negativeButton: Button = ballonView.findViewById(R.id.balloon_button_negative) + val message: TextView = ballonView.findViewById(R.id.balloon_message) + message.setText(if (offerStreaming) R.string.on_demand_config_stream_text + else R.string.on_demand_config_download_text) + positiveButton.setOnClickListener { + UserPreferences.isStreamOverDownload = offerStreaming + // Update all visible lists to reflect new streaming action button + // TODO: need another event type? + EventFlow.postEvent(FlowEvent.EpisodePlayedEvent()) + (activity as MainActivity).showSnackbarAbovePlayer(R.string.on_demand_config_setting_changed, Snackbar.LENGTH_SHORT) + balloon.dismiss() + } + negativeButton.setOnClickListener { + UsageStatistics.doNotAskAgain(UsageStatistics.ACTION_STREAM) // Type does not matter. Both are silenced. + balloon.dismiss() + } + balloon.showAlignBottom(butAction1, 0, (-12 * resources.displayMetrics.density).toInt()) + } + + @UnstableApi override fun onMenuItemClick(menuItem: MenuItem): Boolean { + when (menuItem.itemId) { + R.id.share_notes -> { + if (episode == null) return false + val notes = episode!!.description + if (!notes.isNullOrEmpty()) { + val shareText = if (Build.VERSION.SDK_INT >= 24) HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + else HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + val context = requireContext() + val intent = ShareCompat.IntentBuilder(context) + .setType("text/plain") + .setText(shareText) + .setChooserTitle(R.string.share_notes_label) + .createChooserIntent() + context.startActivity(intent) + } + return true + } + else -> { + if (episode == null) return false + return EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, episode!!) + } + } + } + + @UnstableApi override fun onResume() { + super.onResume() + if (itemLoaded) { + binding.progbarLoading.visibility = View.GONE + updateAppearance() + } + } + + @OptIn(UnstableApi::class) override fun onDestroyView() { + Logd(TAG, "onDestroyView") + binding.root.removeView(webvDescription) + episode = null + webvDescription.clearHistory() + webvDescription.clearCache(true) + webvDescription.clearView() + webvDescription.destroy() + _binding = null + super.onDestroyView() + } + + @UnstableApi private fun onFragmentLoaded() { + if (webviewData != null && !itemLoaded) + webvDescription.loadDataWithBaseURL("https://127.0.0.1", webviewData!!, "text/html", "utf-8", "about:blank") +// if (item?.link != null) binding.webView.loadUrl(item!!.link!!) + updateAppearance() + } + + private fun prepareMenu() { + if (episode!!.media != null) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast) + // these are already available via button1 and button2 + else EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast, R.id.mark_read_item, R.id.visit_website_item) + } + + @UnstableApi private fun updateAppearance() { + if (episode == null) { + Logd(TAG, "updateAppearance item is null") + return + } + prepareMenu() + + if (episode!!.feed != null) binding.txtvPodcast.text = episode!!.feed!!.title + binding.txtvTitle.text = episode!!.title + binding.itemLink.text = episode!!.link + + if (episode?.pubDate != null) { + val pubDateStr = formatAbbrev(context, Date(episode!!.pubDate)) + binding.txtvPublished.text = pubDateStr + binding.txtvPublished.setContentDescription(formatForAccessibility(Date(episode!!.pubDate))) + } + + val media = episode?.media + when { + media == null -> binding.txtvSize.text = "" + media.size > 0 -> binding.txtvSize.text = formatShortFileSize(activity, media.size) + isEpisodeHeadDownloadAllowed && !media.checkedOnSizeButUnknown() -> { + binding.txtvSize.text = "{faw_spinner}" +// Iconify.addIcons(size) + lifecycleScope.launch { + val sizeValue = getMediaSize(episode) + if (sizeValue <= 0) binding.txtvSize.text = "" + else binding.txtvSize.text = formatShortFileSize(activity, sizeValue) + } + } + else -> binding.txtvSize.text = "" + } + + val imgLocFB = ImageResourceUtils.getFallbackImageLocation(episode!!) + val imageLoader = imgvCover.context.imageLoader + val imageRequest = ImageRequest.Builder(requireContext()) + .data(episode!!.imageLocation) + .placeholder(R.color.light_gray) + .listener(object : ImageRequest.Listener { + override fun onError(request: ImageRequest, result: ErrorResult) { + val fallbackImageRequest = ImageRequest.Builder(requireContext()) + .data(imgLocFB) + .setHeader("User-Agent", "Mozilla/5.0") + .error(R.mipmap.ic_launcher) + .target(imgvCover) + .build() + imageLoader.enqueue(fallbackImageRequest) + } + }) + .target(imgvCover) + .build() + imageLoader.enqueue(imageRequest) + + updateButtons() + } + + @UnstableApi private fun updateButtons() { + binding.circularProgressBar.visibility = View.GONE + val dls = DownloadServiceInterface.get() + if (episode != null && episode!!.media != null && episode!!.media!!.downloadUrl != null) { + val url = episode!!.media!!.downloadUrl!! + if (dls != null && dls.isDownloadingEpisode(url)) { + binding.circularProgressBar.visibility = View.VISIBLE + binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), episode) + binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url)) + } + } + + val media: EpisodeMedia? = episode?.media + if (media == null) { + if (episode != null) { +// actionButton1 = VisitWebsiteActionButton(item!!) + butAction1.visibility = View.INVISIBLE + actionButton2 = VisitWebsiteActionButton(episode!!) + } + binding.noMediaLabel.visibility = View.VISIBLE + } else { + binding.noMediaLabel.visibility = View.GONE + if (media.getDuration() > 0) { + binding.txtvDuration.text = DurationConverter.getDurationStringLong(media.getDuration()) + binding.txtvDuration.setContentDescription(DurationConverter.getDurationStringLocalized(requireContext(), media.getDuration().toLong())) + } + if (episode != null) { + actionButton1 = when { + media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode!!) + InTheatre.isCurrentlyPlaying(media) -> PauseActionButton(episode!!) + episode!!.feed != null && episode!!.feed!!.isLocalFeed -> PlayLocalActionButton(episode!!) + media.downloaded -> PlayActionButton(episode!!) + else -> StreamActionButton(episode!!) + } + actionButton2 = when { + media.getMediaType() == MediaType.FLASH || episode!!.feed?.type == Feed.FeedType.YOUTUBE.name -> VisitWebsiteActionButton(episode!!) + dls != null && media.downloadUrl != null && dls.isDownloadingEpisode(media.downloadUrl!!) -> CancelDownloadActionButton(episode!!) + !media.downloaded -> DownloadActionButton(episode!!) + else -> DeleteActionButton(episode!!) + } +// if (actionButton2 != null && media.getMediaType() == MediaType.FLASH) actionButton2!!.visibility = View.GONE + } + } + + if (actionButton1 != null) { + butAction1.setImageResource(actionButton1!!.getDrawable()) + butAction1.visibility = actionButton1!!.visibility + } + if (actionButton2 != null) { + butAction2.setImageResource(actionButton2!!.getDrawable()) + butAction2.visibility = actionButton2!!.visibility + } + } + + override fun onContextItemSelected(item: MenuItem): Boolean { + return webvDescription.onContextItemSelected(item) + } + + @OptIn(UnstableApi::class) private fun openPodcast() { + if (episode?.feedId == null) return + + val fragment: Fragment = FeedEpisodesFragment.newInstance(episode!!.feedId!!) + (activity as MainActivity).loadChildFragment(fragment) + } + + private var eventSink: Job? = null + private var eventStickySink: Job? = null + private fun cancelFlowEvents() { + eventSink?.cancel() + eventSink = null + eventStickySink?.cancel() + eventStickySink = null + } + private fun procFlowEvents() { + if (eventSink == null) eventSink = lifecycleScope.launch { + EventFlow.events.collectLatest { event -> + Logd(TAG, "Received event: ${event.TAG}") + when (event) { + is FlowEvent.QueueEvent -> onQueueEvent(event) + is FlowEvent.FavoritesEvent -> onFavoriteEvent(event) + is FlowEvent.EpisodeEvent -> onEpisodeEvent(event) + is FlowEvent.PlayerSettingsEvent -> updateButtons() + is FlowEvent.EpisodePlayedEvent -> load() + else -> {} + } + } + } + if (eventStickySink == null) eventStickySink = lifecycleScope.launch { + EventFlow.stickyEvents.collectLatest { event -> + Logd(TAG, "Received event: ${event.TAG}") + when (event) { + is FlowEvent.EpisodeDownloadEvent -> onEpisodeDownloadEvent(event) + else -> {} + } + } + } + } + + private fun onFavoriteEvent(event: FlowEvent.FavoritesEvent) { + if (episode?.id == event.episode.id) { + episode = unmanaged(episode!!) + episode!!.isFavorite = event.episode.isFavorite +// episode = event.episode + prepareMenu() + } + } + + private fun onQueueEvent(event: FlowEvent.QueueEvent) { + var i = 0 + val size: Int = event.episodes.size + while (i < size) { + val item_ = event.episodes[i] + if (item_.id == episode?.id) { +// episode = unmanaged(item_) +// episode = item_ + prepareMenu() + break + } + i++ + } + } + + private fun onEpisodeEvent(event: FlowEvent.EpisodeEvent) { +// Logd(TAG, "onEventMainThread() called with ${event.TAG}") + if (this.episode == null) return + for (item in event.episodes) { + if (this.episode!!.id == item.id) { + load() + return + } + } + } + + private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { + if (episode == null || episode!!.media == null) return + if (!event.urls.contains(episode!!.media!!.downloadUrl)) return + if (itemLoaded && activity != null) updateButtons() + } + + private var loadItemsRunning = false + @UnstableApi private fun load() { + if (!itemLoaded) binding.progbarLoading.visibility = View.VISIBLE + Logd(TAG, "load() called") + if (!loadItemsRunning) { + loadItemsRunning = true + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + if (episode != null) { + val duration = episode!!.media?.getDuration() ?: Int.MAX_VALUE + webviewData = shownotesCleaner.processShownotes(episode!!.description ?: "", duration) + } + } + withContext(Dispatchers.Main) { + binding.progbarLoading.visibility = View.GONE + onFragmentLoaded() + itemLoaded = true + } + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } finally { + loadItemsRunning = false + } + } + } + } + + fun setItem(item_: Episode) { + episode = item_ + } + + /** + * Displays information about an Episode (FeedItem) and actions. + */ + class EpisodeHomeFragment : Fragment() { + private var _binding: EpisodeHomeFragmentBinding? = null + private val binding get() = _binding!! + + private var startIndex = 0 + private var ttsSpeed = 1.0f + + private lateinit var toolbar: MaterialToolbar + + private var readerText: String? = null + private var cleanedNotes: String? = null + private var readerhtml: String? = null + private var readMode = true + private var ttsPlaying = false + private var jsEnabled = false + + private var tts: TextToSpeech? = null + private var ttsReady = false + + @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + super.onCreateView(inflater, container, savedInstanceState) + _binding = EpisodeHomeFragmentBinding.inflate(inflater, container, false) + Logd(TAG, "fragment onCreateView") + toolbar = binding.toolbar + toolbar.title = "" + toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } + toolbar.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED) + + if (!episode?.link.isNullOrEmpty()) showContent() + else { + Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() + parentFragmentManager.popBackStack() + } + binding.webView.apply { + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + val isEmpty = view?.title.isNullOrEmpty() && view?.contentDescription.isNullOrEmpty() + if (isEmpty) Logd(TAG, "content is empty") + } + } + } + updateAppearance() + return binding.root + } + + @OptIn(UnstableApi::class) private fun switchMode() { + readMode = !readMode + showContent() + updateAppearance() + } + + @OptIn(UnstableApi::class) private fun showReaderContent() { + runOnIOScope { + if (!episode?.link.isNullOrEmpty()) { + if (cleanedNotes == null) { + if (episode?.transcript == null) { + val url = episode!!.link!! + val htmlSource = fetchHtmlSource(url) + val article = Readability4JExtended(episode?.link!!, htmlSource).parse() + readerText = article.textContent +// Log.d(TAG, "readability4J: ${article.textContent}") + readerhtml = article.contentWithDocumentsCharsetOrUtf8 + } else { + readerhtml = episode!!.transcript + readerText = HtmlCompat.fromHtml(readerhtml!!, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + } + if (!readerhtml.isNullOrEmpty()) { + val shownotesCleaner = ShownotesCleaner(requireContext()) + cleanedNotes = shownotesCleaner.processShownotes(readerhtml!!, 0) + episode = upsertBlk(episode!!) { + it.setTranscriptIfLonger(readerhtml) + } +// persistEpisode(episode) + } + } + } + if (!cleanedNotes.isNullOrEmpty()) { + if (!ttsReady) initializeTTS(requireContext()) + withContext(Dispatchers.Main) { + binding.readerView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes ?: "No notes", + "text/html", "UTF-8", null) + binding.readerView.visibility = View.VISIBLE + binding.webView.visibility = View.GONE + } + } else { + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() + } + } + } + } + + private fun initializeTTS(context: Context) { + Logd(TAG, "starting TTS") + if (tts == null) { + tts = TextToSpeech(context) { status: Int -> + if (status == TextToSpeech.SUCCESS) { + if (episode?.feed?.language != null) { + val result = tts?.setLanguage(Locale(episode!!.feed!!.language!!)) + if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { + Log.w(TAG, "TTS language not supported ${episode?.feed?.language}") + requireActivity().runOnUiThread { + Toast.makeText(context, getString(R.string.language_not_supported_by_tts) + " ${episode?.feed?.language}", Toast.LENGTH_LONG).show() + } + } + } + ttsReady = true +// semaphore.release() + Logd(TAG, "TTS init success") + } else { + Log.w(TAG, "TTS init failed") + requireActivity().runOnUiThread { Toast.makeText(context, R.string.tts_init_failed, Toast.LENGTH_LONG).show() } + } + } + } + } + + private fun showWebContent() { + if (!episode?.link.isNullOrEmpty()) { + binding.webView.settings.javaScriptEnabled = jsEnabled + Logd(TAG, "currentItem!!.link ${episode!!.link}") + binding.webView.loadUrl(episode!!.link!!) + binding.readerView.visibility = View.GONE + binding.webView.visibility = View.VISIBLE + } else Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() + } + + private fun showContent() { + if (readMode) showReaderContent() + else showWebContent() + } + + private val menuProvider = object: MenuProvider { + override fun onPrepareMenu(menu: Menu) { +// super.onPrepareMenu(menu) + Logd(TAG, "onPrepareMenu called") + val textSpeech = menu.findItem(R.id.text_speech) + textSpeech?.isVisible = readMode && tts != null + if (textSpeech?.isVisible == true) { + if (ttsPlaying) textSpeech.setIcon(R.drawable.ic_pause) else textSpeech.setIcon(R.drawable.ic_play_24dp) + } + menu.findItem(R.id.share_notes)?.setVisible(readMode) + menu.findItem(R.id.switchJS)?.setVisible(!readMode) + val btn = menu.findItem(R.id.switch_home) + if (readMode) btn?.setIcon(R.drawable.baseline_home_24) + else btn?.setIcon(R.drawable.outline_home_24) + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.episode_home, menu) + onPrepareMenu(menu) + } + + @OptIn(UnstableApi::class) override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + when (menuItem.itemId) { + R.id.switch_home -> { + switchMode() + return true + } + R.id.switchJS -> { + jsEnabled = !jsEnabled + showWebContent() + return true + } + R.id.text_speech -> { + Logd(TAG, "text_speech selected: $readerText") + if (tts != null) { + if (tts!!.isSpeaking) tts?.stop() + if (!ttsPlaying) { + ttsPlaying = true + if (!readerText.isNullOrEmpty()) { + ttsSpeed = episode?.feed?.preferences?.playSpeed ?: 1.0f + tts?.setSpeechRate(ttsSpeed) + while (startIndex < readerText!!.length) { + val endIndex = minOf(startIndex + MAX_CHUNK_LENGTH, readerText!!.length) + val chunk = readerText!!.substring(startIndex, endIndex) + tts?.speak(chunk, TextToSpeech.QUEUE_ADD, null, null) + startIndex += MAX_CHUNK_LENGTH + } + } + } else ttsPlaying = false + updateAppearance() + } else Toast.makeText(context, R.string.tts_not_available, Toast.LENGTH_LONG).show() + + return true + } + R.id.share_notes -> { + val notes = readerhtml + if (!notes.isNullOrEmpty()) { + val shareText = HtmlCompat.fromHtml(notes, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + val context = requireContext() + val intent = ShareCompat.IntentBuilder(context) + .setType("text/plain") + .setText(shareText) + .setChooserTitle(R.string.share_notes_label) + .createChooserIntent() + context.startActivity(intent) + } + return true + } + else -> { + return episode != null + } + } + } + } + + @UnstableApi override fun onResume() { + super.onResume() + updateAppearance() + } + + private fun cleatWebview(webview: WebView) { + binding.root.removeView(webview) + webview.clearHistory() + webview.clearCache(true) + webview.clearView() + webview.destroy() + } + + @OptIn(UnstableApi::class) override fun onDestroyView() { + Logd(TAG, "onDestroyView") + cleatWebview(binding.webView) + cleatWebview(binding.readerView) + _binding = null + tts?.stop() + tts?.shutdown() + tts = null + super.onDestroyView() + } + + @UnstableApi private fun updateAppearance() { + if (episode == null) { + Logd(TAG, "updateAppearance currentItem is null") + return + } +// onPrepareOptionsMenu(toolbar.menu) + toolbar.invalidateMenu() +// menuProvider.onPrepareMenu(toolbar.menu) + } + + companion object { + private val TAG: String = EpisodeHomeFragment::class.simpleName ?: "Anonymous" + private const val MAX_CHUNK_LENGTH = 2000 + + var episode: Episode? = null // unmanged + + fun newInstance(item: Episode): EpisodeHomeFragment { + val fragment = EpisodeHomeFragment() + Logd(TAG, "item.itemIdentifier ${item.identifier}") + if (item.identifier != episode?.identifier) episode = item + return fragment + } + } + } + + companion object { + private val TAG: String = EpisodeInfoFragment::class.simpleName ?: "Anonymous" + + private suspend fun getMediaSize(episode: Episode?) : Long { + return withContext(Dispatchers.IO) { + if (!isEpisodeHeadDownloadAllowed) return@withContext -1 + val media = episode?.media ?: return@withContext -1 + + var size = Int.MIN_VALUE.toLong() + when { + media.downloaded -> { + val url = media.getLocalMediaUrl() + if (!url.isNullOrEmpty()) { + val mediaFile = File(url) + if (mediaFile.exists()) size = mediaFile.length() + } + } + !media.checkedOnSizeButUnknown() -> { + // only query the network if we haven't already checked + + val url = media.downloadUrl + if (url.isNullOrEmpty()) return@withContext -1 + + val client = getHttpClient() + val httpReq: Builder = Builder().url(url).header("Accept-Encoding", "identity").head() + try { + val response = client.newCall(httpReq.build()).execute() + if (response.isSuccessful) { + val contentLength = response.header("Content-Length")?:"0" + try { + size = contentLength.toInt().toLong() + } catch (e: NumberFormatException) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + } catch (e: Exception) { + Log.e(TAG, Log.getStackTraceString(e)) + return@withContext -1 // better luck next time + } + } + } + // they didn't tell us the size, but we don't want to keep querying on it + upsert(episode) { + if (size <= 0) it.media?.setCheckedOnSizeButUnknown() + else it.media?.size = size + } + size + } + } + + fun newInstance(item: Episode): EpisodeInfoFragment { + val fragment = EpisodeInfoFragment() + fragment.setItem(item) + return fragment + } + } + } + } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeHomeFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeHomeFragment.kt deleted file mode 100644 index 00aecc72..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeHomeFragment.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt deleted file mode 100644 index 02cd6e00..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt index 9f01c5d4..f4d96f02 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt @@ -48,6 +48,7 @@ import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.util.Log import android.view.LayoutInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.AdapterView @@ -73,6 +74,7 @@ import java.io.IOException import java.net.HttpURLConnection import java.net.URL import kotlin.concurrent.Volatile +import kotlin.math.min /** * Downloads a feed from a feed URL and parses it. Subclasses can display the @@ -212,7 +214,7 @@ class OnlineFeedViewFragment : Fragment() { val results = searcher.search(query) if (results.isEmpty()) return@launch for (result in results) { - if (result?.feedUrl != null && result.author != null && result.author.equals(error.artistName, ignoreCase = true) + if (result.feedUrl != null && result.author != null && result.author.equals(error.artistName, ignoreCase = true) && result.title.equals(error.trackName, ignoreCase = true)) { url = result.feedUrl break @@ -268,9 +270,8 @@ class OnlineFeedViewFragment : Fragment() { val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs.first()) Logd(TAG, "startFeedBuilding result1: $channelTabInfo ${channelTabInfo.relatedItems.size}") selectedDownloadUrl = prepareUrl(url) -// selectedDownloadUrl = url val feed_ = Feed(selectedDownloadUrl, null) - feed_.id = 1234567889L + feed_.id = Feed.newId() feed_.type = Feed.FeedType.YOUTUBE.name feed_.hasVideoMedia = true feed_.title = channelInfo.name @@ -472,6 +473,7 @@ class OnlineFeedViewFragment : Fragment() { feed.id = 0L for (item in feed.episodes) { item.id = 0L + item.media?.id = 0L item.feedId = null item.feed = feed val media = item.media @@ -529,8 +531,10 @@ class OnlineFeedViewFragment : Fragment() { Logd(TAG, "showEpisodes ${episodes.size}") if (episodes.isEmpty()) return episodes.sortByDescending { it.pubDate } + var id_ = Feed.newId() for (i in 0.. = 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) { + episodeList.clear() + episodeList.addAll(episodeList_) + } + override fun loadData(): List { + if (episodeList.isEmpty()) return listOf() + return episodeList.subList(0, min(episodeList.size-1, page * EPISODES_PER_PAGE)) + } + override fun loadMoreData(page: Int): List { + 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): RemoteEpisodesFragment { + val i = RemoteEpisodesFragment() + i.setEpisodes(episodes) + return i + } + + } + } + companion object { const val ARG_FEEDURL: String = "arg.feedurl" const val ARG_WAS_MANUAL_URL: String = "manual_url" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/RemoteEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/RemoteEpisodesFragment.kt deleted file mode 100644 index ef2c5780..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/RemoteEpisodesFragment.kt +++ /dev/null @@ -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 = 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) { - episodeList.clear() - episodeList.addAll(episodeList_) - } - - override fun loadData(): List { - if (episodeList.isEmpty()) return listOf() - return episodeList.subList(0, min(episodeList.size-1, page * EPISODES_PER_PAGE)) - } - - override fun loadMoreData(page: Int): List { - 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): RemoteEpisodesFragment { - val i = RemoteEpisodesFragment() - i.setEpisodes(episodes) - return i - } - - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt index 944f3adf..cba4950c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt @@ -95,7 +95,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { } if (videoControlsShowing) { hideVideoControls(false) - if (videoMode == VideoplayerActivity.VideoMode.FULL_SCREEN_VIEW) (activity as AppCompatActivity).supportActionBar?.hide() + if (videoMode == VideoMode.FULL_SCREEN_VIEW) (activity as AppCompatActivity).supportActionBar?.hide() videoControlsShowing = false } return@OnTouchListener true @@ -135,7 +135,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { if (videoControlsShowing) { Logd(TAG, "Hiding video controls") hideVideoControls(true) - if (videoMode == VideoplayerActivity.VideoMode.FULL_SCREEN_VIEW) (activity as? AppCompatActivity)?.supportActionBar?.hide() + if (videoMode == VideoMode.FULL_SCREEN_VIEW) (activity as? AppCompatActivity)?.supportActionBar?.hide() videoControlsShowing = false } } @@ -398,7 +398,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { fun toggleVideoControlsVisibility() { if (videoControlsShowing) { hideVideoControls(true) - if (videoMode == VideoplayerActivity.VideoMode.FULL_SCREEN_VIEW) { + if (videoMode == VideoMode.FULL_SCREEN_VIEW) { (activity as AppCompatActivity).supportActionBar?.hide() } } else { diff --git a/changelog.md b/changelog.md index 60c3320a..620e0bf5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,13 @@ +# 6.5.3 + +* properly assigning ids to remote episodes in OnlineFeedView to resolve the issue of duplicates +* fixed possible startup hang when previous media was Youtube media +* the fixed for random starts in 6.4.0 conflicts with notification play/pause button, narrowed handling to only KEYCODE_MEDIA_STOP +* some fragment class restructuring + # 6.5.2 -* replace all url of http to https +* replaced all url of http to https * resolved the nasty issue of Youtube media not properly played in release app # 6.5.1 diff --git a/fastlane/metadata/android/en-US/changelogs/3020237.txt b/fastlane/metadata/android/en-US/changelogs/3020237.txt new file mode 100644 index 00000000..a997ba9d --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020237.txt @@ -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